feat(server): get public user by id (#10434)

close CLOUD-160
This commit is contained in:
fengmk2
2025-03-06 15:25:06 +00:00
parent 7302c4f954
commit 289d3cd20e
13 changed files with 184 additions and 26 deletions

View File

@@ -152,6 +152,7 @@ test('should get public user by id', async t => {
t.not(publicUser, null); t.not(publicUser, null);
t.is(publicUser!.id, user.id); t.is(publicUser!.id, user.id);
t.true(!('password' in publicUser!)); t.true(!('password' in publicUser!));
t.true(!('email' in publicUser!));
}); });
test('should get public user by email', async t => { test('should get public user by email', async t => {
@@ -164,6 +165,35 @@ test('should get public user by email', async t => {
t.not(publicUser, null); t.not(publicUser, null);
t.is(publicUser!.id, user.id); t.is(publicUser!.id, user.id);
t.true(!('password' in publicUser!)); t.true(!('password' in publicUser!));
t.true(!('email' in publicUser!));
});
test('should get workspace user by id', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
});
const workspaceUser = await t.context.user.getWorkspaceUser(user.id);
t.not(workspaceUser, null);
t.is(workspaceUser!.id, user.id);
t.true(!('password' in workspaceUser!));
t.is(workspaceUser!.email, user.email);
});
test('should get workspace user by email', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
});
const workspaceUser = await t.context.user.getWorkspaceUserByEmail(
user.email
);
t.not(workspaceUser, null);
t.is(workspaceUser!.id, user.id);
t.true(!('password' in workspaceUser!));
t.is(workspaceUser!.email, user.email);
}); });
test('should get user by email', async t => { test('should get user by email', async t => {

View File

@@ -1,7 +1,14 @@
import { randomUUID } from 'node:crypto';
import type { TestFn } from 'ava'; import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import { createTestingApp, TestingApp, updateAvatar } from '../utils'; import {
createTestingApp,
getPublicUserById,
TestingApp,
updateAvatar,
} from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
app: TestingApp; app: TestingApp;
@@ -57,3 +64,43 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname); const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
t.deepEqual(newAvatarRes.body, Buffer.from('new')); t.deepEqual(newAvatarRes.body, Buffer.from('new'));
}); });
test('should be able to get public user by id', async t => {
const { app } = t.context;
const u1 = await app.signup();
const avatar = Buffer.from('test');
await updateAvatar(app, avatar);
const u2 = await app.signup();
// login user can access
let user1 = await getPublicUserById(app, u1.id);
t.truthy(user1);
t.is(user1!.id, u1.id);
t.is(user1!.name, u1.name);
t.truthy(user1!.avatarUrl);
let user2 = await getPublicUserById(app, u2.id);
t.deepEqual(user2, {
id: u2.id,
name: u2.name,
avatarUrl: null,
});
let user3 = await getPublicUserById(app, randomUUID());
t.is(user3, null);
// anonymous user can access
await app.logout();
user1 = await getPublicUserById(app, u1.id);
t.truthy(user1);
t.is(user1!.id, u1.id);
t.is(user1!.name, u1.name);
t.truthy(user1!.avatarUrl);
user2 = await getPublicUserById(app, u2.id);
t.deepEqual(user2, {
id: u2.id,
name: u2.name,
avatarUrl: null,
});
user3 = await getPublicUserById(app, randomUUID());
t.is(user3, null);
});

View File

@@ -1,3 +1,4 @@
import { PublicUserType } from '../../core/user';
import { TestingApp } from './testing-app'; import { TestingApp } from './testing-app';
export async function currentUser(app: TestingApp) { export async function currentUser(app: TestingApp) {
@@ -12,6 +13,25 @@ export async function currentUser(app: TestingApp) {
return res.currentUser; return res.currentUser;
} }
export async function getPublicUserById(
app: TestingApp,
id: string
): Promise<PublicUserType | null> {
const res = await app.gql(
`
query getPublicUserById($id: String!) {
publicUserById(id: $id) {
id
name
avatarUrl
}
}
`,
{ id }
);
return res.publicUserById;
}
export async function sendChangeEmail( export async function sendChangeEmail(
app: TestingApp, app: TestingApp,
email: string, email: string,

View File

@@ -13,4 +13,4 @@ import { UserManagementResolver, UserResolver } from './resolver';
}) })
export class UserModule {} export class UserModule {}
export { PublicUserType, UserType } from './types'; export { PublicUserType, UserType, WorkspaceUserType } from './types';

View File

@@ -27,6 +27,7 @@ import { validators } from '../utils/validators';
import { import {
DeleteAccount, DeleteAccount,
ManageUserInput, ManageUserInput,
PublicUserType,
RemoveAvatar, RemoveAvatar,
UpdateUserInput, UpdateUserInput,
UserOrLimitedUser, UserOrLimitedUser,
@@ -70,6 +71,19 @@ export class UserResolver {
}; };
} }
@Throttle('strict')
@Query(() => PublicUserType, {
name: 'publicUserById',
description: 'Get public user by id',
nullable: true,
})
@Public()
async getPublicUserById(
@Args('id', { type: () => String }) id: string
): Promise<PublicUserType | null> {
return await this.models.user.getPublicUser(id);
}
@Mutation(() => UserType, { @Mutation(() => UserType, {
name: 'uploadAvatar', name: 'uploadAvatar',
description: 'Upload user avatar', description: 'Upload user avatar',

View File

@@ -7,7 +7,7 @@ import {
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import type { User } from '@prisma/client'; import type { User } from '@prisma/client';
import { PublicUser } from '../../models'; import { PublicUser, WorkspaceUser } from '../../models';
import { type CurrentUser } from '../auth/session'; import { type CurrentUser } from '../auth/session';
@ObjectType() @ObjectType()
@@ -51,6 +51,18 @@ export class PublicUserType implements PublicUser {
@Field() @Field()
name!: string; name!: string;
@Field(() => String, { nullable: true })
avatarUrl!: string | null;
}
@ObjectType()
export class WorkspaceUserType implements WorkspaceUser {
@Field()
id!: string;
@Field()
name!: string;
@Field() @Field()
email!: string; email!: string;

View File

@@ -32,7 +32,7 @@ export class WorkspaceEvents {
userId, userId,
workspaceId, workspaceId,
}: Events['workspace.members.requestDeclined']) { }: Events['workspace.members.requestDeclined']) {
const user = await this.models.user.getPublicUser(userId); const user = await this.models.user.getWorkspaceUser(userId);
// send decline mail // send decline mail
await this.workspaceService.sendReviewDeclinedEmail( await this.workspaceService.sendReviewDeclinedEmail(
user?.email, user?.email,
@@ -60,8 +60,8 @@ export class WorkspaceEvents {
to, to,
}: Events['workspace.owner.changed']) { }: Events['workspace.owner.changed']) {
// send ownership transferred mail // send ownership transferred mail
const fromUser = await this.models.user.getPublicUser(from); const fromUser = await this.models.user.getWorkspaceUser(from);
const toUser = await this.models.user.getPublicUser(to); const toUser = await this.models.user.getWorkspaceUser(to);
if (fromUser) { if (fromUser) {
await this.workspaceService.sendOwnershipTransferredEmail( await this.workspaceService.sendOwnershipTransferredEmail(

View File

@@ -35,7 +35,7 @@ import {
DocAction, DocAction,
DocRole, DocRole,
} from '../../permission'; } from '../../permission';
import { PublicUserType } from '../../user'; import { WorkspaceUserType } from '../../user';
import { WorkspaceType } from '../types'; import { WorkspaceType } from '../types';
import { import {
DotToUnderline, DotToUnderline,
@@ -124,8 +124,8 @@ class GrantedDocUserType {
@Field(() => DocRole, { name: 'role' }) @Field(() => DocRole, { name: 'role' })
type!: DocRole; type!: DocRole;
@Field(() => PublicUserType) @Field(() => WorkspaceUserType)
user!: PublicUserType; user!: WorkspaceUserType;
} }
@ObjectType() @ObjectType()
@@ -391,16 +391,16 @@ export class DocResolver {
pagination pagination
); );
const publicUsers = await this.models.user.getPublicUsers( const workspaceUsers = await this.models.user.getWorkspaceUsers(
permissions.map(p => p.userId) permissions.map(p => p.userId)
); );
const publicUsersMap = new Map(publicUsers.map(pu => [pu.id, pu])); const workspaceUsersMap = new Map(workspaceUsers.map(wu => [wu.id, wu]));
return paginate( return paginate(
permissions.map(p => ({ permissions.map(p => ({
...p, ...p,
user: publicUsersMap.get(p.userId) as PublicUserType, user: workspaceUsersMap.get(p.userId) as WorkspaceUserType,
})), })),
'createdAt', 'createdAt',
pagination, pagination,

View File

@@ -86,7 +86,7 @@ export class WorkspaceService {
return; return;
} }
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = await this.models.user.getPublicUser(inviteeUserId); const invitee = await this.models.user.getWorkspaceUser(inviteeUserId);
if (!invitee) { if (!invitee) {
this.logger.error( this.logger.error(
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}` `Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
@@ -105,10 +105,10 @@ export class WorkspaceService {
await this.getInviteInfo(inviteId); await this.getInviteInfo(inviteId);
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = inviteeUserId const invitee = inviteeUserId
? await this.models.user.getPublicUser(inviteeUserId) ? await this.models.user.getWorkspaceUser(inviteeUserId)
: null; : null;
const inviter = inviterUserId const inviter = inviterUserId
? await this.models.user.getPublicUser(inviterUserId) ? await this.models.user.getWorkspaceUser(inviterUserId)
: await this.models.workspaceUser.getOwner(workspaceId); : await this.models.workspaceUser.getOwner(workspaceId);
if (!inviter || !invitee) { if (!inviter || !invitee) {
@@ -173,7 +173,7 @@ export class WorkspaceService {
return; return;
} }
const invitee = await this.models.user.getPublicUser(inviteeUserId); const invitee = await this.models.user.getWorkspaceUser(inviteeUserId);
if (!invitee) { if (!invitee) {
this.logger.error( this.logger.error(
`Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}` `Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}`
@@ -220,7 +220,7 @@ export class WorkspaceService {
userId: string, userId: string,
ws: { id: string; role: WorkspaceRole } ws: { id: string; role: WorkspaceRole }
) { ) {
const user = await this.models.user.getPublicUser(userId); const user = await this.models.user.getWorkspaceUser(userId);
if (!user) throw new UserNotFound(); if (!user) throw new UserNotFound();
const workspace = await this.getWorkspaceInfo(ws.id); const workspace = await this.getWorkspaceInfo(ws.id);

View File

@@ -533,7 +533,7 @@ export class WorkspaceResolver {
const inviteeId = inviteeUserId || user?.id; const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound(); if (!inviteeId) throw new UserNotFound();
const invitee = await this.models.user.getPublicUser(inviteeId); const invitee = await this.models.user.getWorkspaceUser(inviteeId);
return { workspace, user: owner, invitee }; return { workspace, user: owner, invitee };
} }

View File

@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { type ConnectedAccount, Prisma, type User } from '@prisma/client'; import { type ConnectedAccount, Prisma, type User } from '@prisma/client';
import { pick } from 'lodash-es';
import { import {
CryptoHelper, CryptoHelper,
@@ -14,11 +13,17 @@ import { WorkspaceRole } from './common';
import type { Workspace } from './workspace'; import type { Workspace } from './workspace';
const publicUserSelect = { const publicUserSelect = {
id: true,
name: true,
avatarUrl: true,
} satisfies Prisma.UserSelect;
const workspaceUserSelect = {
id: true, id: true,
name: true, name: true,
email: true, email: true,
avatarUrl: true, avatarUrl: true,
} satisfies Prisma.UserSelect; } satisfies Prisma.UserSelect;
type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string }; type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string };
type UpdateUserInput = Omit<Partial<Prisma.UserCreateInput>, 'id'>; type UpdateUserInput = Omit<Partial<Prisma.UserCreateInput>, 'id'>;
@@ -45,6 +50,7 @@ declare global {
} }
export type PublicUser = Pick<User, keyof typeof publicUserSelect>; export type PublicUser = Pick<User, keyof typeof publicUserSelect>;
export type WorkspaceUser = Pick<User, keyof typeof workspaceUserSelect>;
export type { ConnectedAccount, User }; export type { ConnectedAccount, User };
@Injectable() @Injectable()
@@ -83,6 +89,20 @@ export class UserModel extends BaseModel {
}); });
} }
async getWorkspaceUser(id: string): Promise<WorkspaceUser | null> {
return this.db.user.findUnique({
select: workspaceUserSelect,
where: { id },
});
}
async getWorkspaceUsers(ids: string[]): Promise<WorkspaceUser[]> {
return this.db.user.findMany({
select: workspaceUserSelect,
where: { id: { in: ids } },
});
}
async getUserByEmail(email: string): Promise<User | null> { async getUserByEmail(email: string): Promise<User | null> {
const rows = await this.db.$queryRaw<User[]>` const rows = await this.db.$queryRaw<User[]>`
SELECT id, name, email, password, registered, email_verified as emailVerifiedAt, avatar_url as avatarUrl, registered, created_at as createdAt SELECT id, name, email, password, registered, email_verified as emailVerifiedAt, avatar_url as avatarUrl, registered, created_at as createdAt
@@ -118,7 +138,7 @@ export class UserModel extends BaseModel {
async getPublicUserByEmail(email: string): Promise<PublicUser | null> { async getPublicUserByEmail(email: string): Promise<PublicUser | null> {
const rows = await this.db.$queryRaw<PublicUser[]>` const rows = await this.db.$queryRaw<PublicUser[]>`
SELECT id, name, email, avatar_url as avatarUrl SELECT id, name, avatar_url as avatarUrl
FROM "users" FROM "users"
WHERE lower("email") = lower(${email}) WHERE lower("email") = lower(${email})
`; `;
@@ -126,8 +146,14 @@ export class UserModel extends BaseModel {
return rows[0] ?? null; return rows[0] ?? null;
} }
toPublicUser(user: User): PublicUser { async getWorkspaceUserByEmail(email: string): Promise<WorkspaceUser | null> {
return pick(user, Object.keys(publicUserSelect)) as any; const rows = await this.db.$queryRaw<WorkspaceUser[]>`
SELECT id, name, email, avatar_url as avatarUrl
FROM "users"
WHERE lower("email") = lower(${email})
`;
return rows[0] ?? null;
} }
async create(data: CreateUserInput) { async create(data: CreateUserInput) {

View File

@@ -574,7 +574,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null; return null;
} }
const user = await this.models.user.getPublicUserByEmail(customer.email); const user = await this.models.user.getWorkspaceUserByEmail(customer.email);
if (!user) { if (!user) {
return { return {
@@ -620,7 +620,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null; return null;
} }
const user = await this.models.user.getPublicUserByEmail( const user = await this.models.user.getWorkspaceUserByEmail(
invoice.customer_email invoice.customer_email
); );

View File

@@ -493,7 +493,7 @@ input GrantDocUserRolesInput {
type GrantedDocUserType { type GrantedDocUserType {
role: DocRole! role: DocRole!
user: PublicUserType! user: WorkspaceUserType!
} }
type GrantedDocUserTypeEdge { type GrantedDocUserTypeEdge {
@@ -997,7 +997,6 @@ enum PublicDocMode {
type PublicUserType { type PublicUserType {
avatarUrl: String avatarUrl: String
email: String!
id: String! id: String!
name: String! name: String!
} }
@@ -1022,6 +1021,9 @@ type Query {
listCopilotPrompts: [CopilotPromptType!]! listCopilotPrompts: [CopilotPromptType!]!
prices: [SubscriptionPrice!]! prices: [SubscriptionPrice!]!
"""Get public user by id"""
publicUserById(id: String!): PublicUserType
"""server config""" """server config"""
serverConfig: ServerConfigType! serverConfig: ServerConfigType!
@@ -1555,6 +1557,13 @@ type WorkspaceType {
team: Boolean! team: Boolean!
} }
type WorkspaceUserType {
avatarUrl: String
email: String!
id: String!
name: String!
}
type WrongSignInCredentialsDataType { type WrongSignInCredentialsDataType {
email: String! email: String!
} }