diff --git a/packages/backend/server/src/base/event/def.ts b/packages/backend/server/src/base/event/def.ts index e164564ed242c..d6768d14dfe8b 100644 --- a/packages/backend/server/src/base/event/def.ts +++ b/packages/backend/server/src/base/event/def.ts @@ -3,13 +3,11 @@ 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']; - }>; + members: { + reviewRequested: Payload<{ inviteId: string }>; + requestDeclined: Payload<{ inviteId: string }>; + requestApproved: Payload<{ inviteId: string }>; + updated: Payload<{ workspaceId: Workspace['id']; count: number }>; }; deleted: Payload; blob: { diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts index ab2b81e10f53e..659ed2a1166ce 100644 --- a/packages/backend/server/src/core/permission/service.ts +++ b/packages/backend/server/src/core/permission/service.ts @@ -6,19 +6,11 @@ import { groupBy } from 'lodash-es'; import { DocAccessDenied, EventEmitter, - PrismaTransaction, SpaceAccessDenied, SpaceOwnerNotFound, } from '../../base'; -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( @@ -377,21 +369,6 @@ export class PermissionService { .then(p => p.id); } - private async isTeamWorkspace(tx: PrismaTransaction, workspaceId: string) { - return await tx.workspaceFeature - .count({ - where: { - workspaceId, - activated: true, - feature: { - feature: QuotaType.TeamPlanV1, - type: FeatureKind.Feature, - }, - }, - }) - .then(count => count > 0); - } - async acceptWorkspaceInvitation( invitationId: string, workspaceId: string, @@ -410,91 +387,94 @@ export class PermissionService { } async refreshSeatStatus(workspaceId: string, memberLimit: number) { - const [pending, underReview] = await this.prisma.$transaction(async tx => { + const usedCount = await this.prisma.workspaceUserPermission.count({ + where: { workspaceId, status: WorkspaceMemberStatus.Accepted }, + }); + + const availableCount = memberLimit - usedCount; + + if (availableCount <= 0) { + return; + } + + await this.prisma.$transaction(async tx => { const members = await tx.workspaceUserPermission.findMany({ - where: { workspaceId }, - select: { userId: true, status: true, updatedAt: true }, + select: { id: true, status: true }, + where: { + workspaceId, + status: { + in: [ + WorkspaceMemberStatus.NeedMoreSeat, + WorkspaceMemberStatus.NeedMoreSeatAndReview, + ], + }, + }, + orderBy: { createdAt: 'asc' }, }); - const memberCount = members.filter( - m => m.status === WorkspaceMemberStatus.Accepted - ).length; - const needChange = members - .filter(m => NeedUpdateStatus.has(m.status)) - .toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt)) - .slice(0, memberLimit - memberCount); + + const needChange = members.slice(0, availableCount); const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy( needChange, m => m.status ); - 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: { - workspaceId, - userId: { in: inviteByLink }, - status: WorkspaceMemberStatus.Pending, - }, - select: { id: true, user: { select: { email: true } } }, - }) - .then(r => r.map(m => ({ inviteId: m.id, email: m.user.email }))); - const underReview = await tx.workspaceUserPermission - .findMany({ - where: { - workspaceId, - userId: { in: inviteByLink }, - status: WorkspaceMemberStatus.UnderReview, - }, - select: { id: true }, - }) - .then(r => ({ inviteIds: r.map(m => m.id) })); - return [pending, underReview] as const; + const toPendings = NeedMoreSeat ?? []; + if (toPendings.length > 0) { + await tx.workspaceUserPermission.updateMany({ + where: { id: { in: toPendings.map(m => m.id) } }, + data: { status: WorkspaceMemberStatus.Pending }, + }); + } + + const toUnderReviewUserIds = NeedMoreSeatAndReview ?? []; + if (toUnderReviewUserIds.length > 0) { + await tx.workspaceUserPermission.updateMany({ + where: { id: { in: toUnderReviewUserIds.map(m => m.id) } }, + data: { status: WorkspaceMemberStatus.UnderReview }, + }); + } + + return [toPendings, toUnderReviewUserIds] as const; }); - this.event.emit('workspace.team.seatAvailable', pending); - this.event.emit('workspace.team.reviewRequest', underReview); } async revokeWorkspace(workspaceId: string, user: string) { - return await this.prisma.$transaction(async tx => { - const result = await tx.workspaceUserPermission.deleteMany({ - where: { - workspaceId, - userId: user, - // We shouldn't revoke owner permission - // should auto deleted by workspace/user delete cascading - type: { not: Permission.Owner }, - }, - }); + const permission = await this.prisma.workspaceUserPermission.findUnique({ + where: { workspaceId_userId: { workspaceId, userId: user } }, + }); - const success = result.count > 0; - - if (success) { - const isTeam = await this.isTeamWorkspace(tx, workspaceId); - if (isTeam) { - const count = await tx.workspaceUserPermission.count({ - where: { workspaceId }, - }); - this.event.emit('workspace.members.updated', { - workspaceId, - count, - }); - this.event.emit('workspace.team.declineRequest', { - workspaceId, - inviteeId: user, - }); - } - } - return success; + // We shouldn't revoke owner permission + // should auto deleted by workspace/user delete cascading + if (!permission || permission.type === Permission.Owner) { + return false; + } + + await this.prisma.workspaceUserPermission.deleteMany({ + where: { + workspaceId, + userId: user, + }, + }); + + const count = await this.prisma.workspaceUserPermission.count({ + where: { workspaceId }, }); + + this.event.emit('workspace.members.updated', { + workspaceId, + count, + }); + + if ( + permission.status === 'UnderReview' || + permission.status === 'NeedMoreSeatAndReview' + ) { + this.event.emit('workspace.members.requestDeclined', { + inviteId: permission.id, + }); + } + + return true; } /// End regin: workspace permission diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index a32c4765374f2..b7e91fd78adba 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -78,20 +78,6 @@ export class WorkspaceService { }; } - 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); @@ -117,7 +103,7 @@ export class WorkspaceService { return true; } - async sendReviewRequestMail(inviteId: string) { + async sendReviewRequestedMail(inviteId: string) { const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); if (!inviteeUserId) { this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); @@ -145,24 +131,50 @@ export class WorkspaceService { } } + async sendInviteMail(inviteId: string) { + const target = await this.getInviteeEmailTarget(inviteId); + + if (!target) { + return; + } + + const owner = await this.permission.getWorkspaceOwner(target.workspace.id); + + await this.mailer.sendInviteEmail(target.email, inviteId, { + workspace: target.workspace, + user: { + avatar: owner.avatarUrl || '', + name: owner.name || '', + }, + }); + } + async sendReviewApproveEmail(inviteId: string) { - const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); - if (!inviteeUserId) { - this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); + const target = await this.getInviteeEmailTarget(inviteId); + + if (!target) { 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}` - ); + + await this.mailer.sendReviewApproveEmail(target.email, target.workspace); + } + + async sendReviewDeclinedEmail(inviteId: string) { + const target = await this.getInviteeEmailTarget(inviteId); + + if (!target) { return; } - await this.mailer.sendReviewApproveEmail(invitee.email, workspace); + + await this.mailer.sendReviewDeclinedEmail(target.email, target.workspace); } - async sendReviewDeclinedEmail(workspaceId: string, inviteeUserId: string) { + private async getInviteeEmailTarget(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) { @@ -172,6 +184,9 @@ export class WorkspaceService { return; } - await this.mailer.sendReviewDeclinedEmail(invitee.email, workspace); + return { + email: 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 a42eb6c576a5e..2d584ffddc1e3 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -123,7 +123,7 @@ export class TeamWorkspaceResolver { // after user click the invite link, we can check again and reject if charge failed if (sendInviteMail) { try { - await this.workspaceService.sendInviteMail(ret.inviteId, email); + await this.workspaceService.sendInviteMail(ret.inviteId); ret.sentSuccess = true; } catch (e) { this.logger.warn( @@ -253,8 +253,9 @@ export class TeamWorkspaceResolver { ); if (result) { - // send approve mail - await this.workspaceService.sendReviewApproveEmail(result); + this.event.emit('workspace.members.requestApproved', { + inviteId: result, + }); } return result; } @@ -314,22 +315,27 @@ export class TeamWorkspaceResolver { } } - @OnEvent('workspace.team.reviewRequest') - async onReviewRequest({ - inviteIds, - }: EventPayload<'workspace.team.reviewRequest'>) { + @OnEvent('workspace.members.reviewRequested') + async onReviewRequested({ + inviteId, + }: EventPayload<'workspace.members.reviewRequested'>) { // send review request mail to owner and admin - for (const inviteId of inviteIds) { - await this.workspaceService.sendReviewRequestMail(inviteId); - } + await this.workspaceService.sendReviewRequestedMail(inviteId); } - @OnEvent('workspace.team.declineRequest') + @OnEvent('workspace.members.requestDeclined') async onDeclineRequest({ - workspaceId, - inviteeId, - }: EventPayload<'workspace.team.declineRequest'>) { + inviteId, + }: EventPayload<'workspace.members.requestDeclined'>) { // send decline mail - await this.workspaceService.sendReviewDeclinedEmail(workspaceId, inviteeId); + await this.workspaceService.sendReviewDeclinedEmail(inviteId); + } + + @OnEvent('workspace.members.requestApproved') + async onApproveRequest({ + inviteId, + }: EventPayload<'workspace.members.requestApproved'>) { + // send approve mail + await this.workspaceService.sendReviewApproveEmail(inviteId); } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 02e82bd61fa8d..eeaae23df0a89 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -432,7 +432,7 @@ export class WorkspaceResolver { ); if (sendInviteMail) { try { - await this.workspaceService.sendInviteMail(inviteId, email); + await this.workspaceService.sendInviteMail(inviteId); } catch (e) { const ret = await this.permissions.revokeWorkspace( workspaceId, @@ -567,8 +567,8 @@ export class WorkspaceResolver { } else { const inviteId = await this.permissions.grant(workspaceId, user.id); if (isTeam) { - this.event.emit('workspace.team.reviewRequest', { - inviteIds: [inviteId], + this.event.emit('workspace.members.reviewRequested', { + inviteId, }); } // invite by link need admin to approve diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index f2179b0222d48..337b08896b798 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -79,9 +79,6 @@ declare module '../../base/event/def' { recurring: SubscriptionRecurring; }>; }; - members: { - updated: Payload<{ workspaceId: Workspace['id']; count: number }>; - }; } }