diff --git a/packages/backend/server/src/__tests__/models/user.spec.ts b/packages/backend/server/src/__tests__/models/user.spec.ts index a8be93dd25..3db2728a8b 100644 --- a/packages/backend/server/src/__tests__/models/user.spec.ts +++ b/packages/backend/server/src/__tests__/models/user.spec.ts @@ -152,6 +152,7 @@ test('should get public user by id', async t => { t.not(publicUser, null); t.is(publicUser!.id, user.id); t.true(!('password' in publicUser!)); + t.true(!('email' in publicUser!)); }); 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.is(publicUser!.id, user.id); 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 => { diff --git a/packages/backend/server/src/__tests__/user/user.e2e.ts b/packages/backend/server/src/__tests__/user/user.e2e.ts index 3441d6c134..8e74bde4e6 100644 --- a/packages/backend/server/src/__tests__/user/user.e2e.ts +++ b/packages/backend/server/src/__tests__/user/user.e2e.ts @@ -1,7 +1,14 @@ +import { randomUUID } from 'node:crypto'; + import type { TestFn } from 'ava'; import ava from 'ava'; -import { createTestingApp, TestingApp, updateAvatar } from '../utils'; +import { + createTestingApp, + getPublicUserById, + TestingApp, + updateAvatar, +} from '../utils'; const test = ava as TestFn<{ 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); 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); +}); diff --git a/packages/backend/server/src/__tests__/utils/user.ts b/packages/backend/server/src/__tests__/utils/user.ts index bcb922f171..28811584bc 100644 --- a/packages/backend/server/src/__tests__/utils/user.ts +++ b/packages/backend/server/src/__tests__/utils/user.ts @@ -1,3 +1,4 @@ +import { PublicUserType } from '../../core/user'; import { TestingApp } from './testing-app'; export async function currentUser(app: TestingApp) { @@ -12,6 +13,25 @@ export async function currentUser(app: TestingApp) { return res.currentUser; } +export async function getPublicUserById( + app: TestingApp, + id: string +): Promise { + const res = await app.gql( + ` + query getPublicUserById($id: String!) { + publicUserById(id: $id) { + id + name + avatarUrl + } + } + `, + { id } + ); + return res.publicUserById; +} + export async function sendChangeEmail( app: TestingApp, email: string, diff --git a/packages/backend/server/src/core/user/index.ts b/packages/backend/server/src/core/user/index.ts index 698507c838..1b084162a6 100644 --- a/packages/backend/server/src/core/user/index.ts +++ b/packages/backend/server/src/core/user/index.ts @@ -13,4 +13,4 @@ import { UserManagementResolver, UserResolver } from './resolver'; }) export class UserModule {} -export { PublicUserType, UserType } from './types'; +export { PublicUserType, UserType, WorkspaceUserType } from './types'; diff --git a/packages/backend/server/src/core/user/resolver.ts b/packages/backend/server/src/core/user/resolver.ts index 22e46648c0..21b1215bc9 100644 --- a/packages/backend/server/src/core/user/resolver.ts +++ b/packages/backend/server/src/core/user/resolver.ts @@ -27,6 +27,7 @@ import { validators } from '../utils/validators'; import { DeleteAccount, ManageUserInput, + PublicUserType, RemoveAvatar, UpdateUserInput, 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 { + return await this.models.user.getPublicUser(id); + } + @Mutation(() => UserType, { name: 'uploadAvatar', description: 'Upload user avatar', diff --git a/packages/backend/server/src/core/user/types.ts b/packages/backend/server/src/core/user/types.ts index e84507408c..941efd611a 100644 --- a/packages/backend/server/src/core/user/types.ts +++ b/packages/backend/server/src/core/user/types.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/graphql'; import type { User } from '@prisma/client'; -import { PublicUser } from '../../models'; +import { PublicUser, WorkspaceUser } from '../../models'; import { type CurrentUser } from '../auth/session'; @ObjectType() @@ -51,6 +51,18 @@ export class PublicUserType implements PublicUser { @Field() name!: string; + @Field(() => String, { nullable: true }) + avatarUrl!: string | null; +} + +@ObjectType() +export class WorkspaceUserType implements WorkspaceUser { + @Field() + id!: string; + + @Field() + name!: string; + @Field() email!: string; diff --git a/packages/backend/server/src/core/workspaces/event.ts b/packages/backend/server/src/core/workspaces/event.ts index 07cdc41f90..309afd72d0 100644 --- a/packages/backend/server/src/core/workspaces/event.ts +++ b/packages/backend/server/src/core/workspaces/event.ts @@ -32,7 +32,7 @@ export class WorkspaceEvents { userId, workspaceId, }: Events['workspace.members.requestDeclined']) { - const user = await this.models.user.getPublicUser(userId); + const user = await this.models.user.getWorkspaceUser(userId); // send decline mail await this.workspaceService.sendReviewDeclinedEmail( user?.email, @@ -60,8 +60,8 @@ export class WorkspaceEvents { to, }: Events['workspace.owner.changed']) { // send ownership transferred mail - const fromUser = await this.models.user.getPublicUser(from); - const toUser = await this.models.user.getPublicUser(to); + const fromUser = await this.models.user.getWorkspaceUser(from); + const toUser = await this.models.user.getWorkspaceUser(to); if (fromUser) { await this.workspaceService.sendOwnershipTransferredEmail( diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 14e744b441..253910f1be 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -35,7 +35,7 @@ import { DocAction, DocRole, } from '../../permission'; -import { PublicUserType } from '../../user'; +import { WorkspaceUserType } from '../../user'; import { WorkspaceType } from '../types'; import { DotToUnderline, @@ -124,8 +124,8 @@ class GrantedDocUserType { @Field(() => DocRole, { name: 'role' }) type!: DocRole; - @Field(() => PublicUserType) - user!: PublicUserType; + @Field(() => WorkspaceUserType) + user!: WorkspaceUserType; } @ObjectType() @@ -391,16 +391,16 @@ export class DocResolver { pagination ); - const publicUsers = await this.models.user.getPublicUsers( + const workspaceUsers = await this.models.user.getWorkspaceUsers( 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( permissions.map(p => ({ ...p, - user: publicUsersMap.get(p.userId) as PublicUserType, + user: workspaceUsersMap.get(p.userId) as WorkspaceUserType, })), 'createdAt', pagination, diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index 6c43ed8906..832c532157 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -86,7 +86,7 @@ export class WorkspaceService { return; } const workspace = await this.getWorkspaceInfo(workspaceId); - const invitee = await this.models.user.getPublicUser(inviteeUserId); + const invitee = await this.models.user.getWorkspaceUser(inviteeUserId); if (!invitee) { this.logger.error( `Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}` @@ -105,10 +105,10 @@ export class WorkspaceService { await this.getInviteInfo(inviteId); const workspace = await this.getWorkspaceInfo(workspaceId); const invitee = inviteeUserId - ? await this.models.user.getPublicUser(inviteeUserId) + ? await this.models.user.getWorkspaceUser(inviteeUserId) : null; const inviter = inviterUserId - ? await this.models.user.getPublicUser(inviterUserId) + ? await this.models.user.getWorkspaceUser(inviterUserId) : await this.models.workspaceUser.getOwner(workspaceId); if (!inviter || !invitee) { @@ -173,7 +173,7 @@ export class WorkspaceService { return; } - const invitee = await this.models.user.getPublicUser(inviteeUserId); + const invitee = await this.models.user.getWorkspaceUser(inviteeUserId); if (!invitee) { this.logger.error( `Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}` @@ -220,7 +220,7 @@ export class WorkspaceService { userId: string, 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(); const workspace = await this.getWorkspaceInfo(ws.id); diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index ad24283ce7..92950500bd 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -533,7 +533,7 @@ export class WorkspaceResolver { const inviteeId = inviteeUserId || user?.id; 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 }; } diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index ba8194ee02..b8e598e1e5 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { type ConnectedAccount, Prisma, type User } from '@prisma/client'; -import { pick } from 'lodash-es'; import { CryptoHelper, @@ -14,11 +13,17 @@ import { WorkspaceRole } from './common'; import type { Workspace } from './workspace'; const publicUserSelect = { + id: true, + name: true, + avatarUrl: true, +} satisfies Prisma.UserSelect; +const workspaceUserSelect = { id: true, name: true, email: true, avatarUrl: true, } satisfies Prisma.UserSelect; + type CreateUserInput = Omit & { name?: string }; type UpdateUserInput = Omit, 'id'>; @@ -45,6 +50,7 @@ declare global { } export type PublicUser = Pick; +export type WorkspaceUser = Pick; export type { ConnectedAccount, User }; @Injectable() @@ -83,6 +89,20 @@ export class UserModel extends BaseModel { }); } + async getWorkspaceUser(id: string): Promise { + return this.db.user.findUnique({ + select: workspaceUserSelect, + where: { id }, + }); + } + + async getWorkspaceUsers(ids: string[]): Promise { + return this.db.user.findMany({ + select: workspaceUserSelect, + where: { id: { in: ids } }, + }); + } + async getUserByEmail(email: string): Promise { const rows = await this.db.$queryRaw` 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 { const rows = await this.db.$queryRaw` - SELECT id, name, email, avatar_url as avatarUrl + SELECT id, name, avatar_url as avatarUrl FROM "users" WHERE lower("email") = lower(${email}) `; @@ -126,8 +146,14 @@ export class UserModel extends BaseModel { return rows[0] ?? null; } - toPublicUser(user: User): PublicUser { - return pick(user, Object.keys(publicUserSelect)) as any; + async getWorkspaceUserByEmail(email: string): Promise { + const rows = await this.db.$queryRaw` + SELECT id, name, email, avatar_url as avatarUrl + FROM "users" + WHERE lower("email") = lower(${email}) + `; + + return rows[0] ?? null; } async create(data: CreateUserInput) { diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index 0405088d32..8492bf4b20 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -574,7 +574,7 @@ export class SubscriptionService implements OnApplicationBootstrap { return null; } - const user = await this.models.user.getPublicUserByEmail(customer.email); + const user = await this.models.user.getWorkspaceUserByEmail(customer.email); if (!user) { return { @@ -620,7 +620,7 @@ export class SubscriptionService implements OnApplicationBootstrap { return null; } - const user = await this.models.user.getPublicUserByEmail( + const user = await this.models.user.getWorkspaceUserByEmail( invoice.customer_email ); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index f14454fb45..cd7b81a0e1 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -493,7 +493,7 @@ input GrantDocUserRolesInput { type GrantedDocUserType { role: DocRole! - user: PublicUserType! + user: WorkspaceUserType! } type GrantedDocUserTypeEdge { @@ -997,7 +997,6 @@ enum PublicDocMode { type PublicUserType { avatarUrl: String - email: String! id: String! name: String! } @@ -1022,6 +1021,9 @@ type Query { listCopilotPrompts: [CopilotPromptType!]! prices: [SubscriptionPrice!]! + """Get public user by id""" + publicUserById(id: String!): PublicUserType + """server config""" serverConfig: ServerConfigType! @@ -1555,6 +1557,13 @@ type WorkspaceType { team: Boolean! } +type WorkspaceUserType { + avatarUrl: String + email: String! + id: String! + name: String! +} + type WrongSignInCredentialsDataType { email: String! }