diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts index a7adcb712a..2bc26b0bc7 100644 --- a/packages/backend/server/src/core/permission/service.ts +++ b/packages/backend/server/src/core/permission/service.ts @@ -14,6 +14,11 @@ import { FeatureKind } from '../features/types'; import { QuotaType } from '../quota/types'; import { Permission, PublicPageMode } from './types'; +const NeedUpdateStatus = new Set([ + WorkspaceMemberStatus.NeedMoreSeat, + WorkspaceMemberStatus.NeedMoreSeatAndReview, +]); + @Injectable() export class PermissionService { constructor( @@ -94,6 +99,20 @@ export class PermissionService { return owner.user; } + async getWorkspaceAdmin(workspaceId: string) { + const admin = await this.prisma.workspaceUserPermission.findMany({ + where: { + workspaceId, + type: Permission.Admin, + }, + include: { + user: true, + }, + }); + + return admin.map(({ user }) => user); + } + async getWorkspaceMemberCount(workspaceId: string) { return this.prisma.workspaceUserPermission.count({ where: { @@ -351,18 +370,6 @@ export class PermissionService { .then(p => p.id); } - async getWorkspaceInvitation(invitationId: string, workspaceId: string) { - return this.prisma.workspaceUserPermission.findUniqueOrThrow({ - where: { - id: invitationId, - workspaceId, - }, - include: { - user: true, - }, - }); - } - private async isTeamWorkspace(tx: PrismaTransaction, workspaceId: string) { return await tx.workspaceFeature .count({ @@ -396,24 +403,14 @@ export class PermissionService { } async refreshSeatStatus(workspaceId: string, memberLimit: number) { - return this.prisma.$transaction(async tx => { + const [pending, underReview] = await this.prisma.$transaction(async tx => { const members = await tx.workspaceUserPermission.findMany({ - where: { - workspaceId, - }, - select: { - userId: true, - status: true, - updatedAt: true, - }, + where: { workspaceId }, + select: { userId: true, status: true, updatedAt: true }, }); const memberCount = members.filter( m => m.status === WorkspaceMemberStatus.Accepted ).length; - const NeedUpdateStatus = new Set([ - WorkspaceMemberStatus.NeedMoreSeat, - WorkspaceMemberStatus.NeedMoreSeatAndReview, - ]); const needChange = members .filter(m => NeedUpdateStatus.has(m.status)) .toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt)) @@ -422,32 +419,41 @@ export class PermissionService { needChange, m => m.status ); - const approvedCount = await tx.workspaceUserPermission - .updateMany({ + const inviteByMail = NeedMoreSeat?.map(m => m.userId) ?? []; + await tx.workspaceUserPermission.updateMany({ + where: { workspaceId, userId: { in: inviteByMail } }, + data: { status: WorkspaceMemberStatus.Pending }, + }); + const inviteByLink = NeedMoreSeatAndReview?.map(m => m.userId) ?? []; + await tx.workspaceUserPermission.updateMany({ + where: { workspaceId, userId: { in: inviteByLink } }, + data: { status: WorkspaceMemberStatus.UnderReview }, + }); + + const pending = await tx.workspaceUserPermission + .findMany({ where: { - userId: { - in: NeedMoreSeat?.map(m => m.userId) ?? [], - }, - }, - data: { - status: WorkspaceMemberStatus.Accepted, + workspaceId, + userId: { in: inviteByLink }, + status: WorkspaceMemberStatus.Pending, }, + select: { id: true, user: { select: { email: true } } }, }) - .then(r => r.count); - const needReviewCount = await tx.workspaceUserPermission - .updateMany({ + .then(r => r.map(m => ({ inviteId: m.id, email: m.user.email }))); + const underReview = await tx.workspaceUserPermission + .findMany({ where: { - userId: { - in: NeedMoreSeatAndReview?.map(m => m.userId) ?? [], - }, - }, - data: { + workspaceId, + userId: { in: inviteByLink }, status: WorkspaceMemberStatus.UnderReview, }, + select: { id: true }, }) - .then(r => r.count); - return approvedCount + needReviewCount === needChange.length; + .then(r => ({ inviteIds: r.map(m => m.id) })); + return [pending, underReview] as const; }); + this.event.emit('workspace.team.seatAvailable', pending); + this.event.emit('workspace.team.reviewRequest', underReview); } async revokeWorkspace(workspaceId: string, user: string) { @@ -474,6 +480,10 @@ export class PermissionService { workspaceId, count, }); + this.event.emit('workspace.team.declineRequest', { + workspaceId, + inviteeId: user, + }); } } return success; diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index 112239fee2..b0ff055819 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -15,6 +15,7 @@ import { TeamWorkspaceResolver, WorkspaceBlobResolver, WorkspaceResolver, + WorkspaceService, } from './resolvers'; @Module({ @@ -35,6 +36,7 @@ import { PagePermissionResolver, DocHistoryResolver, WorkspaceBlobResolver, + WorkspaceService, ], }) export class WorkspaceModule {} diff --git a/packages/backend/server/src/core/workspaces/resolvers/index.ts b/packages/backend/server/src/core/workspaces/resolvers/index.ts index e3903cbee1..f005d40359 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/index.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/index.ts @@ -1,5 +1,6 @@ export * from './blob'; export * from './history'; export * from './page'; +export * from './service'; export * from './team'; export * from './workspace'; diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts new file mode 100644 index 0000000000..0a2ef1e284 --- /dev/null +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -0,0 +1,177 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { getStreamAsBuffer } from 'get-stream'; + +import { Cache, MailService } from '../../../fundamentals'; +import { DocContentService } from '../../doc-renderer'; +import { PermissionService } from '../../permission'; +import { WorkspaceBlobStorage } from '../../storage'; +import { UserService } from '../../user'; + +export const defaultWorkspaceAvatar = + 'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC'; + +export type InviteInfo = { + workspaceId: string; + inviterUserId?: string; + inviteeUserId?: string; +}; + +@Injectable() +export class WorkspaceService { + private readonly logger = new Logger(WorkspaceService.name); + + constructor( + private readonly blobStorage: WorkspaceBlobStorage, + private readonly cache: Cache, + private readonly doc: DocContentService, + private readonly mailer: MailService, + private readonly permission: PermissionService, + private readonly prisma: PrismaClient, + private readonly user: UserService + ) {} + + async getInviteInfo(inviteId: string): Promise { + // invite link + const invite = await this.cache.get( + `workspace:inviteLinkId:${inviteId}` + ); + if (typeof invite?.workspaceId === 'string') { + return invite; + } + + return await this.prisma.workspaceUserPermission + .findUniqueOrThrow({ + where: { + id: inviteId, + }, + select: { + workspaceId: true, + userId: true, + }, + }) + .then(r => ({ + workspaceId: r.workspaceId, + inviteeUserId: r.userId, + })); + } + + async getWorkspaceInfo(workspaceId: string) { + const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); + + let avatar = defaultWorkspaceAvatar; + if (workspaceContent?.avatarKey) { + const avatarBlob = await this.blobStorage.get( + workspaceId, + workspaceContent.avatarKey + ); + + if (avatarBlob.body) { + avatar = (await getStreamAsBuffer(avatarBlob.body)).toString('base64'); + } + } + + return { + avatar, + id: workspaceId, + name: workspaceContent?.name ?? '', + }; + } + + async sendInviteMail(inviteId: string, email: string) { + const { workspaceId } = await this.getInviteInfo(inviteId); + const workspace = await this.getWorkspaceInfo(workspaceId); + const owner = await this.permission.getWorkspaceOwner(workspaceId); + + await this.mailer.sendInviteEmail(email, inviteId, { + workspace, + user: { + avatar: owner.avatarUrl || '', + name: owner.name || '', + }, + }); + } + + async sendAcceptedEmail(inviteId: string) { + const { workspaceId, inviterUserId, inviteeUserId } = + await this.getInviteInfo(inviteId); + const workspace = await this.getWorkspaceInfo(workspaceId); + const invitee = inviteeUserId + ? await this.user.findUserById(inviteeUserId) + : null; + const inviter = inviterUserId + ? await this.user.findUserById(inviterUserId) + : await this.permission.getWorkspaceOwner(workspaceId); + + if (!inviter || !invitee) { + this.logger.error( + `Inviter or invitee user not found for inviteId: ${inviteId}` + ); + return false; + } + + await this.mailer.sendAcceptedEmail(inviter.email, { + inviteeName: invitee.name, + workspaceName: workspace.name, + }); + return true; + } + + async sendReviewRequestMail(inviteId: string) { + const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); + if (!inviteeUserId) { + this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); + return; + } + + const invitee = await this.user.findUserById(inviteeUserId); + if (!invitee) { + this.logger.error( + `Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}` + ); + return; + } + + const workspace = await this.getWorkspaceInfo(workspaceId); + const owner = await this.permission.getWorkspaceOwner(workspaceId); + const admin = await this.permission.getWorkspaceAdmin(workspaceId); + + for (const user of [owner, ...admin]) { + await this.mailer.sendReviewRequestMail( + user.email, + invitee.email, + workspace + ); + } + } + + async sendReviewApproveEmail(inviteId: string) { + const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); + if (!inviteeUserId) { + this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); + return; + } + const workspace = await this.getWorkspaceInfo(workspaceId); + const invitee = await this.user.findUserById(inviteeUserId); + if (!invitee) { + this.logger.error( + `Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}` + ); + return; + } + await this.mailer.sendReviewApproveEmail(invitee.email, workspace); + } + + async sendReviewDeclinedEmail(workspaceId: string, inviteeUserId: string) { + const workspace = await this.getWorkspaceInfo(workspaceId); + const invitee = await this.user.findUserById(inviteeUserId); + if (!invitee) { + this.logger.error( + `Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}` + ); + return; + } + + await this.mailer.sendReviewDeclinedEmail(invitee.email, workspace); + } +} diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index e980162813..4d9b8c4edf 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -12,8 +12,9 @@ import { nanoid } from 'nanoid'; import { Cache, EventEmitter, - MailService, + type EventPayload, NotInSpace, + OnEvent, RequestMutex, TooManyRequest, URLHelper, @@ -28,7 +29,7 @@ import { WorkspaceInviteLinkExpireTime, WorkspaceType, } from '../types'; -import { WorkspaceResolver } from './workspace'; +import { WorkspaceService } from './service'; /** * Workspace team resolver @@ -42,14 +43,13 @@ export class TeamWorkspaceResolver { constructor( private readonly cache: Cache, private readonly event: EventEmitter, - private readonly mailer: MailService, private readonly url: URLHelper, private readonly prisma: PrismaClient, private readonly permissions: PermissionService, private readonly users: UserService, private readonly quota: QuotaManagementService, private readonly mutex: RequestMutex, - private readonly workspace: WorkspaceResolver + private readonly workspaceService: WorkspaceService ) {} @ResolveField(() => Boolean, { @@ -119,20 +119,8 @@ export class TeamWorkspaceResolver { : WorkspaceMemberStatus.Pending ); if (!needMoreSeat && sendInviteMail) { - const inviteInfo = await this.workspace.getInviteInfo(ret.inviteId); - try { - await this.mailer.sendInviteEmail(email, ret.inviteId, { - workspace: { - id: inviteInfo.workspace.id, - name: inviteInfo.workspace.name, - avatar: inviteInfo.workspace.avatar, - }, - user: { - avatar: inviteInfo.user?.avatarUrl || '', - name: inviteInfo.user?.name || '', - }, - }); + await this.workspaceService.sendInviteMail(ret.inviteId, email); ret.sentSuccess = true; } catch (e) { this.logger.warn( @@ -182,7 +170,7 @@ export class TeamWorkspaceResolver { @Args('workspaceId') workspaceId: string, @Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime }) expireTime: WorkspaceInviteLinkExpireTime - ): Promise { + ): Promise { await this.permissions.checkWorkspace( workspaceId, user.id, @@ -205,7 +193,7 @@ export class TeamWorkspaceResolver { await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime }); await this.cache.set( cacheInviteId, - { workspaceId, inviteeUserId: user.id }, + { workspaceId, inviterUserId: user.id }, { ttl: expireTime } ); return { @@ -262,7 +250,8 @@ export class TeamWorkspaceResolver { ); if (result) { - // TODO(@darkskygit): send team approve mail + // send approve mail + await this.workspaceService.sendReviewApproveEmail(result); } return result; } @@ -321,4 +310,31 @@ export class TeamWorkspaceResolver { return new TooManyRequest(); } } + + @OnEvent('workspace.team.seatAvailable') + async onSeatAvailable(payload: EventPayload<'workspace.team.seatAvailable'>) { + // send invite mail when seat is available for NeedMoreSeat member + for (const { inviteId, email } of payload) { + await this.workspaceService.sendInviteMail(inviteId, email); + } + } + + @OnEvent('workspace.team.reviewRequest') + async onReviewRequest({ + inviteIds, + }: EventPayload<'workspace.team.reviewRequest'>) { + // send review request mail to owner and admin + for (const inviteId of inviteIds) { + await this.workspaceService.sendReviewRequestMail(inviteId); + } + } + + @OnEvent('workspace.team.declineRequest') + async onDeclineRequest({ + workspaceId, + inviteeId, + }: EventPayload<'workspace.team.declineRequest'>) { + // send decline mail + await this.workspaceService.sendReviewDeclinedEmail(workspaceId, inviteeId); + } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 7a6884d395..c3c35314d2 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -11,7 +11,6 @@ import { Resolver, } from '@nestjs/graphql'; import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; -import { getStreamAsBuffer } from 'get-stream'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import type { FileUpload } from '../../../fundamentals'; @@ -32,10 +31,8 @@ import { } from '../../../fundamentals'; import { CurrentUser, Public } from '../../auth'; import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc'; -import { DocContentService } from '../../doc-renderer'; import { Permission, PermissionService } from '../../permission'; import { QuotaManagementService, QuotaQueryType } from '../../quota'; -import { WorkspaceBlobStorage } from '../../storage'; import { UserService, UserType } from '../../user'; import { InvitationType, @@ -43,7 +40,7 @@ import { UpdateWorkspaceInput, WorkspaceType, } from '../types'; -import { defaultWorkspaceAvatar } from '../utils'; +import { WorkspaceService } from './service'; @ObjectType() export class EditorType implements Partial { @@ -86,9 +83,8 @@ export class WorkspaceResolver { private readonly quota: QuotaManagementService, private readonly users: UserService, private readonly event: EventEmitter, - private readonly blobStorage: WorkspaceBlobStorage, private readonly mutex: RequestMutex, - private readonly doc: DocContentService, + private readonly workspaceService: WorkspaceService, private readonly workspaceStorage: PgWorkspaceDocStorageAdapter ) {} @@ -433,20 +429,8 @@ export class WorkspaceResolver { permission ); if (sendInviteMail) { - const inviteInfo = await this.getInviteInfo(inviteId); - try { - await this.mailer.sendInviteEmail(email, inviteId, { - workspace: { - id: inviteInfo.workspace.id, - name: inviteInfo.workspace.name, - avatar: inviteInfo.workspace.avatar, - }, - user: { - avatar: inviteInfo.user?.avatarUrl || '', - name: inviteInfo.user?.name || '', - }, - }); + await this.workspaceService.sendInviteMail(inviteId, email); } catch (e) { const ret = await this.permissions.revokeWorkspace( workspaceId, @@ -483,63 +467,20 @@ export class WorkspaceResolver { @Query(() => InvitationType, { description: 'send workspace invitation', }) - async getInviteInfo(@Args('inviteId') inviteId: string) { - let workspaceId = null; - let invitee = null; - // invite link - const invite = await this.cache.get<{ - workspaceId: string; - inviteeUserId: string; - }>(`workspace:inviteLinkId:${inviteId}`); - if (typeof invite?.workspaceId === 'string') { - workspaceId = invite.workspaceId; - invitee = { user: await this.users.findUserById(invite.inviteeUserId) }; - } - if (!workspaceId) { - workspaceId = await this.prisma.workspaceUserPermission - .findUniqueOrThrow({ - where: { - id: inviteId, - }, - select: { - workspaceId: true, - }, - }) - .then(({ workspaceId }) => workspaceId); - } - - const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); - + async getInviteInfo( + @CurrentUser() user: UserType | undefined, + @Args('inviteId') inviteId: string + ) { + const { workspaceId, inviteeUserId } = + await this.workspaceService.getInviteInfo(inviteId); + const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId); const owner = await this.permissions.getWorkspaceOwner(workspaceId); - if (!invitee) { - invitee = await this.permissions.getWorkspaceInvitation( - inviteId, - workspaceId - ); - } + const inviteeId = inviteeUserId || user?.id; + if (!inviteeId) throw new UserNotFound(); + const invitee = await this.users.findUserById(inviteeId); - let avatar = ''; - if (workspaceContent?.avatarKey) { - const avatarBlob = await this.blobStorage.get( - workspaceId, - workspaceContent.avatarKey - ); - - if (avatarBlob.body) { - avatar = (await getStreamAsBuffer(avatarBlob.body)).toString('base64'); - } - } - - return { - workspace: { - name: workspaceContent?.name ?? '', - avatar: avatar || defaultWorkspaceAvatar, - id: workspaceId, - }, - user: owner, - invitee: invitee.user, - }; + return { workspace, user: owner, invitee }; } @Mutation(() => Boolean) @@ -569,13 +510,7 @@ export class WorkspaceResolver { ); } - const result = await this.permissions.revokeWorkspace(workspaceId, userId); - - if (result && isTeam) { - // TODO(@darkskygit): send team revoke mail - } - - return result; + return await this.permissions.revokeWorkspace(workspaceId, userId); } @Mutation(() => Boolean) @@ -615,8 +550,11 @@ export class WorkspaceResolver { return true; } else { const inviteId = await this.permissions.grant(workspaceId, user.id); + this.event.emit('workspace.team.reviewRequest', { + inviteIds: [inviteId], + }); // invite by link need admin to approve - return this.permissions.acceptWorkspaceInvitation( + return await this.permissions.acceptWorkspaceInvitation( inviteId, workspaceId, WorkspaceMemberStatus.UnderReview @@ -629,36 +567,31 @@ export class WorkspaceResolver { // so we need to check seat again here await this.quota.checkWorkspaceSeat(workspaceId, true); - const { - invitee, - user: inviter, - workspace, - } = await this.getInviteInfo(inviteId); - - if (!inviter || !invitee) { - throw new UserNotFound(); - } - if (sendAcceptMail) { - // TODO(@darkskygit): team accept mail - await this.mailer.sendAcceptedEmail(inviter.email, { - inviteeName: invitee.name, - workspaceName: workspace.name, - }); + const success = await this.workspaceService.sendAcceptedEmail(inviteId); + if (!success) throw new UserNotFound(); } - return this.permissions.acceptWorkspaceInvitation(inviteId, workspaceId); + return await this.permissions.acceptWorkspaceInvitation( + inviteId, + workspaceId + ); } @Mutation(() => Boolean) async leaveWorkspace( @CurrentUser() user: CurrentUser, @Args('workspaceId') workspaceId: string, - @Args('workspaceName') workspaceName: string, - @Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean + @Args('sendLeaveMail', { nullable: true }) sendLeaveMail?: boolean, + @Args('workspaceName', { + nullable: true, + deprecationReason: 'no longer used', + }) + _workspaceName?: string ) { await this.permissions.checkWorkspace(workspaceId, user.id); - + const { name: workspaceName } = + await this.workspaceService.getWorkspaceInfo(workspaceId); const owner = await this.permissions.getWorkspaceOwner(workspaceId); if (sendLeaveMail) { diff --git a/packages/backend/server/src/core/workspaces/utils.ts b/packages/backend/server/src/core/workspaces/utils.ts deleted file mode 100644 index 366c500278..0000000000 --- a/packages/backend/server/src/core/workspaces/utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const defaultWorkspaceAvatar = - 'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC'; diff --git a/packages/backend/server/src/fundamentals/event/def.ts b/packages/backend/server/src/fundamentals/event/def.ts index c9cb423813..e164564ed2 100644 --- a/packages/backend/server/src/fundamentals/event/def.ts +++ b/packages/backend/server/src/fundamentals/event/def.ts @@ -3,6 +3,14 @@ import type { Snapshot, User, Workspace } from '@prisma/client'; import { Flatten, Payload } from './types'; export interface WorkspaceEvents { + team: { + seatAvailable: Payload<{ inviteId: string; email: string }[]>; + reviewRequest: Payload<{ inviteIds: string[] }>; + declineRequest: Payload<{ + workspaceId: Workspace['id']; + inviteeId: User['id']; + }>; + }; deleted: Payload; blob: { deleted: Payload<{ diff --git a/packages/backend/server/src/fundamentals/mailer/mail.service.ts b/packages/backend/server/src/fundamentals/mailer/mail.service.ts index f3e4168ac1..cf1f1cde21 100644 --- a/packages/backend/server/src/fundamentals/mailer/mail.service.ts +++ b/packages/backend/server/src/fundamentals/mailer/mail.service.ts @@ -166,6 +166,7 @@ export class MailService { html, }); } + async sendChangeEmail(to: string, url: string) { const html = emailTemplate({ title: 'Verify your current email for AFFiNE', @@ -180,6 +181,7 @@ export class MailService { html, }); } + async sendVerifyChangeEmail(to: string, url: string) { const html = emailTemplate({ title: 'Verify your new email address', @@ -194,6 +196,7 @@ export class MailService { html, }); } + async sendVerifyEmail(to: string, url: string) { const html = emailTemplate({ title: 'Verify your email address', @@ -208,6 +211,7 @@ export class MailService { html, }); } + async sendNotificationChangeEmail(to: string) { const html = emailTemplate({ title: 'Email change successful', @@ -219,6 +223,7 @@ export class MailService { html, }); } + async sendAcceptedEmail( to: string, { @@ -241,6 +246,7 @@ export class MailService { html, }); } + async sendLeaveWorkspaceEmail( to: string, { @@ -263,4 +269,46 @@ export class MailService { html, }); } + + // =================== Team Workspace Mails =================== + async sendReviewRequestMail( + to: string, + invitee: string, + ws: { id: string; name: string } + ) { + const { id: workspaceId, name: workspaceName } = ws; + const title = `New request to join ${workspaceName}`; + + const html = emailTemplate({ + title: 'Request to join your workspace', + content: `${invitee} has requested to join ${workspaceName}. As a workspace owner/admin, you can approve or decline this request.`, + buttonContent: 'Review request', + buttonUrl: this.url.link(`/workspace/${workspaceId}`), + }); + return this.sendMail({ to, subject: title, html }); + } + + async sendReviewApproveEmail(to: string, ws: { id: string; name: string }) { + const { id: workspaceId, name: workspaceName } = ws; + const title = `Your request to join ${workspaceName} has been approved`; + + const html = emailTemplate({ + title: 'Welcome to the workspace!', + content: `Your request to join ${workspaceName} has been accepted. You can now access the team workspace and collaborate with other members.`, + buttonContent: 'Open Workspace', + buttonUrl: this.url.link(`/workspace/${workspaceId}`), + }); + return this.sendMail({ to, subject: title, html }); + } + + async sendReviewDeclinedEmail(to: string, ws: { name: string }) { + const { name: workspaceName } = ws; + const title = `Your request to join ${workspaceName} was declined`; + + const html = emailTemplate({ + title: 'Request declined', + content: `Your request to join ${workspaceName} has been declined by the workspace admin.`, + }); + return this.sendMail({ to, subject: title, html }); + } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index e0987c41a5..fe3c4bbc8c 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -504,7 +504,7 @@ type Mutation { """Create a stripe customer portal to manage payment methods""" createCustomerPortal: String! - createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String! + createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink! """Create a new user""" createUser(input: CreateUserInput!): UserType! @@ -523,7 +523,7 @@ type Mutation { grantMember(permission: Permission!, userId: String!, workspaceId: String!): String! invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String! inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]! - leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean! + leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean! publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage! recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime! releaseDeletedBlobs(workspaceId: String!): Boolean! diff --git a/packages/backend/server/tests/team.e2e.ts b/packages/backend/server/tests/team.e2e.ts index d12389b5c5..61125fbffc 100644 --- a/packages/backend/server/tests/team.e2e.ts +++ b/packages/backend/server/tests/team.e2e.ts @@ -7,6 +7,7 @@ import ava from 'ava'; import { AppModule } from '../src/app.module'; import { AuthService } from '../src/core/auth'; +import { DocContentService } from '../src/core/doc-renderer'; import { Permission, PermissionService } from '../src/core/permission'; import { QuotaManagementService, @@ -15,11 +16,11 @@ import { } from '../src/core/quota'; import { acceptInviteById, + createInviteLink, createTestingApp, createWorkspace, getInviteInfo, grantMember, - inviteLink, inviteUser, inviteUsers, leaveWorkspace, @@ -40,6 +41,16 @@ const test = ava as TestFn<{ test.beforeEach(async t => { const { app } = await createTestingApp({ imports: [AppModule], + tapModule: module => { + module.overrideProvider(DocContentService).useValue({ + getWorkspaceContent() { + return { + name: 'test', + avatarKey: null, + }; + }, + }); + }, }); const quota = app.get(QuotaService); @@ -94,8 +105,14 @@ const init = async (app: INestApplication, memberLimit = 10) => { return [members, invites] as const; }; - const createInviteLink = async () => { - const inviteId = await inviteLink(app, owner.token.token, ws.id, 'OneDay'); + const getCreateInviteLinkFetcher = async () => { + const { link } = await createInviteLink( + app, + owner.token.token, + ws.id, + 'OneDay' + ); + const inviteId = link.split('/').pop()!; return [ inviteId, async (email: string): Promise => { @@ -113,7 +130,7 @@ const init = async (app: INestApplication, memberLimit = 10) => { return { invite, inviteBatch, - createInviteLink, + createInviteLink: getCreateInviteLinkFetcher, owner, ws, admin, @@ -169,7 +186,7 @@ test('should be able to check seat limit', async t => { ws.id, (await members1)[0][0].id ), - WorkspaceMemberStatus.Accepted, + WorkspaceMemberStatus.Pending, 'should become accepted after refresh' ); t.is( @@ -239,8 +256,7 @@ test('should be able to leave workspace', async t => { ); }); -// enabled in next PR -test.skip('should be able to invite by link', async t => { +test('should be able to invite by link', async t => { const { app, permissions, quotaManager } = t.context; const { createInviteLink, owner, ws } = await init(app, 4); const [inviteId, invite] = await createInviteLink(); diff --git a/packages/backend/server/tests/utils/invite.ts b/packages/backend/server/tests/utils/invite.ts index 9d9dc68676..6712362a6c 100644 --- a/packages/backend/server/tests/utils/invite.ts +++ b/packages/backend/server/tests/utils/invite.ts @@ -65,12 +65,12 @@ export async function inviteUsers( return res.body.data.inviteBatch; } -export async function inviteLink( +export async function createInviteLink( app: INestApplication, token: string, workspaceId: string, expireTime: 'OneDay' | 'ThreeDays' | 'OneWeek' | 'OneMonth' -): Promise { +): Promise<{ link: string; expireTime: string }> { const res = await request(app.getHttpServer()) .post(gql) .auth(token, { type: 'bearer' }) @@ -78,7 +78,10 @@ export async function inviteLink( .send({ query: ` mutation { - createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) + createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) { + link + expireTime + } } `, }) @@ -109,7 +112,10 @@ export async function acceptInviteById( }) .expect(200); if (res.body.errors) { - throw new Error(res.body.errors[0].message); + console.error(res.body.errors); + throw new Error(res.body.errors[0].message, { + cause: res.body.errors[0].cause, + }); } return res.body.data.acceptInviteById; } @@ -127,7 +133,7 @@ export async function leaveWorkspace( .send({ query: ` mutation { - leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail}) + leaveWorkspace(workspaceId: "${workspaceId}", sendLeaveMail: ${sendLeaveMail}) } `, }) diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx index ccd555ceff..0b25dbf7eb 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx @@ -88,7 +88,7 @@ export const CloudWorkspaceMembersPanel = ({ const onGenerateInviteLink = useCallback( async (expireTime: WorkspaceInviteLinkExpireTime) => { - const link = + const { link } = await permissionService.permission.generateInviteLink(expireTime); return link; }, diff --git a/packages/frontend/core/src/modules/permissions/services/permission.ts b/packages/frontend/core/src/modules/permissions/services/permission.ts index fcf9168da6..35560c0945 100644 --- a/packages/frontend/core/src/modules/permissions/services/permission.ts +++ b/packages/frontend/core/src/modules/permissions/services/permission.ts @@ -16,10 +16,7 @@ export class WorkspacePermissionService extends Service { } async leaveWorkspace() { - await this.store.leaveWorkspace( - this.workspaceService.workspace.id, - this.workspaceService.workspace.name$.value ?? '' - ); + await this.store.leaveWorkspace(this.workspaceService.workspace.id); this.workspacesService.list.revalidate(); } } diff --git a/packages/frontend/core/src/modules/permissions/stores/permission.ts b/packages/frontend/core/src/modules/permissions/stores/permission.ts index 6c8dee4b6d..34d128e4d0 100644 --- a/packages/frontend/core/src/modules/permissions/stores/permission.ts +++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts @@ -180,7 +180,7 @@ export class WorkspacePermissionStore extends Store { /** * @param workspaceName for send email */ - async leaveWorkspace(workspaceId: string, workspaceName: string) { + async leaveWorkspace(workspaceId: string) { if (!this.workspaceServerService.server) { throw new Error('No Server'); } @@ -188,7 +188,6 @@ export class WorkspacePermissionStore extends Store { query: leaveWorkspaceMutation, variables: { workspaceId, - workspaceName, }, }); } diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 9d92badc70..8b09b06891 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -840,12 +840,8 @@ export const leaveWorkspaceMutation = { definitionName: 'leaveWorkspace', containsFile: false, query: ` -mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLeaveMail: Boolean) { - leaveWorkspace( - workspaceId: $workspaceId - workspaceName: $workspaceName - sendLeaveMail: $sendLeaveMail - ) +mutation leaveWorkspace($workspaceId: String!, $sendLeaveMail: Boolean) { + leaveWorkspace(workspaceId: $workspaceId, sendLeaveMail: $sendLeaveMail) }`, }; @@ -1442,7 +1438,10 @@ export const createInviteLinkMutation = { containsFile: false, query: ` mutation createInviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) { - createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime) + createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime) { + link + expireTime + } }`, }; diff --git a/packages/frontend/graphql/src/graphql/leave-workspace.gql b/packages/frontend/graphql/src/graphql/leave-workspace.gql index 1b5064943a..dd0abe6197 100644 --- a/packages/frontend/graphql/src/graphql/leave-workspace.gql +++ b/packages/frontend/graphql/src/graphql/leave-workspace.gql @@ -1,11 +1,3 @@ -mutation leaveWorkspace( - $workspaceId: String! - $workspaceName: String! - $sendLeaveMail: Boolean -) { - leaveWorkspace( - workspaceId: $workspaceId - workspaceName: $workspaceName - sendLeaveMail: $sendLeaveMail - ) +mutation leaveWorkspace($workspaceId: String!, $sendLeaveMail: Boolean) { + leaveWorkspace(workspaceId: $workspaceId, sendLeaveMail: $sendLeaveMail) } diff --git a/packages/frontend/graphql/src/graphql/workspace-invite-link.gql b/packages/frontend/graphql/src/graphql/workspace-invite-link.gql index 5ddc566097..5b61cb8f58 100644 --- a/packages/frontend/graphql/src/graphql/workspace-invite-link.gql +++ b/packages/frontend/graphql/src/graphql/workspace-invite-link.gql @@ -2,5 +2,8 @@ mutation createInviteLink( $workspaceId: String! $expireTime: WorkspaceInviteLinkExpireTime! ) { - createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime) + createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime) { + link + expireTime + } } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 77a33afdf8..b6fe4918dc 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -567,7 +567,7 @@ export interface Mutation { createCopilotSession: Scalars['String']['output']; /** Create a stripe customer portal to manage payment methods */ createCustomerPortal: Scalars['String']['output']; - createInviteLink: Scalars['String']['output']; + createInviteLink: InviteLink; /** Create a new user */ createUser: UserType; /** Create a new workspace */ @@ -735,7 +735,7 @@ export interface MutationInviteBatchArgs { export interface MutationLeaveWorkspaceArgs { sendLeaveMail?: InputMaybe; workspaceId: Scalars['String']['input']; - workspaceName: Scalars['String']['input']; + workspaceName?: InputMaybe; } export interface MutationPublishPageArgs { @@ -2163,7 +2163,6 @@ export type InvoicesQuery = { export type LeaveWorkspaceMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; - workspaceName: Scalars['String']['input']; sendLeaveMail?: InputMaybe; }>; @@ -2692,7 +2691,11 @@ export type CreateInviteLinkMutationVariables = Exact<{ export type CreateInviteLinkMutation = { __typename?: 'Mutation'; - createInviteLink: string; + createInviteLink: { + __typename?: 'InviteLink'; + link: string; + expireTime: string; + }; }; export type RevokeInviteLinkMutationVariables = Exact<{