diff --git a/packages/backend/server/src/modules/quota/storage.ts b/packages/backend/server/src/modules/quota/storage.ts index f754c6576c..dde3620626 100644 --- a/packages/backend/server/src/modules/quota/storage.ts +++ b/packages/backend/server/src/modules/quota/storage.ts @@ -22,6 +22,8 @@ export class QuotaManagementService { expiredAt: quota.expiredAt, blobLimit: quota.feature.blobLimit, storageQuota: quota.feature.storageQuota, + historyPeriod: quota.feature.historyPeriod, + memberLimit: quota.feature.memberLimit, }; } diff --git a/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts b/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts index ad3208b555..abd7a9c522 100644 --- a/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts @@ -1,5 +1,6 @@ import { ForbiddenException, + HttpStatus, InternalServerErrorException, Logger, NotFoundException, @@ -16,6 +17,7 @@ import { } from '@nestjs/graphql'; import type { User } from '@prisma/client'; import { getStreamAsBuffer } from 'get-stream'; +import { GraphQLError } from 'graphql'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { applyUpdate, Doc } from 'yjs'; @@ -26,6 +28,7 @@ import type { FileUpload } from '../../../types'; import { Auth, CurrentUser, Public } from '../../auth'; import { MailService } from '../../auth/mailer'; import { AuthService } from '../../auth/service'; +import { QuotaManagementService } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; import { UsersService, UserType } from '../../users'; import { PermissionService } from '../permission'; @@ -54,6 +57,7 @@ export class WorkspaceResolver { private readonly mailer: MailService, private readonly prisma: PrismaService, private readonly permissions: PermissionService, + private readonly quota: QuotaManagementService, private readonly users: UsersService, private readonly event: EventEmitter, private readonly blobStorage: WorkspaceBlobStorage @@ -321,8 +325,23 @@ export class WorkspaceResolver { throw new ForbiddenException('Cannot change owner'); } - const target = await this.users.findUserByEmail(email); + // member limit check + const [memberCount, quota] = await Promise.all([ + this.prisma.workspaceUserPermission.count({ + where: { workspaceId }, + }), + this.quota.getUserQuota(user.id), + ]); + if (memberCount >= quota.memberLimit) { + throw new GraphQLError('Workspace member limit reached', { + extensions: { + status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE], + code: HttpStatus.PAYLOAD_TOO_LARGE, + }, + }); + } + let target = await this.users.findUserByEmail(email); if (target) { const originRecord = await this.prisma.workspaceUserPermission.findFirst({ where: { @@ -330,94 +349,52 @@ export class WorkspaceResolver { userId: target.id, }, }); - - if (originRecord) { - return originRecord.id; - } - - const inviteId = await this.permissions.grant( - workspaceId, - target.id, - 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 || '', - }, - }); - } catch (e) { - const ret = await this.permissions.revokeWorkspace( - workspaceId, - target.id - ); - - if (!ret) { - this.logger.fatal( - `failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}` - ); - } else { - this.logger.warn( - `failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}` - ); - } - - return new InternalServerErrorException(e); - } - } - return inviteId; + // only invite if the user is not already in the workspace + if (originRecord) return originRecord.id; } else { - const user = await this.auth.createAnonymousUser(email); - const inviteId = await this.permissions.grant( - workspaceId, - user.id, - 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 || '', - }, - }); - } catch (e) { - const ret = await this.permissions.revokeWorkspace( - workspaceId, - user.id - ); - - if (!ret) { - this.logger.fatal( - `failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}` - ); - } else { - this.logger.warn( - `failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}` - ); - } - - return new InternalServerErrorException(e); - } - } - return inviteId; + target = await this.auth.createAnonymousUser(email); } + + const inviteId = await this.permissions.grant( + workspaceId, + target.id, + 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 || '', + }, + }); + } catch (e) { + const ret = await this.permissions.revokeWorkspace( + workspaceId, + target.id + ); + + if (!ret) { + this.logger.fatal( + `failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}` + ); + } else { + this.logger.warn( + `failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}` + ); + } + + return new InternalServerErrorException(e); + } + } + return inviteId; } @Throttle({ diff --git a/packages/backend/server/tests/mailer.spec.ts b/packages/backend/server/tests/mailer.spec.ts index 023cfdf35d..e25355bf16 100644 --- a/packages/backend/server/tests/mailer.spec.ts +++ b/packages/backend/server/tests/mailer.spec.ts @@ -9,7 +9,8 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule } from '../src/app'; import { MailService } from '../src/modules/auth/mailer'; -import { FeatureManagementService } from '../src/modules/features'; +import { FeatureKind, FeatureManagementService } from '../src/modules/features'; +import { Quotas } from '../src/modules/quota'; import { PrismaService } from '../src/prisma'; import { createWorkspace, getInviteInfo, inviteUser, signUp } from './utils'; @@ -88,6 +89,32 @@ const FakePrisma = { }, }; }, + get features() { + return { + async findFirst() { + return { + id: 1, + type: FeatureKind.Quota, + feature: Quotas[0].feature, + configs: Quotas[0].configs, + version: Quotas[0].version, + createdAt: new Date(), + }; + }, + }; + }, + get userFeatures() { + return { + async findFirst() { + return { + createdAt: new Date(), + featureId: 1, + reason: '', + expiredAt: null, + }; + }, + }; + }, }; const test = ava as TestFn<{