diff --git a/packages/backend/server/migrations/20241129062332_workspace_invite_status/migration.sql b/packages/backend/server/migrations/20241129062332_workspace_invite_status/migration.sql new file mode 100644 index 0000000000..edf84205a7 --- /dev/null +++ b/packages/backend/server/migrations/20241129062332_workspace_invite_status/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "WorkspaceMemberStatus" AS ENUM ('Pending', 'NeedMoreSeat', 'NeedMoreSeatAndReview', 'UnderReview', 'Accepted'); + +-- AlterTable +ALTER TABLE "workspace_features" ADD COLUMN "configs" JSON NOT NULL DEFAULT '{}'; + +-- AlterTable +ALTER TABLE "workspace_user_permissions" ADD COLUMN "status" "WorkspaceMemberStatus" NOT NULL DEFAULT 'Pending', +ADD COLUMN "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateIndex +CREATE INDEX "workspace_features_workspace_id_idx" ON "workspace_features"("workspace_id"); diff --git a/packages/backend/server/migrations/20241204081412_add_workspace_level_feature_flags/migration.sql b/packages/backend/server/migrations/20241204081412_add_workspace_level_feature_flags/migration.sql new file mode 100644 index 0000000000..85cdfa236e --- /dev/null +++ b/packages/backend/server/migrations/20241204081412_add_workspace_level_feature_flags/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "enable_ai" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 269cd8b372..9f810ceef4 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -97,8 +97,10 @@ model VerificationToken { model Workspace { id String @id @default(uuid()) @db.VarChar public Boolean - enableUrlPreview Boolean @default(false) @map("enable_url_preview") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + // workspace level feature flags + enableAi Boolean @default(true) @map("enable_ai") + enableUrlPreview Boolean @default(false) @map("enable_url_preview") pages WorkspacePage[] permissions WorkspaceUserPermission[] @@ -126,21 +128,33 @@ model WorkspacePage { @@map("workspace_pages") } +enum WorkspaceMemberStatus { + Pending // 1. old state accepted = false + NeedMoreSeat // 2.1 for team: workspace owner need to buy more seat + NeedMoreSeatAndReview // 2.2 for team: workspace owner need to buy more seat and member need review + UnderReview // 3. for team: member is under review + Accepted // 4. old state accepted = true +} + model WorkspaceUserPermission { - id String @id @default(uuid()) @db.VarChar - workspaceId String @map("workspace_id") @db.VarChar - userId String @map("user_id") @db.VarChar + id String @id @default(uuid()) @db.VarChar + workspaceId String @map("workspace_id") @db.VarChar + userId String @map("user_id") @db.VarChar // Read/Write - type Int @db.SmallInt - /// Whether the permission invitation is accepted by the user - accepted Boolean @default(false) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + type Int @db.SmallInt + /// @deprecated Whether the permission invitation is accepted by the user + accepted Boolean @default(false) + /// Whether the invite status of the workspace member + status WorkspaceMemberStatus @default(Pending) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + /// When the invite status changed + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@unique([workspaceId, userId]) - // optimize for quering user's workspace permissions + // optimize for querying user's workspace permissions @@index(userId) @@map("workspace_user_permissions") } @@ -200,6 +214,8 @@ model WorkspaceFeature { workspaceId String @map("workspace_id") @db.VarChar featureId Int @map("feature_id") @db.Integer + // override quota's configs + configs Json @default("{}") @db.Json // we will record the reason why the feature is enabled/disabled // for example: // - copilet_v1: "owner buy the copilet feature package" @@ -216,6 +232,7 @@ model WorkspaceFeature { feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + @@index([workspaceId]) @@map("workspace_features") } @@ -225,7 +242,7 @@ model Feature { version Int @default(0) @db.Integer // 0: feature, 1: quota type Int @db.Integer - // configs, define by feature conntroller + // configs, define by feature controller configs Json @db.Json createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) diff --git a/packages/backend/server/src/core/features/index.ts b/packages/backend/server/src/core/features/index.ts index 054cbbf100..0d953e3235 100644 --- a/packages/backend/server/src/core/features/index.ts +++ b/packages/backend/server/src/core/features/index.ts @@ -26,9 +26,11 @@ import { FeatureService } from './service'; }) export class FeatureModule {} +export type { FeatureConfigType } from './feature'; export { type CommonFeature, commonFeatureSchema, + type FeatureConfig, FeatureKind, Features, FeatureType, diff --git a/packages/backend/server/src/core/features/management.ts b/packages/backend/server/src/core/features/management.ts index 9ff93bb5b7..1f6506a159 100644 --- a/packages/backend/server/src/core/features/management.ts +++ b/packages/backend/server/src/core/features/management.ts @@ -69,7 +69,7 @@ export class FeatureManagementService { } async listEarlyAccess(type: EarlyAccessType = EarlyAccessType.App) { - return this.feature.listFeatureUsers( + return this.feature.listUsersByFeature( type === EarlyAccessType.App ? FeatureType.EarlyAccess : FeatureType.AIEarlyAccess @@ -132,7 +132,7 @@ export class FeatureManagementService { // ======== User Feature ======== async getActivatedUserFeatures(userId: string): Promise { - const features = await this.feature.getActivatedUserFeatures(userId); + const features = await this.feature.getUserActivatedFeatures(userId); return features.map(f => f.feature.name); } @@ -165,7 +165,7 @@ export class FeatureManagementService { } async listFeatureWorkspaces(feature: FeatureType) { - return this.feature.listFeatureWorkspaces(feature); + return this.feature.listWorkspacesByFeature(feature); } @OnEvent('user.admin.created') diff --git a/packages/backend/server/src/core/features/service.ts b/packages/backend/server/src/core/features/service.ts index c885e06074..7d7ff34e4c 100644 --- a/packages/backend/server/src/core/features/service.ts +++ b/packages/backend/server/src/core/features/service.ts @@ -12,14 +12,9 @@ export class FeatureService { async getFeature(feature: F) { const data = await this.prisma.feature.findFirst({ - where: { - feature, - type: FeatureKind.Feature, - }, + where: { feature, type: FeatureKind.Feature }, select: { id: true }, - orderBy: { - version: 'desc', - }, + orderBy: { version: 'desc' }, }); if (data) { @@ -146,7 +141,7 @@ export class FeatureService { return configs.filter(feature => !!feature.feature); } - async getActivatedUserFeatures(userId: string) { + async getUserActivatedFeatures(userId: string) { const features = await this.prisma.userFeature.findMany({ where: { userId, @@ -173,7 +168,7 @@ export class FeatureService { return configs.filter(feature => !!feature.feature); } - async listFeatureUsers(feature: FeatureType) { + async listUsersByFeature(feature: FeatureType) { return this.prisma.userFeature .findMany({ where: { @@ -318,7 +313,9 @@ export class FeatureService { return configs.filter(feature => !!feature.feature); } - async listFeatureWorkspaces(feature: FeatureType): Promise { + async listWorkspacesByFeature( + feature: FeatureType + ): Promise { return this.prisma.workspaceFeature .findMany({ where: { diff --git a/packages/backend/server/src/core/features/types/index.ts b/packages/backend/server/src/core/features/types/index.ts index 9b156f45e7..4623deb986 100644 --- a/packages/backend/server/src/core/features/types/index.ts +++ b/packages/backend/server/src/core/features/types/index.ts @@ -76,20 +76,24 @@ export const Features: Feature[] = [ /// ======== schema infer ======== +export const FeatureConfigSchema = z.discriminatedUnion('feature', [ + featureCopilot, + featureEarlyAccess, + featureAIEarlyAccess, + featureUnlimitedWorkspace, + featureUnlimitedCopilot, + featureAdministrator, +]); + export const FeatureSchema = commonFeatureSchema .extend({ type: z.literal(FeatureKind.Feature), }) - .and( - z.discriminatedUnion('feature', [ - featureCopilot, - featureEarlyAccess, - featureAIEarlyAccess, - featureUnlimitedWorkspace, - featureUnlimitedCopilot, - featureAdministrator, - ]) - ); + .and(FeatureConfigSchema); + +export type FeatureConfig = (z.infer< + typeof FeatureConfigSchema +> & { feature: F })['configs']; export type Feature = z.infer; diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts index 7fba7c6887..d546350da8 100644 --- a/packages/backend/server/src/core/permission/service.ts +++ b/packages/backend/server/src/core/permission/service.ts @@ -1,17 +1,36 @@ import { Injectable } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; +import { groupBy } from 'lodash-es'; import { DocAccessDenied, + EventEmitter, + PrismaTransaction, SpaceAccessDenied, SpaceOwnerNotFound, } from '../../fundamentals'; +import { FeatureKind } from '../features/types'; +import { QuotaType } from '../quota/types'; import { Permission, PublicPageMode } from './types'; @Injectable() export class PermissionService { - constructor(private readonly prisma: PrismaClient) {} + constructor( + private readonly prisma: PrismaClient, + private readonly event: EventEmitter + ) {} + + private get acceptedCondition() { + return [ + { + accepted: true, + }, + { + status: WorkspaceMemberStatus.Accepted, + }, + ]; + } /// Start regin: workspace permission async get(ws: string, user: string) { @@ -19,7 +38,7 @@ export class PermissionService { where: { workspaceId: ws, userId: user, - accepted: true, + OR: this.acceptedCondition, }, }); @@ -36,7 +55,7 @@ export class PermissionService { .count({ where: { workspaceId, - accepted: true, + OR: this.acceptedCondition, }, }) .then(count => count > 0); @@ -47,8 +66,8 @@ export class PermissionService { .findMany({ where: { userId, - accepted: true, type: Permission.Owner, + OR: this.acceptedCondition, }, select: { workspaceId: true, @@ -120,19 +139,31 @@ export class PermissionService { return this.tryCheckPage(ws, id, user); } + async getWorkspaceMemberStatus(ws: string, user: string) { + return this.prisma.workspaceUserPermission + .findFirst({ + where: { + workspaceId: ws, + userId: user, + }, + select: { status: true }, + }) + .then(r => r?.status); + } + /** * Returns whether a given user is a member of a workspace and has the given or higher permission. */ async isWorkspaceMember( ws: string, user: string, - permission: Permission + permission: Permission = Permission.Read ): Promise { const count = await this.prisma.workspaceUserPermission.count({ where: { workspaceId: ws, userId: user, - accepted: true, + OR: this.acceptedCondition, type: { gte: permission, }, @@ -193,7 +224,7 @@ export class PermissionService { where: { workspaceId: ws, userId: user, - accepted: true, + OR: this.acceptedCondition, type: { gte: permission, }, @@ -208,6 +239,33 @@ export class PermissionService { return false; } + async checkWorkspaceIs( + ws: string, + user: string, + permission: Permission = Permission.Read + ) { + if (!(await this.tryCheckWorkspaceIs(ws, user, permission))) { + throw new SpaceAccessDenied({ spaceId: ws }); + } + } + + async tryCheckWorkspaceIs( + ws: string, + user: string, + permission: Permission = Permission.Read + ) { + const count = await this.prisma.workspaceUserPermission.count({ + where: { + workspaceId: ws, + userId: user, + OR: this.acceptedCondition, + type: permission, + }, + }); + + return count > 0; + } + async allowUrlPreview(ws: string) { const count = await this.prisma.workspace.count({ where: { @@ -222,13 +280,14 @@ export class PermissionService { async grant( ws: string, user: string, - permission: Permission = Permission.Read + permission: Permission = Permission.Read, + status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending ): Promise { const data = await this.prisma.workspaceUserPermission.findFirst({ where: { workspaceId: ws, userId: user, - accepted: true, + OR: this.acceptedCondition, }, }); @@ -274,6 +333,7 @@ export class PermissionService { workspaceId: ws, userId: user, type: permission, + status, }, }) .then(p => p.id); @@ -291,33 +351,124 @@ export class PermissionService { }); } - async acceptWorkspaceInvitation(invitationId: string, workspaceId: string) { + 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, + status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted + ) { const result = await this.prisma.workspaceUserPermission.updateMany({ where: { id: invitationId, workspaceId: workspaceId, + AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }], }, data: { accepted: true, + status: status, }, }); return result.count > 0; } - async revokeWorkspace(ws: string, user: string) { - const result = await this.prisma.workspaceUserPermission.deleteMany({ - where: { - workspaceId: ws, - userId: user, - type: { - // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading - not: Permission.Owner, + async refreshSeatStatus(workspaceId: string, memberLimit: number) { + return this.prisma.$transaction(async tx => { + const members = await tx.workspaceUserPermission.findMany({ + 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)) + .slice(0, memberLimit - memberCount); + const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy( + needChange, + m => m.status + ); + const approvedCount = await tx.workspaceUserPermission + .updateMany({ + where: { + userId: { + in: NeedMoreSeat?.map(m => m.userId) ?? [], + }, + }, + data: { + status: WorkspaceMemberStatus.Accepted, + }, + }) + .then(r => r.count); + const needReviewCount = await tx.workspaceUserPermission + .updateMany({ + where: { + userId: { + in: NeedMoreSeatAndReview?.map(m => m.userId) ?? [], + }, + }, + data: { + status: WorkspaceMemberStatus.UnderReview, + }, + }) + .then(r => r.count); + return approvedCount + needReviewCount === needChange.length; }); + } - return result.count > 0; + 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 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, + }); + } + } + return success; + }); } /// End regin: workspace permission diff --git a/packages/backend/server/src/core/quota/index.ts b/packages/backend/server/src/core/quota/index.ts index 1cf61cf5df..6564566121 100644 --- a/packages/backend/server/src/core/quota/index.ts +++ b/packages/backend/server/src/core/quota/index.ts @@ -22,4 +22,10 @@ export class QuotaModule {} export { QuotaManagementService, QuotaService }; export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema'; -export { QuotaQueryType, QuotaType } from './types'; +export { + formatDate, + formatSize, + type QuotaBusinessType, + QuotaQueryType, + QuotaType, +} from './types'; diff --git a/packages/backend/server/src/core/quota/quota.ts b/packages/backend/server/src/core/quota/quota.ts index 95321df902..ac106f7cc1 100644 --- a/packages/backend/server/src/core/quota/quota.ts +++ b/packages/backend/server/src/core/quota/quota.ts @@ -5,6 +5,7 @@ const QuotaCache = new Map(); export class QuotaConfig { readonly config: Quota; + readonly override?: Quota['configs']; static async get(tx: PrismaTransaction, featureId: number) { const cachedQuota = QuotaCache.get(featureId); @@ -31,7 +32,7 @@ export class QuotaConfig { return config; } - private constructor(data: any) { + private constructor(data: any, override?: any) { const config = QuotaSchema.safeParse(data); if (config.success) { this.config = config.data; @@ -42,6 +43,38 @@ export class QuotaConfig { )})}` ); } + if (override) { + const overrideConfig = QuotaSchema.safeParse({ + ...config.data, + configs: Object.assign({}, config.data.configs, override), + }); + if (overrideConfig.success) { + this.override = overrideConfig.data.configs; + } else { + throw new Error( + `Invalid quota override config: ${override.error.message}, ${JSON.stringify( + data + )})}` + ); + } + } + } + + withOverride(override: any) { + if (override) { + return new QuotaConfig( + this.config, + Object.assign({}, this.override, override) + ); + } + return this; + } + + checkOverride(override: any) { + return QuotaSchema.safeParse({ + ...this.config, + configs: Object.assign({}, this.config.configs, override), + }); } get version() { @@ -54,29 +87,43 @@ export class QuotaConfig { } get blobLimit() { - return this.config.configs.blobLimit; + return this.override?.blobLimit || this.config.configs.blobLimit; } get businessBlobLimit() { return ( - this.config.configs.businessBlobLimit || this.config.configs.blobLimit + this.override?.businessBlobLimit || + this.config.configs.businessBlobLimit || + this.override?.blobLimit || + this.config.configs.blobLimit ); } + private get additionalQuota() { + const seatQuota = + this.override?.seatQuota || this.config.configs.seatQuota || 0; + return this.memberLimit * seatQuota; + } + get storageQuota() { - return this.config.configs.storageQuota; + const baseQuota = + this.override?.storageQuota || this.config.configs.storageQuota; + return baseQuota + this.additionalQuota; } get historyPeriod() { - return this.config.configs.historyPeriod; + return this.override?.historyPeriod || this.config.configs.historyPeriod; } get memberLimit() { - return this.config.configs.memberLimit; + return this.override?.memberLimit || this.config.configs.memberLimit; } get copilotActionLimit() { - return this.config.configs.copilotActionLimit || undefined; + if ('copilotActionLimit' in this.config.configs) { + return this.config.configs.copilotActionLimit || undefined; + } + return undefined; } get humanReadable() { diff --git a/packages/backend/server/src/core/quota/schema.ts b/packages/backend/server/src/core/quota/schema.ts index 2a7a8d6e6e..535f43e854 100644 --- a/packages/backend/server/src/core/quota/schema.ts +++ b/packages/backend/server/src/core/quota/schema.ts @@ -143,9 +143,9 @@ export const Quotas: Quota[] = [ configs: { // quota name name: 'Restricted', - // single blob limit 10MB + // single blob limit 1MB blobLimit: OneMB, - // total blob limit 1GB + // total blob limit 10MB storageQuota: 10 * OneMB, // history period of validity 30 days historyPeriod: 30 * OneDay, @@ -174,12 +174,31 @@ export const Quotas: Quota[] = [ copilotActionLimit: 10, }, }, + { + feature: QuotaType.TeamPlanV1, + type: FeatureKind.Quota, + version: 1, + configs: { + // quota name + name: 'Team Workspace', + // single blob limit 100MB + blobLimit: 500 * OneMB, + // total blob limit 100GB + storageQuota: 100 * OneGB, + // seat quota 20GB per seat + seatQuota: 20 * OneGB, + // history period of validity 30 days + historyPeriod: 30 * OneDay, + // member limit 1, override by workspace config + memberLimit: 1, + }, + }, ]; -export function getLatestQuota(type: QuotaType) { +export function getLatestQuota(type: Q): Quota { const quota = Quotas.filter(f => f.feature === type); quota.sort((a, b) => b.version - a.version); - return quota[0]; + return quota[0] as Quota; } export const FreePlan = getLatestQuota(QuotaType.FreePlanV1); diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index c5193e768e..619574747e 100644 --- a/packages/backend/server/src/core/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -15,14 +15,32 @@ export class QuotaService { private readonly feature: FeatureManagementService ) {} + async getQuota( + quota: Q, + tx?: PrismaTransaction + ): Promise { + const executor = tx ?? this.prisma; + + const data = await executor.feature.findFirst({ + where: { feature: quota, type: FeatureKind.Quota }, + select: { id: true }, + orderBy: { version: 'desc' }, + }); + + if (data) { + return QuotaConfig.get(this.prisma, data.id); + } + return undefined; + } + + // ======== User Quota ======== + // get activated user quota async getUserQuota(userId: string) { const quota = await this.prisma.userFeature.findFirst({ where: { userId, - feature: { - type: FeatureKind.Quota, - }, + feature: { type: FeatureKind.Quota }, activated: true, }, select: { @@ -47,9 +65,7 @@ export class QuotaService { const quotas = await this.prisma.userFeature.findMany({ where: { userId, - feature: { - type: FeatureKind.Quota, - }, + feature: { type: FeatureKind.Quota }, }, select: { activated: true, @@ -58,9 +74,7 @@ export class QuotaService { expiredAt: true, featureId: true, }, - orderBy: { - id: 'asc', - }, + orderBy: { id: 'asc' }, }); const configs = await Promise.all( quotas.map(async quota => { @@ -88,12 +102,8 @@ export class QuotaService { expiredAt?: Date ) { await this.prisma.$transaction(async tx => { - const hasSameActivatedQuota = await this.hasQuota(userId, quota, tx); - - if (hasSameActivatedQuota) { - // don't need to switch - return; - } + const hasSameActivatedQuota = await this.hasUserQuota(userId, quota, tx); + if (hasSameActivatedQuota) return; // don't need to switch const featureId = await tx.feature .findFirst({ @@ -133,7 +143,7 @@ export class QuotaService { }); } - async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) { + async hasUserQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) { const executor = tx ?? this.prisma; return executor.userFeature @@ -150,6 +160,161 @@ export class QuotaService { .then(count => count > 0); } + // ======== Workspace Quota ======== + + // get activated workspace quota + async getWorkspaceQuota(workspaceId: string) { + const quota = await this.prisma.workspaceFeature.findFirst({ + where: { + workspaceId, + feature: { type: FeatureKind.Quota }, + activated: true, + }, + select: { + configs: true, + reason: true, + createdAt: true, + expiredAt: true, + featureId: true, + }, + }); + + if (quota) { + const feature = await QuotaConfig.get(this.prisma, quota.featureId); + const { configs, ...rest } = quota; + return { ...rest, feature: feature.withOverride(configs) }; + } + return null; + } + + // switch user to a new quota + // currently each user can only have one quota + async switchWorkspaceQuota( + workspaceId: string, + quota: QuotaType, + reason?: string, + expiredAt?: Date + ) { + await this.prisma.$transaction(async tx => { + const hasSameActivatedQuota = await this.hasWorkspaceQuota( + workspaceId, + quota, + tx + ); + if (hasSameActivatedQuota) return; // don't need to switch + + const featureId = await tx.feature + .findFirst({ + where: { feature: quota, type: FeatureKind.Quota }, + select: { id: true }, + orderBy: { version: 'desc' }, + }) + .then(f => f?.id); + + if (!featureId) { + throw new Error(`Quota ${quota} not found`); + } + + // we will deactivate all exists quota for this workspace + await this.deactivateWorkspaceQuota(workspaceId, undefined, tx); + + await tx.workspaceFeature.create({ + data: { + workspaceId, + featureId, + reason: reason ?? 'switch quota', + activated: true, + expiredAt, + }, + }); + }); + } + + async deactivateWorkspaceQuota( + workspaceId: string, + quota?: QuotaType, + tx?: PrismaTransaction + ) { + const executor = tx ?? this.prisma; + + await executor.workspaceFeature.updateMany({ + where: { + id: undefined, + workspaceId, + feature: quota + ? { feature: quota, type: FeatureKind.Quota } + : { type: FeatureKind.Quota }, + }, + data: { activated: false }, + }); + } + + async hasWorkspaceQuota( + workspaceId: string, + quota: QuotaType, + tx?: PrismaTransaction + ) { + const executor = tx ?? this.prisma; + + return executor.workspaceFeature + .count({ + where: { + workspaceId, + feature: { + feature: quota, + type: FeatureKind.Quota, + }, + activated: true, + }, + }) + .then(count => count > 0); + } + + async getWorkspaceConfig( + workspaceId: string, + type: Q + ): Promise { + const quota = await this.getQuota(type); + if (quota) { + const configs = await this.prisma.workspaceFeature + .findFirst({ + where: { + workspaceId, + feature: { feature: type, type: FeatureKind.Feature }, + activated: true, + }, + select: { configs: true }, + }) + .then(q => q?.configs); + return quota.withOverride(configs); + } + return undefined; + } + + async updateWorkspaceConfig( + workspaceId: string, + quota: QuotaType, + configs: any + ) { + const current = await this.getWorkspaceConfig(workspaceId, quota); + + const ret = current?.checkOverride(configs); + if (!ret || !ret.success) { + throw new Error( + `Invalid quota config: ${ret?.error.message || 'quota not defined'}` + ); + } + const r = await this.prisma.workspaceFeature.updateMany({ + where: { + workspaceId, + feature: { feature: quota, type: FeatureKind.Quota }, + activated: true, + }, + data: { configs }, + }); + return r.count; + } + @OnEvent('user.subscription.activated') async onSubscriptionUpdated({ userId, diff --git a/packages/backend/server/src/core/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts index ac77ace364..8476a48973 100644 --- a/packages/backend/server/src/core/quota/storage.ts +++ b/packages/backend/server/src/core/quota/storage.ts @@ -1,16 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; +import { MemberQuotaExceeded } from '../../fundamentals'; import { FeatureService, FeatureType } from '../features'; import { PermissionService } from '../permission'; import { WorkspaceBlobStorage } from '../storage'; import { OneGB } from './constant'; +import { QuotaConfig } from './quota'; import { QuotaService } from './service'; -import { formatSize, QuotaQueryType } from './types'; - -type QuotaBusinessType = QuotaQueryType & { - businessBlobLimit: number; - unlimited: boolean; -}; +import { formatSize, Quota, type QuotaBusinessType, QuotaType } from './types'; @Injectable() export class QuotaManagementService { @@ -40,6 +37,46 @@ export class QuotaManagementService { }; } + async getWorkspaceConfig( + workspaceId: string, + quota: Q + ): Promise { + return this.quota.getWorkspaceConfig(workspaceId, quota); + } + + async updateWorkspaceConfig( + workspaceId: string, + quota: Q, + configs: Partial['configs']> + ) { + const orig = await this.getWorkspaceConfig(workspaceId, quota); + return await this.quota.updateWorkspaceConfig( + workspaceId, + quota, + Object.assign({}, orig?.override, configs) + ); + } + + // ======== Team Workspace ======== + async addTeamWorkspace(workspaceId: string, reason: string) { + return this.quota.switchWorkspaceQuota( + workspaceId, + QuotaType.TeamPlanV1, + reason + ); + } + + async removeTeamWorkspace(workspaceId: string) { + return this.quota.deactivateWorkspaceQuota( + workspaceId, + QuotaType.TeamPlanV1 + ); + } + + async isTeamWorkspace(workspaceId: string) { + return this.quota.hasWorkspaceQuota(workspaceId, QuotaType.TeamPlanV1); + } + async getUserUsage(userId: string) { const workspaces = await this.permissions.getOwnedWorkspaces(userId); @@ -109,6 +146,26 @@ export class QuotaManagementService { ); } + private async getWorkspaceQuota(userId: string, workspaceId: string) { + const { feature: workspaceQuota } = + (await this.quota.getWorkspaceQuota(workspaceId)) || {}; + const { feature: userQuota } = await this.quota.getUserQuota(userId); + if (workspaceQuota) { + return workspaceQuota.withOverride({ + // override user quota with workspace quota + copilotActionLimit: userQuota.copilotActionLimit, + }); + } + return userQuota; + } + + async checkWorkspaceSeat(workspaceId: string, excludeSelf = false) { + const quota = await this.getWorkspaceUsage(workspaceId); + if (quota.memberCount - (excludeSelf ? 1 : 0) >= quota.memberLimit) { + throw new MemberQuotaExceeded(); + } + } + // get workspace's owner quota and total size of used // quota was apply to owner's account async getWorkspaceUsage(workspaceId: string): Promise { @@ -116,17 +173,15 @@ export class QuotaManagementService { const memberCount = await this.permissions.getWorkspaceMemberCount(workspaceId); const { - feature: { - name, - blobLimit, - businessBlobLimit, - historyPeriod, - memberLimit, - storageQuota, - copilotActionLimit, - humanReadable, - }, - } = await this.quota.getUserQuota(owner.id); + name, + blobLimit, + businessBlobLimit, + historyPeriod, + memberLimit, + storageQuota, + copilotActionLimit, + humanReadable, + } = await this.getWorkspaceQuota(owner.id, workspaceId); // get all workspaces size of owner used const usedSize = await this.getUserUsage(owner.id); // relax restrictions if workspace has unlimited feature @@ -157,7 +212,7 @@ export class QuotaManagementService { return quota; } - private mergeUnlimitedQuota(orig: QuotaBusinessType) { + private mergeUnlimitedQuota(orig: QuotaBusinessType): QuotaBusinessType { return { ...orig, storageQuota: 1000 * OneGB, diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index 5bfff228a5..122be3bb33 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -17,27 +17,39 @@ import { ByteUnit, OneDay, OneKB } from './constant'; export enum QuotaType { FreePlanV1 = 'free_plan_v1', ProPlanV1 = 'pro_plan_v1', + TeamPlanV1 = 'team_plan_v1', LifetimeProPlanV1 = 'lifetime_pro_plan_v1', // only for test, smaller quota RestrictedPlanV1 = 'restricted_plan_v1', } -const quotaPlan = z.object({ +const basicQuota = z.object({ + name: z.string(), + blobLimit: z.number().positive().int(), + storageQuota: z.number().positive().int(), + seatQuota: z.number().positive().int().nullish(), + historyPeriod: z.number().positive().int(), + memberLimit: z.number().positive().int(), + businessBlobLimit: z.number().positive().int().nullish(), +}); + +const userQuota = basicQuota.extend({ + copilotActionLimit: z.number().positive().int().nullish(), +}); + +const userQuotaPlan = z.object({ feature: z.enum([ QuotaType.FreePlanV1, QuotaType.ProPlanV1, QuotaType.LifetimeProPlanV1, QuotaType.RestrictedPlanV1, ]), - configs: z.object({ - name: z.string(), - blobLimit: z.number().positive().int(), - storageQuota: z.number().positive().int(), - historyPeriod: z.number().positive().int(), - memberLimit: z.number().positive().int(), - businessBlobLimit: z.number().positive().int().nullish(), - copilotActionLimit: z.number().positive().int().nullish(), - }), + configs: userQuota, +}); + +const workspaceQuotaPlan = z.object({ + feature: z.enum([QuotaType.TeamPlanV1]), + configs: basicQuota, }); /// ======== schema infer ======== @@ -46,9 +58,12 @@ export const QuotaSchema = commonFeatureSchema .extend({ type: z.literal(FeatureKind.Quota), }) - .and(z.discriminatedUnion('feature', [quotaPlan])); + .and(z.discriminatedUnion('feature', [userQuotaPlan, workspaceQuotaPlan])); -export type Quota = z.infer; +export type Quota = z.infer< + typeof QuotaSchema +> & { feature: Q }; +export type QuotaConfigType = Quota['configs']; /// ======== query types ======== @@ -120,3 +135,8 @@ export function formatSize(bytes: number, decimals: number = 2): string { export function formatDate(ms: number): string { return `${(ms / OneDay).toFixed(0)} days`; } + +export type QuotaBusinessType = QuotaQueryType & { + businessBlobLimit: number; + unlimited: boolean; +}; diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index 78d6e70738..112239fee2 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -12,6 +12,7 @@ import { WorkspaceManagementResolver } from './management'; import { DocHistoryResolver, PagePermissionResolver, + TeamWorkspaceResolver, WorkspaceBlobResolver, WorkspaceResolver, } from './resolvers'; @@ -29,6 +30,7 @@ import { controllers: [WorkspacesController], providers: [ WorkspaceResolver, + TeamWorkspaceResolver, WorkspaceManagementResolver, PagePermissionResolver, DocHistoryResolver, diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index 82c8f6c1ca..da61143a99 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -166,7 +166,11 @@ export class WorkspaceBlobResolver { @Args('workspaceId') workspaceId: string, @Args('hash') name: string ) { - await this.permissions.checkWorkspace(workspaceId, user.id); + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Write + ); await this.storage.delete(workspaceId, name); diff --git a/packages/backend/server/src/core/workspaces/resolvers/index.ts b/packages/backend/server/src/core/workspaces/resolvers/index.ts index d4e282afd0..e3903cbee1 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/index.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/index.ts @@ -1,4 +1,5 @@ export * from './blob'; export * from './history'; export * from './page'; +export * from './team'; export * from './workspace'; diff --git a/packages/backend/server/src/core/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts index 7b8a411f44..813a92d06e 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/page.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/page.ts @@ -140,7 +140,7 @@ export class PagePermissionResolver { await this.permission.checkWorkspace( docId.workspace, user.id, - Permission.Read + Permission.Write ); return this.permission.publishPage(docId.workspace, docId.guid, mode); @@ -177,7 +177,7 @@ export class PagePermissionResolver { await this.permission.checkWorkspace( docId.workspace, user.id, - Permission.Read + Permission.Write ); const isPublic = await this.permission.isPublicPage( diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts new file mode 100644 index 0000000000..1d4a59f6a1 --- /dev/null +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -0,0 +1,284 @@ +import { Logger } from '@nestjs/common'; +import { + Args, + Mutation, + Parent, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; +import { nanoid } from 'nanoid'; + +import { + Cache, + EventEmitter, + MailService, + NotInSpace, + RequestMutex, + TooManyRequest, +} from '../../../fundamentals'; +import { CurrentUser } from '../../auth'; +import { Permission, PermissionService } from '../../permission'; +import { QuotaManagementService } from '../../quota'; +import { UserService } from '../../user'; +import { + InviteResult, + WorkspaceInviteLinkExpireTime, + WorkspaceType, +} from '../types'; +import { WorkspaceResolver } from './workspace'; + +/** + * Workspace team resolver + * Public apis rate limit: 10 req/m + * Other rate limit: 120 req/m + */ +@Resolver(() => WorkspaceType) +export class TeamWorkspaceResolver { + private readonly logger = new Logger(TeamWorkspaceResolver.name); + + constructor( + private readonly cache: Cache, + private readonly event: EventEmitter, + private readonly mailer: MailService, + private readonly prisma: PrismaClient, + private readonly permissions: PermissionService, + private readonly users: UserService, + private readonly quota: QuotaManagementService, + private readonly mutex: RequestMutex, + private readonly workspace: WorkspaceResolver + ) {} + + @ResolveField(() => Boolean, { + name: 'team', + description: 'if workspace is team workspace', + complexity: 2, + }) + team(@Parent() workspace: WorkspaceType) { + return this.quota.isTeamWorkspace(workspace.id); + } + + @Mutation(() => [InviteResult]) + async inviteBatch( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string, + @Args({ name: 'emails', type: () => [String] }) emails: string[], + @Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean + ) { + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); + + // lock to prevent concurrent invite + const lockFlag = `invite:${workspaceId}`; + await using lock = await this.mutex.lock(lockFlag); + if (!lock) { + return new TooManyRequest(); + } + + const quota = await this.quota.getWorkspaceUsage(workspaceId); + + const results = []; + for (const [idx, email] of emails.entries()) { + const ret: InviteResult = { email, sentSuccess: false, inviteId: null }; + try { + let target = await this.users.findUserByEmail(email); + if (target) { + const originRecord = + await this.prisma.workspaceUserPermission.findFirst({ + where: { + workspaceId, + userId: target.id, + }, + }); + // only invite if the user is not already in the workspace + if (originRecord) continue; + } else { + target = await this.users.createUser({ + email, + registered: false, + }); + } + const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit; + + ret.inviteId = await this.permissions.grant( + workspaceId, + target.id, + Permission.Write, + needMoreSeat + ? WorkspaceMemberStatus.NeedMoreSeat + : 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 || '', + }, + }); + ret.sentSuccess = true; + } catch (e) { + this.logger.warn( + `failed to send ${workspaceId} invite email to ${email}: ${e}` + ); + } + } + } catch (e) { + this.logger.error('failed to invite user', e); + } + results.push(ret); + } + + const memberCount = quota.memberCount + results.length; + if (memberCount > quota.memberLimit) { + this.event.emit('workspace.members.updated', { + workspaceId, + count: memberCount, + }); + } + + return results; + } + + @Mutation(() => String) + async inviteLink( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string, + @Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime }) + expireTime: WorkspaceInviteLinkExpireTime + ) { + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); + const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`; + const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId); + if (typeof invite?.inviteId === 'string') { + return invite.inviteId; + } + + const inviteId = nanoid(); + const cacheInviteId = `workspace:inviteLinkId:${inviteId}`; + await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime }); + await this.cache.set(cacheInviteId, { workspaceId }, { ttl: expireTime }); + return inviteId; + } + + @Mutation(() => Boolean) + async revokeInviteLink( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string + ) { + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); + const cacheId = `workspace:inviteLink:${workspaceId}`; + return await this.cache.delete(cacheId); + } + + @Mutation(() => String) + async approveMember( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string, + @Args('userId') userId: string + ) { + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); + + try { + // lock to prevent concurrent invite and grant + const lockFlag = `invite:${workspaceId}`; + await using lock = await this.mutex.lock(lockFlag); + if (!lock) { + return new TooManyRequest(); + } + + const isUnderReview = + (await this.permissions.getWorkspaceMemberStatus( + workspaceId, + userId + )) === WorkspaceMemberStatus.UnderReview; + if (isUnderReview) { + const result = await this.permissions.grant( + workspaceId, + userId, + Permission.Write, + WorkspaceMemberStatus.Accepted + ); + + if (result) { + // TODO(@darkskygit): send team approve mail + } + + return result; + } else { + return new NotInSpace({ spaceId: workspaceId }); + } + } catch (e) { + this.logger.error('failed to invite user', e); + return new TooManyRequest(); + } + } + + @Mutation(() => String) + async grantMember( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string, + @Args('userId') userId: string, + @Args('permission', { type: () => Permission }) permission: Permission + ) { + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Owner + ); + + try { + // lock to prevent concurrent invite and grant + const lockFlag = `invite:${workspaceId}`; + await using lock = await this.mutex.lock(lockFlag); + if (!lock) { + return new TooManyRequest(); + } + + const isMember = await this.permissions.isWorkspaceMember( + workspaceId, + userId + ); + if (isMember) { + const result = await this.permissions.grant( + workspaceId, + userId, + permission + ); + + if (result) { + // TODO(@darkskygit): send team role changed mail + } + + return result; + } else { + return new NotInSpace({ spaceId: workspaceId }); + } + } catch (e) { + this.logger.error('failed to invite user', e); + return new TooManyRequest(); + } + } +} diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 7371452e70..a2070d8bd8 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -10,23 +10,24 @@ import { ResolveField, Resolver, } from '@nestjs/graphql'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import { getStreamAsBuffer } from 'get-stream'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import type { FileUpload } from '../../../fundamentals'; import { + Cache, CantChangeSpaceOwner, DocNotFound, EventEmitter, InternalServerError, MailService, - MemberQuotaExceeded, RequestMutex, SpaceAccessDenied, SpaceNotFound, Throttle, TooManyRequest, + UserFriendlyError, UserNotFound, } from '../../../fundamentals'; import { CurrentUser, Public } from '../../auth'; @@ -78,6 +79,7 @@ export class WorkspaceResolver { private readonly logger = new Logger(WorkspaceResolver.name); constructor( + private readonly cache: Cache, private readonly mailer: MailService, private readonly prisma: PrismaClient, private readonly permissions: PermissionService, @@ -153,31 +155,21 @@ export class WorkspaceResolver { @Args('take', { type: () => Int, nullable: true }) take?: number ) { const data = await this.prisma.workspaceUserPermission.findMany({ - where: { - workspaceId: workspace.id, - }, + where: { workspaceId: workspace.id }, skip, take: take || 8, - orderBy: [ - { - createdAt: 'asc', - }, - { - type: 'desc', - }, - ], - include: { - user: true, - }, + orderBy: [{ createdAt: 'asc' }, { type: 'desc' }], + include: { user: true }, }); return data .filter(({ user }) => !!user) - .map(({ id, accepted, type, user }) => ({ + .map(({ id, accepted, status, type, user }) => ({ ...user, permission: type, inviteId: id, accepted, + status, })); } @@ -240,7 +232,14 @@ export class WorkspaceResolver { const data = await this.prisma.workspaceUserPermission.findMany({ where: { userId: user.id, - accepted: true, + OR: [ + { + accepted: true, + }, + { + status: WorkspaceMemberStatus.Accepted, + }, + ], }, include: { workspace: true, @@ -287,6 +286,7 @@ export class WorkspaceResolver { type: Permission.Owner, userId: user.id, accepted: true, + status: WorkspaceMemberStatus.Accepted, }, }, }, @@ -331,7 +331,12 @@ export class WorkspaceResolver { @Args({ name: 'input', type: () => UpdateWorkspaceInput }) { id, ...updates }: UpdateWorkspaceInput ) { - await this.permissions.checkWorkspace(id, user.id, Permission.Admin); + const isTeam = await this.quota.isTeamWorkspace(id); + await this.permissions.checkWorkspace( + id, + user.id, + isTeam ? Permission.Owner : Permission.Admin + ); return this.prisma.workspace.update({ where: { @@ -379,7 +384,7 @@ export class WorkspaceResolver { } try { - // lock to prevent concurrent invite + // lock to prevent concurrent invite and grant const lockFlag = `invite:${workspaceId}`; await using lock = await this.mutex.lock(lockFlag); if (!lock) { @@ -387,10 +392,7 @@ export class WorkspaceResolver { } // member limit check - const quota = await this.quota.getWorkspaceUsage(workspaceId); - if (quota.memberCount >= quota.memberLimit) { - return new MemberQuotaExceeded(); - } + await this.quota.checkWorkspaceSeat(workspaceId); let target = await this.users.findUserByEmail(email); if (target) { @@ -452,6 +454,10 @@ export class WorkspaceResolver { } return inviteId; } catch (e) { + // pass through user friendly error + if (e instanceof UserFriendlyError) { + return e; + } this.logger.error('failed to invite user', e); return new TooManyRequest(); } @@ -463,16 +469,26 @@ export class WorkspaceResolver { description: 'send workspace invitation', }) async getInviteInfo(@Args('inviteId') inviteId: string) { - const workspaceId = await this.prisma.workspaceUserPermission - .findUniqueOrThrow({ - where: { - id: inviteId, - }, - select: { - workspaceId: true, - }, - }) - .then(({ workspaceId }) => workspaceId); + let workspaceId = null; + // invite link + const invite = await this.cache.get<{ workspaceId: string }>( + `workspace:inviteLinkId:${inviteId}` + ); + if (typeof invite?.workspaceId === 'string') { + workspaceId = invite.workspaceId; + } + if (!workspaceId) { + workspaceId = await this.prisma.workspaceUserPermission + .findUniqueOrThrow({ + where: { + id: inviteId, + }, + select: { + workspaceId: true, + }, + }) + .then(({ workspaceId }) => workspaceId); + } const workspaceContent = await this.doc.getWorkspaceContent(workspaceId); @@ -511,22 +527,81 @@ export class WorkspaceResolver { @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { - await this.permissions.checkWorkspace( + const isTeam = await this.quota.isTeamWorkspace(workspaceId); + const isAdmin = await this.permissions.tryCheckWorkspaceIs( workspaceId, - user.id, + userId, Permission.Admin ); + if (isTeam && isAdmin) { + // only owner can revoke team workspace admin + await this.permissions.checkWorkspaceIs( + workspaceId, + user.id, + Permission.Owner + ); + } else { + await this.permissions.checkWorkspace( + workspaceId, + user.id, + Permission.Admin + ); + } - return this.permissions.revokeWorkspace(workspaceId, userId); + const result = await this.permissions.revokeWorkspace(workspaceId, userId); + + if (result && isTeam) { + // TODO(@darkskygit): send team revoke mail + } + + return result; } @Mutation(() => Boolean) @Public() async acceptInviteById( + @CurrentUser() user: CurrentUser | undefined, @Args('workspaceId') workspaceId: string, @Args('inviteId') inviteId: string, @Args('sendAcceptMail', { nullable: true }) sendAcceptMail: boolean ) { + if (user) { + // invite link + const invite = await this.cache.get<{ inviteId: string }>( + `workspace:inviteLink:${workspaceId}` + ); + if (invite?.inviteId === inviteId) { + const lockFlag = `invite:${workspaceId}`; + await using lock = await this.mutex.lock(lockFlag); + if (!lock) { + return new TooManyRequest(); + } + + const quota = await this.quota.getWorkspaceUsage(workspaceId); + if (quota.memberCount >= quota.memberLimit) { + await this.permissions.grant( + workspaceId, + user.id, + Permission.Write, + WorkspaceMemberStatus.NeedMoreSeatAndReview + ); + return true; + } else { + const inviteId = await this.permissions.grant(workspaceId, user.id); + // invite by link need admin to approve + return this.permissions.acceptWorkspaceInvitation( + inviteId, + workspaceId, + WorkspaceMemberStatus.UnderReview + ); + } + } + } + + // we added seats when sending invitation emails, but the deduction may fail + // so we need to check seat again here + await this.quota.checkWorkspaceSeat(workspaceId, true); + const { invitee, user: inviter, @@ -538,6 +613,7 @@ export class WorkspaceResolver { } if (sendAcceptMail) { + // TODO(@darkskygit): team accept mail await this.mailer.sendAcceptedEmail(inviter.email, { inviteeName: invitee.name, workspaceName: workspace.name, diff --git a/packages/backend/server/src/core/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts index f0e5f0e419..4452f5d85a 100644 --- a/packages/backend/server/src/core/workspaces/types.ts +++ b/packages/backend/server/src/core/workspaces/types.ts @@ -8,7 +8,7 @@ import { PickType, registerEnumType, } from '@nestjs/graphql'; -import type { Workspace } from '@prisma/client'; +import { Workspace, WorkspaceMemberStatus } from '@prisma/client'; import { SafeIntResolver } from 'graphql-scalars'; import { Permission } from '../permission'; @@ -19,6 +19,11 @@ registerEnumType(Permission, { description: 'User permission in workspace', }); +registerEnumType(WorkspaceMemberStatus, { + name: 'WorkspaceMemberStatus', + description: 'Member invite status in workspace', +}); + @ObjectType() export class InviteUserType extends OmitType( PartialType(UserType), @@ -34,8 +39,16 @@ export class InviteUserType extends OmitType( @Field({ description: 'Invite id' }) inviteId!: string; - @Field({ description: 'User accepted' }) + @Field({ + description: 'User accepted', + deprecationReason: 'Use `status` instead', + }) accepted!: boolean; + + @Field(() => WorkspaceMemberStatus, { + description: 'Member invite status in workspace', + }) + status!: WorkspaceMemberStatus; } @ObjectType() @@ -46,6 +59,9 @@ export class WorkspaceType implements Partial { @Field({ description: 'is Public workspace' }) public!: boolean; + @Field({ description: 'Enable AI' }) + enableAi!: boolean; + @Field({ description: 'Enable url previous when sharing' }) enableUrlPreview!: boolean; @@ -92,9 +108,38 @@ export class InvitationType { @InputType() export class UpdateWorkspaceInput extends PickType( PartialType(WorkspaceType), - ['public', 'enableUrlPreview'], + ['public', 'enableAi', 'enableUrlPreview'], InputType ) { @Field(() => ID) id!: string; } + +@ObjectType() +export class InviteResult { + @Field(() => String) + email!: string; + + @Field(() => String, { + nullable: true, + description: 'Invite id, null if invite record create failed', + }) + inviteId!: string | null; + + @Field(() => Boolean, { description: 'Invite email sent success' }) + sentSuccess!: boolean; +} + +const Day = 24 * 60 * 60 * 1000; + +export enum WorkspaceInviteLinkExpireTime { + OneDay = Day, + ThreeDays = 3 * Day, + OneWeek = 7 * Day, + OneMonth = 30 * Day, +} + +registerEnumType(WorkspaceInviteLinkExpireTime, { + name: 'WorkspaceInviteLinkExpireTime', + description: 'Workspace invite link expire time', +}); diff --git a/packages/backend/server/src/data/migrations/1732861452428-migrate-invite-status.ts b/packages/backend/server/src/data/migrations/1732861452428-migrate-invite-status.ts new file mode 100644 index 0000000000..05d36714eb --- /dev/null +++ b/packages/backend/server/src/data/migrations/1732861452428-migrate-invite-status.ts @@ -0,0 +1,18 @@ +import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; + +export class MigrateInviteStatus1732861452428 { + // do the migration + static async up(db: PrismaClient) { + await db.workspaceUserPermission.updateMany({ + where: { + accepted: true, + }, + data: { + status: WorkspaceMemberStatus.Accepted, + }, + }); + } + + // revert the migration + static async down(_db: PrismaClient) {} +} diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index c59072bf43..1538843a2f 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -3,6 +3,7 @@ import './config'; import { ServerFeature } from '../../core/config'; import { FeatureModule } from '../../core/features'; import { PermissionModule } from '../../core/permission'; +import { QuotaModule } from '../../core/quota'; import { UserModule } from '../../core/user'; import { Plugin } from '../registry'; import { StripeWebhookController } from './controller'; @@ -11,6 +12,7 @@ import { UserSubscriptionManager, WorkspaceSubscriptionManager, } from './manager'; +import { TeamQuotaOverride } from './quota'; import { SubscriptionResolver, UserSubscriptionResolver, @@ -22,7 +24,7 @@ import { StripeWebhook } from './webhook'; @Plugin({ name: 'payment', - imports: [FeatureModule, UserModule, PermissionModule], + imports: [FeatureModule, QuotaModule, UserModule, PermissionModule], providers: [ StripeProvider, SubscriptionService, @@ -33,6 +35,7 @@ import { StripeWebhook } from './webhook'; WorkspaceSubscriptionManager, SubscriptionCronJobs, WorkspaceSubscriptionResolver, + TeamQuotaOverride, ], controllers: [StripeWebhookController], requires: [ diff --git a/packages/backend/server/src/plugins/payment/quota.ts b/packages/backend/server/src/plugins/payment/quota.ts new file mode 100644 index 0000000000..1b32b478dd --- /dev/null +++ b/packages/backend/server/src/plugins/payment/quota.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { PermissionService } from '../../core/permission'; +import { QuotaManagementService, QuotaType } from '../../core/quota'; +import type { EventPayload } from '../../fundamentals'; + +@Injectable() +export class TeamQuotaOverride { + constructor( + private readonly manager: QuotaManagementService, + private readonly permission: PermissionService + ) {} + + @OnEvent('workspace.subscription.activated') + async onSubscriptionUpdated({ + workspaceId, + plan, + recurring, + quantity, + }: EventPayload<'workspace.subscription.activated'>) { + switch (plan) { + case 'team': + await this.manager.addTeamWorkspace( + workspaceId, + `${recurring} team subscription activated` + ); + await this.manager.updateWorkspaceConfig( + workspaceId, + QuotaType.TeamPlanV1, + { memberLimit: quantity } + ); + await this.permission.refreshSeatStatus(workspaceId, quantity); + break; + default: + break; + } + } + + @OnEvent('workspace.subscription.canceled') + async onSubscriptionCanceled({ + workspaceId, + plan, + }: EventPayload<'workspace.subscription.canceled'>) { + switch (plan) { + case 'team': + await this.manager.removeTeamWorkspace(workspaceId); + break; + default: + break; + } + } +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index bd3651b7af..8e37b799ab 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -361,9 +361,19 @@ type InvitationWorkspaceType { name: String! } +type InviteResult { + email: String! + + """Invite id, null if invite record create failed""" + inviteId: String + + """Invite email sent success""" + sentSuccess: Boolean! +} + type InviteUserType { """User accepted""" - accepted: Boolean! + accepted: Boolean! @deprecated(reason: "Use `status` instead") """User avatar url""" avatarUrl: String @@ -389,6 +399,9 @@ type InviteUserType { """User permission in workspace""" permission: Permission! + + """Member invite status in workspace""" + status: WorkspaceMemberStatus! } enum InvoiceStatus { @@ -451,6 +464,7 @@ type MissingOauthQueryParameterDataType { type Mutation { acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! + approveMember(userId: String!, workspaceId: String!): String! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! changeEmail(email: String!, token: String!): UserType! changePassword(newPassword: String!, token: String!, userId: String): Boolean! @@ -490,7 +504,10 @@ type Mutation { """Create a chat session""" forkCopilotSession(options: ForkChatSessionInput!): String! + 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!]! + inviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String! leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean! publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage! recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime! @@ -500,6 +517,7 @@ type Mutation { removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! revoke(userId: String!, workspaceId: String!): Boolean! + revokeInviteLink(workspaceId: String!): Boolean! revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! sendChangeEmail(callbackUrl: String!, email: String): Boolean! @@ -831,6 +849,9 @@ input UpdateUserInput { } input UpdateWorkspaceInput { + """Enable AI""" + enableAi: Boolean + """Enable url previous when sharing""" enableUrlPreview: Boolean id: ID! @@ -902,6 +923,22 @@ type WorkspaceBlobSizes { size: SafeInt! } +"""Workspace invite link expire time""" +enum WorkspaceInviteLinkExpireTime { + OneDay + OneMonth + OneWeek + ThreeDays +} + +"""Member invite status in workspace""" +enum WorkspaceMemberStatus { + Accepted + NeedMoreSeat + Pending + UnderReview +} + type WorkspacePage { id: String! mode: PublicPageMode! @@ -929,6 +966,9 @@ type WorkspaceType { """Workspace created date""" createdAt: DateTime! + """Enable AI""" + enableAi: Boolean! + """Enable url previous when sharing""" enableUrlPreview: Boolean! @@ -976,6 +1016,9 @@ type WorkspaceType { """The team subscription of the workspace, if exists.""" subscription: SubscriptionType + + """if workspace is team workspace""" + team: Boolean! } type tokenType { diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts index 6eff56f5d7..5aba7255e9 100644 --- a/packages/backend/server/tests/feature.spec.ts +++ b/packages/backend/server/tests/feature.spec.ts @@ -1,7 +1,6 @@ /// -import { INestApplication, Injectable } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { INestApplication } from '@nestjs/common'; import type { TestFn } from 'ava'; import ava from 'ava'; @@ -12,32 +11,10 @@ import { FeatureService, FeatureType, } from '../src/core/features'; -import { Permission } from '../src/core/permission'; -import { UserType } from '../src/core/user/types'; import { WorkspaceResolver } from '../src/core/workspaces/resolvers'; import { Config, ConfigModule } from '../src/fundamentals/config'; import { createTestingApp } from './utils'; - -@Injectable() -class WorkspaceResolverMock { - constructor(private readonly prisma: PrismaClient) {} - - async createWorkspace(user: UserType, _init: null) { - const workspace = await this.prisma.workspace.create({ - data: { - public: false, - permissions: { - create: { - type: Permission.Owner, - userId: user.id, - accepted: true, - }, - }, - }, - }); - return workspace; - } -} +import { WorkspaceResolverMock } from './utils/feature'; const test = ava as TestFn<{ auth: AuthService; @@ -105,7 +82,7 @@ test('should be able to check early access', async t => { const f2 = await management.canEarlyAccess(u1.email); t.true(f2, 'should have early access'); - const f3 = await feature.listFeatureUsers(FeatureType.EarlyAccess); + const f3 = await feature.listUsersByFeature(FeatureType.EarlyAccess); t.is(f3.length, 1, 'should have 1 user'); t.is(f3[0].id, u1.id, 'should be the same user'); }); @@ -179,7 +156,7 @@ test('should be able to check workspace feature', async t => { const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot); t.true(f2, 'should have copilot'); - const f3 = await feature.listFeatureWorkspaces(FeatureType.Copilot); + const f3 = await feature.listWorkspacesByFeature(FeatureType.Copilot); t.is(f3.length, 1, 'should have 1 workspace'); t.is(f3[0].id, w1.id, 'should be the same workspace'); }); diff --git a/packages/backend/server/tests/quota.spec.ts b/packages/backend/server/tests/quota.spec.ts index ec3d0e6b04..fd7c9a3d34 100644 --- a/packages/backend/server/tests/quota.spec.ts +++ b/packages/backend/server/tests/quota.spec.ts @@ -11,29 +11,41 @@ import { QuotaService, QuotaType, } from '../src/core/quota'; +import { OneGB, OneMB } from '../src/core/quota/constant'; import { FreePlan, ProPlan } from '../src/core/quota/schema'; import { StorageModule } from '../src/core/storage'; +import { WorkspaceResolver } from '../src/core/workspaces/resolvers'; import { createTestingModule } from './utils'; +import { WorkspaceResolverMock } from './utils/feature'; const test = ava as TestFn<{ auth: AuthService; quota: QuotaService; quotaManager: QuotaManagementService; + workspace: WorkspaceResolver; module: TestingModule; }>; test.beforeEach(async t => { const module = await createTestingModule({ imports: [StorageModule, QuotaModule], + providers: [WorkspaceResolver], + tapModule: module => { + module + .overrideProvider(WorkspaceResolver) + .useClass(WorkspaceResolverMock); + }, }); const quota = module.get(QuotaService); const quotaManager = module.get(QuotaManagementService); + const workspace = module.get(WorkspaceResolver); const auth = module.get(AuthService); t.context.module = module; t.context.quota = quota; t.context.quotaManager = quotaManager; + t.context.workspace = workspace; t.context.auth = auth; }); @@ -128,3 +140,28 @@ test('should be able to check quota', async t => { 'should be free plan' ); }); + +test('should be able to override quota', async t => { + const { auth, quotaManager, workspace } = t.context; + + const u1 = await auth.signUp('test@affine.pro', '123456'); + const w1 = await workspace.createWorkspace(u1, null); + + const wq1 = await quotaManager.getWorkspaceUsage(w1.id); + t.is(wq1.blobLimit, 10 * OneMB, 'should be 10MB'); + t.is(wq1.businessBlobLimit, 100 * OneMB, 'should be 100MB'); + t.is(wq1.memberLimit, 3, 'should be 3'); + + await quotaManager.addTeamWorkspace(w1.id, 'test'); + const wq2 = await quotaManager.getWorkspaceUsage(w1.id); + t.is(wq2.storageQuota, 120 * OneGB, 'should be override to 100GB'); + t.is(wq2.businessBlobLimit, 500 * OneMB, 'should be override to 500MB'); + t.is(wq2.memberLimit, 1, 'should be override to 1'); + + await quotaManager.updateWorkspaceConfig(w1.id, QuotaType.TeamPlanV1, { + memberLimit: 2, + }); + const wq3 = await quotaManager.getWorkspaceUsage(w1.id); + t.is(wq3.storageQuota, 140 * OneGB, 'should be override to 120GB'); + t.is(wq3.memberLimit, 2, 'should be override to 1'); +}); diff --git a/packages/backend/server/tests/team.e2e.ts b/packages/backend/server/tests/team.e2e.ts new file mode 100644 index 0000000000..10196301f1 --- /dev/null +++ b/packages/backend/server/tests/team.e2e.ts @@ -0,0 +1,287 @@ +/// + +import { INestApplication } from '@nestjs/common'; +import { WorkspaceMemberStatus } from '@prisma/client'; +import type { TestFn } from 'ava'; +import ava from 'ava'; + +import { AppModule } from '../src/app.module'; +import { AuthService } from '../src/core/auth'; +import { Permission, PermissionService } from '../src/core/permission'; +import { + QuotaManagementService, + QuotaService, + QuotaType, +} from '../src/core/quota'; +import { + acceptInviteById, + createTestingApp, + createWorkspace, + grantMember, + inviteLink, + inviteUser, + inviteUsers, + leaveWorkspace, + PermissionEnum, + signUp, + sleep, + UserAuthedType, +} from './utils'; + +const test = ava as TestFn<{ + app: INestApplication; + auth: AuthService; + quota: QuotaService; + quotaManager: QuotaManagementService; + permissions: PermissionService; +}>; + +test.beforeEach(async t => { + const { app } = await createTestingApp({ + imports: [AppModule], + }); + + const quota = app.get(QuotaService); + const quotaManager = app.get(QuotaManagementService); + const permissions = app.get(PermissionService); + const auth = app.get(AuthService); + + t.context.app = app; + t.context.quota = quota; + t.context.quotaManager = quotaManager; + t.context.permissions = permissions; + t.context.auth = auth; +}); + +test.afterEach.always(async t => { + await t.context.app.close(); +}); + +const init = async (app: INestApplication, memberLimit = 10) => { + const owner = await signUp(app, 'test', 'test@affine.pro', '123456'); + const ws = await createWorkspace(app, owner.token.token); + + const quota = app.get(QuotaManagementService); + await quota.addTeamWorkspace(ws.id, 'test'); + await quota.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, { + memberLimit, + }); + + const invite = async ( + email: string, + permission: PermissionEnum = 'Write' + ) => { + const member = await signUp(app, email.split('@')[0], email, '123456'); + const inviteId = await inviteUser( + app, + owner.token.token, + ws.id, + member.email, + permission + ); + await acceptInviteById(app, ws.id, inviteId); + return member; + }; + + const inviteBatch = async (emails: string[]) => { + const members = []; + for (const email of emails) { + const member = await signUp(app, email.split('@')[0], email, '123456'); + members.push(member); + } + const invites = await inviteUsers(app, owner.token.token, ws.id, emails); + return [members, invites] as const; + }; + + const createInviteLink = async () => { + const inviteId = await inviteLink(app, owner.token.token, ws.id, 'OneDay'); + return async (email: string): Promise => { + const member = await signUp(app, email.split('@')[0], email, '123456'); + await acceptInviteById(app, ws.id, inviteId, false, member.token.token); + return member; + }; + }; + + const admin = await invite('admin@affine.pro', 'Admin'); + const write = await invite('member1@affine.pro'); + const read = await invite('member2@affine.pro', 'Read'); + + return { + invite, + inviteBatch, + createInviteLink, + owner, + ws, + admin, + write, + read, + }; +}; + +test('should be able to check seat limit', async t => { + const { app, permissions, quotaManager } = t.context; + const { invite, inviteBatch, ws } = await init(app, 4); + + { + // invite + await t.throwsAsync( + invite('member3@affine.pro', 'Read'), + { message: 'You have exceeded your workspace member quota.' }, + 'should throw error if exceed member limit' + ); + await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, { + memberLimit: 5, + }); + await t.notThrowsAsync( + invite('member4@affine.pro', 'Read'), + 'should not throw error if not exceed member limit' + ); + } + + { + const members1 = inviteBatch(['member5@affine.pro']); + // invite batch + await t.notThrowsAsync( + members1, + 'should not throw error in batch invite event reach limit' + ); + + t.is( + await permissions.getWorkspaceMemberStatus( + ws.id, + (await members1)[0][0].id + ), + WorkspaceMemberStatus.NeedMoreSeat, + 'should be able to check member status' + ); + + // refresh seat, fifo + sleep(1000); + const [[members2]] = await inviteBatch(['member6@affine.pro']); + await permissions.refreshSeatStatus(ws.id, 6); + + t.is( + await permissions.getWorkspaceMemberStatus( + ws.id, + (await members1)[0][0].id + ), + WorkspaceMemberStatus.Accepted, + 'should become accepted after refresh' + ); + t.is( + await permissions.getWorkspaceMemberStatus(ws.id, members2.id), + WorkspaceMemberStatus.NeedMoreSeat, + 'should not change status' + ); + } +}); + +test('should be able to grant team member permission', async t => { + const { app, permissions } = t.context; + const { owner, ws, admin, write, read } = await init(app); + + await t.throwsAsync( + grantMember(app, read.token.token, ws.id, write.id, 'Write'), + { instanceOf: Error }, + 'should throw error if not owner' + ); + await t.throwsAsync( + grantMember(app, write.token.token, ws.id, read.id, 'Write'), + { instanceOf: Error }, + 'should throw error if not owner' + ); + await t.throwsAsync( + grantMember(app, admin.token.token, ws.id, read.id, 'Write'), + { instanceOf: Error }, + 'should throw error if not owner' + ); + + { + // owner should be able to grant permission + t.true( + await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Read), + 'should be able to check permission' + ); + t.truthy( + await grantMember(app, owner.token.token, ws.id, read.id, 'Admin'), + 'should be able to grant permission' + ); + t.true( + await permissions.tryCheckWorkspaceIs(ws.id, read.id, Permission.Admin), + 'should be able to check permission' + ); + } +}); + +test('should be able to leave workspace', async t => { + const { app } = t.context; + const { owner, ws, admin, write, read } = await init(app); + + t.false( + await leaveWorkspace(app, owner.token.token, ws.id), + 'owner should not be able to leave workspace' + ); + t.true( + await leaveWorkspace(app, admin.token.token, ws.id), + 'admin should be able to leave workspace' + ); + t.true( + await leaveWorkspace(app, write.token.token, ws.id), + 'write should be able to leave workspace' + ); + t.true( + await leaveWorkspace(app, read.token.token, ws.id), + 'read should be able to leave workspace' + ); +}); + +test('should be able to invite by link', async t => { + const { app, permissions, quotaManager } = t.context; + const { createInviteLink, ws } = await init(app, 4); + const invite = await createInviteLink(); + { + // invite link + const members: UserAuthedType[] = []; + await t.notThrowsAsync(async () => { + members.push(await invite('member3@affine.pro')); + members.push(await invite('member4@affine.pro')); + }, 'should not throw error even exceed member limit'); + const [m3, m4] = members; + + t.is( + await permissions.getWorkspaceMemberStatus(ws.id, m3.id), + WorkspaceMemberStatus.NeedMoreSeatAndReview, + 'should not change status' + ); + t.is( + await permissions.getWorkspaceMemberStatus(ws.id, m4.id), + WorkspaceMemberStatus.NeedMoreSeatAndReview, + 'should not change status' + ); + + await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, { + memberLimit: 5, + }); + await permissions.refreshSeatStatus(ws.id, 5); + t.is( + await permissions.getWorkspaceMemberStatus(ws.id, m3.id), + WorkspaceMemberStatus.UnderReview, + 'should not change status' + ); + t.is( + await permissions.getWorkspaceMemberStatus(ws.id, m4.id), + WorkspaceMemberStatus.NeedMoreSeatAndReview, + 'should not change status' + ); + + await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, { + memberLimit: 6, + }); + await permissions.refreshSeatStatus(ws.id, 6); + t.is( + await permissions.getWorkspaceMemberStatus(ws.id, m4.id), + WorkspaceMemberStatus.UnderReview, + 'should not change status' + ); + } +}); diff --git a/packages/backend/server/tests/utils/feature.ts b/packages/backend/server/tests/utils/feature.ts new file mode 100644 index 0000000000..3b25b48670 --- /dev/null +++ b/packages/backend/server/tests/utils/feature.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; + +import { Permission } from '../../src/core/permission'; +import { UserType } from '../../src/core/user/types'; + +@Injectable() +export class WorkspaceResolverMock { + constructor(private readonly prisma: PrismaClient) {} + + async createWorkspace(user: UserType, _init: null) { + const workspace = await this.prisma.workspace.create({ + data: { + public: false, + permissions: { + create: { + type: Permission.Owner, + userId: user.id, + accepted: true, + status: WorkspaceMemberStatus.Accepted, + }, + }, + }, + }); + return workspace; + } +} diff --git a/packages/backend/server/tests/utils/invite.ts b/packages/backend/server/tests/utils/invite.ts index 3bf0a3853b..8daa903ae3 100644 --- a/packages/backend/server/tests/utils/invite.ts +++ b/packages/backend/server/tests/utils/invite.ts @@ -3,13 +3,14 @@ import request from 'supertest'; import type { InvitationType } from '../../src/core/workspaces'; import { gql } from './common'; +import { PermissionEnum } from './utils'; export async function inviteUser( app: INestApplication, token: string, workspaceId: string, email: string, - permission: string, + permission: PermissionEnum, sendInviteMail = false ): Promise { const res = await request(app.getHttpServer()) @@ -24,18 +25,81 @@ export async function inviteUser( `, }) .expect(200); + if (res.body.errors) { + throw new Error(res.body.errors[0].message); + } return res.body.data.invite; } +export async function inviteUsers( + app: INestApplication, + token: string, + workspaceId: string, + emails: string[], + sendInviteMail = false +): Promise> { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) { + inviteBatch( + workspaceId: $workspaceId + emails: $emails + sendInviteMail: $sendInviteMail + ) { + email + inviteId + sentSuccess + } + } + `, + variables: { workspaceId, emails, sendInviteMail }, + }) + .expect(200); + if (res.body.errors) { + throw new Error(res.body.errors[0].message); + } + return res.body.data.inviteBatch; +} + +export async function inviteLink( + app: INestApplication, + token: string, + workspaceId: string, + expireTime: 'OneDay' | 'ThreeDays' | 'OneWeek' | 'OneMonth' +): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + inviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) + } + `, + }) + .expect(200); + if (res.body.errors) { + throw new Error(res.body.errors[0].message); + } + return res.body.data.inviteLink; +} + export async function acceptInviteById( app: INestApplication, workspaceId: string, inviteId: string, - sendAcceptMail = false + sendAcceptMail = false, + token: string = '' ): Promise { const res = await request(app.getHttpServer()) .post(gql) .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .auth(token, { type: 'bearer' }) .send({ query: ` mutation { @@ -44,6 +108,9 @@ export async function acceptInviteById( `, }) .expect(200); + if (res.body.errors) { + throw new Error(res.body.errors[0].message); + } return res.body.data.acceptInviteById; } @@ -65,6 +132,9 @@ export async function leaveWorkspace( `, }) .expect(200); + if (res.body.errors) { + throw new Error(res.body.errors[0].message); + } return res.body.data.leaveWorkspace; } diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index f364463be9..79b793a873 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -10,6 +10,8 @@ import { sessionUser } from '../../src/core/auth/service'; import { UserService, type UserType } from '../../src/core/user'; import { gql } from './common'; +export type UserAuthedType = UserType & { token: ClientTokenType }; + export async function internalSignIn(app: INestApplication, userId: string) { const auth = app.get(AuthService); @@ -49,7 +51,7 @@ export async function signUp( email: string, password: string, autoVerifyEmail = true -): Promise { +): Promise { const user = await app.get(UserService).createUser({ name, email, @@ -176,7 +178,7 @@ export async function changeEmail( userToken: string, token: string, email: string -): Promise { +): Promise { const res = await request(app.getHttpServer()) .post(gql) .auth(userToken, { type: 'bearer' }) diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index b685b6d79d..78973de963 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -14,6 +14,8 @@ import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652 import { Config, GlobalExceptionFilter } from '../../src/fundamentals'; import { GqlModule } from '../../src/fundamentals/graphql'; +export type PermissionEnum = 'Owner' | 'Admin' | 'Write' | 'Read'; + async function flushDB(client: PrismaClient) { const result: { tablename: string }[] = await client.$queryRaw`SELECT tablename diff --git a/packages/backend/server/tests/utils/workspace.ts b/packages/backend/server/tests/utils/workspace.ts index f895d07c26..a2cf6c2716 100644 --- a/packages/backend/server/tests/utils/workspace.ts +++ b/packages/backend/server/tests/utils/workspace.ts @@ -3,6 +3,7 @@ import request from 'supertest'; import type { WorkspaceType } from '../../src/core/workspaces'; import { gql } from './common'; +import { PermissionEnum } from './utils'; export async function createWorkspace( app: INestApplication, @@ -150,3 +151,32 @@ export async function revokePublicPage( .expect(200); return res.body.errors?.[0]?.message || res.body.data?.revokePublicPage; } + +export async function grantMember( + app: INestApplication, + token: string, + workspaceId: string, + userId: string, + permission: PermissionEnum +) { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .set({ 'x-request-id': 'test', 'x-operation-name': 'test' }) + .send({ + query: ` + mutation { + grantMember( + workspaceId: "${workspaceId}" + userId: "${userId}" + permission: ${permission} + ) + } + `, + }) + .expect(200); + if (res.body.errors) { + throw new Error(res.body.errors[0].message); + } + return res.body.data?.grantMember; +} diff --git a/packages/backend/server/tests/workspace-blobs.spec.ts b/packages/backend/server/tests/workspace-blobs.e2e.ts similarity index 100% rename from packages/backend/server/tests/workspace-blobs.spec.ts rename to packages/backend/server/tests/workspace-blobs.e2e.ts diff --git a/packages/backend/server/tests/workspace/controller.spec.ts b/packages/backend/server/tests/workspace/controller.spec.ts index 519829bd49..69a60e516d 100644 --- a/packages/backend/server/tests/workspace/controller.spec.ts +++ b/packages/backend/server/tests/workspace/controller.spec.ts @@ -1,7 +1,7 @@ import { Readable } from 'node:stream'; import { HttpStatus, INestApplication } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; import request from 'supertest'; @@ -182,6 +182,7 @@ test('should be able to get permission granted workspace', async t => { userId: u1.id, type: 1, accepted: true, + status: WorkspaceMemberStatus.Accepted, }, }); diff --git a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts index 9169ed226f..7d66eb0e8b 100644 --- a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts +++ b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts @@ -1,5 +1,5 @@ import { DebugLogger } from '@affine/debug'; -import type { GetEnableUrlPreviewQuery } from '@affine/graphql'; +import type { GetWorkspaceConfigQuery } from '@affine/graphql'; import type { WorkspaceService } from '@toeverything/infra'; import { backoffRetry, @@ -8,21 +8,22 @@ import { Entity, fromPromise, LiveData, - mapInto, onComplete, onStart, } from '@toeverything/infra'; -import { exhaustMap } from 'rxjs'; +import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { isBackendError, isNetworkError } from '../../cloud'; import type { WorkspaceShareSettingStore } from '../stores/share-setting'; +type EnableAi = GetWorkspaceConfigQuery['workspace']['enableAi']; type EnableUrlPreview = - GetEnableUrlPreviewQuery['workspace']['enableUrlPreview']; + GetWorkspaceConfigQuery['workspace']['enableUrlPreview']; const logger = new DebugLogger('affine:workspace-permission'); export class WorkspaceShareSetting extends Entity { + enableAi$ = new LiveData(null); enableUrlPreview$ = new LiveData(null); isLoading$ = new LiveData(false); error$ = new LiveData(null); @@ -38,7 +39,7 @@ export class WorkspaceShareSetting extends Entity { revalidate = effect( exhaustMap(() => { return fromPromise(signal => - this.store.fetchWorkspaceEnableUrlPreview( + this.store.fetchWorkspaceConfig( this.workspaceService.workspace.id, signal ) @@ -51,7 +52,13 @@ export class WorkspaceShareSetting extends Entity { when: isBackendError, count: 3, }), - mapInto(this.enableUrlPreview$), + mergeMap(value => { + if (value) { + this.enableAi$.next(value.enableAi); + this.enableUrlPreview$.next(value.enableUrlPreview); + } + return EMPTY; + }), catchErrorInto(this.error$, error => { logger.error('Failed to fetch enableUrlPreview', error); }), diff --git a/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts b/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts index 5a3006c4eb..c3d2dd69c4 100644 --- a/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts +++ b/packages/frontend/core/src/modules/share-setting/stores/share-setting.ts @@ -1,6 +1,7 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud'; import { - getEnableUrlPreviewQuery, + getWorkspaceConfigQuery, + setEnableAiMutation, setEnableUrlPreviewMutation, } from '@affine/graphql'; import { Store } from '@toeverything/infra'; @@ -10,15 +11,12 @@ export class WorkspaceShareSettingStore extends Store { super(); } - async fetchWorkspaceEnableUrlPreview( - workspaceId: string, - signal?: AbortSignal - ) { + async fetchWorkspaceConfig(workspaceId: string, signal?: AbortSignal) { if (!this.workspaceServerService.server) { throw new Error('No Server'); } const data = await this.workspaceServerService.server.gql({ - query: getEnableUrlPreviewQuery, + query: getWorkspaceConfigQuery, variables: { id: workspaceId, }, @@ -26,7 +24,27 @@ export class WorkspaceShareSettingStore extends Store { signal, }, }); - return data.workspace.enableUrlPreview; + return data.workspace; + } + + async updateWorkspaceEnableAi( + workspaceId: string, + enableAi: boolean, + signal?: AbortSignal + ) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + await this.workspaceServerService.server.gql({ + query: setEnableAiMutation, + variables: { + id: workspaceId, + enableAi, + }, + context: { + signal, + }, + }); } async updateWorkspaceEnableUrlPreview( diff --git a/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql b/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql index 14bee6e4d2..e6513c32d2 100644 --- a/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql +++ b/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql @@ -10,6 +10,7 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { inviteId accepted emailVerified + status } } } diff --git a/packages/frontend/graphql/src/graphql/get-workspaces.gql b/packages/frontend/graphql/src/graphql/get-workspaces.gql index 354a8de4b7..ecfee373c5 100644 --- a/packages/frontend/graphql/src/graphql/get-workspaces.gql +++ b/packages/frontend/graphql/src/graphql/get-workspaces.gql @@ -2,6 +2,7 @@ query getWorkspaces { workspaces { id initialized + team owner { id } diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 72917a6c43..0d9e5ceb0a 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -442,6 +442,7 @@ query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { inviteId accepted emailVerified + status } } }`, @@ -702,6 +703,7 @@ query getWorkspaces { workspaces { id initialized + team owner { id } @@ -1166,19 +1168,33 @@ mutation verifyEmail($token: String!) { }`, }; -export const getEnableUrlPreviewQuery = { - id: 'getEnableUrlPreviewQuery' as const, - operationName: 'getEnableUrlPreview', +export const getWorkspaceConfigQuery = { + id: 'getWorkspaceConfigQuery' as const, + operationName: 'getWorkspaceConfig', definitionName: 'workspace', containsFile: false, query: ` -query getEnableUrlPreview($id: String!) { +query getWorkspaceConfig($id: String!) { workspace(id: $id) { + enableAi enableUrlPreview } }`, }; +export const setEnableAiMutation = { + id: 'setEnableAiMutation' as const, + operationName: 'setEnableAi', + definitionName: 'updateWorkspace', + containsFile: false, + query: ` +mutation setEnableAi($id: ID!, $enableAi: Boolean!) { + updateWorkspace(input: {id: $id, enableAi: $enableAi}) { + id + } +}`, +}; + export const setEnableUrlPreviewMutation = { id: 'setEnableUrlPreviewMutation' as const, operationName: 'setEnableUrlPreview', @@ -1306,6 +1322,47 @@ mutation acceptInviteByInviteId($workspaceId: String!, $inviteId: String!, $send }`, }; +export const inviteBatchMutation = { + id: 'inviteBatchMutation' as const, + operationName: 'inviteBatch', + definitionName: 'inviteBatch', + containsFile: false, + query: ` +mutation inviteBatch($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) { + inviteBatch( + workspaceId: $workspaceId + emails: $emails + sendInviteMail: $sendInviteMail + ) { + email + inviteId + sentSuccess + } +}`, +}; + +export const inviteLinkMutation = { + id: 'inviteLinkMutation' as const, + operationName: 'inviteLink', + definitionName: 'inviteLink', + containsFile: false, + query: ` +mutation inviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) { + inviteLink(workspaceId: $workspaceId, expireTime: $expireTime) +}`, +}; + +export const revokeInviteLinkMutation = { + id: 'revokeInviteLinkMutation' as const, + operationName: 'revokeInviteLink', + definitionName: 'revokeInviteLink', + containsFile: false, + query: ` +mutation revokeInviteLink($workspaceId: String!) { + revokeInviteLink(workspaceId: $workspaceId) +}`, +}; + export const workspaceQuotaQuery = { id: 'workspaceQuotaQuery' as const, operationName: 'workspaceQuota', @@ -1333,3 +1390,25 @@ query workspaceQuota($id: String!) { } }`, }; + +export const approveWorkspaceTeamMemberMutation = { + id: 'approveWorkspaceTeamMemberMutation' as const, + operationName: 'approveWorkspaceTeamMember', + definitionName: 'approveMember', + containsFile: false, + query: ` +mutation approveWorkspaceTeamMember($workspaceId: String!, $userId: String!) { + approveMember(workspaceId: $workspaceId, userId: $userId) +}`, +}; + +export const grantWorkspaceTeamMemberMutation = { + id: 'grantWorkspaceTeamMemberMutation' as const, + operationName: 'grantWorkspaceTeamMember', + definitionName: 'grantMember', + containsFile: false, + query: ` +mutation grantWorkspaceTeamMember($workspaceId: String!, $userId: String!, $permission: Permission!) { + grantMember(workspaceId: $workspaceId, userId: $userId, permission: $permission) +}`, +}; diff --git a/packages/frontend/graphql/src/graphql/workspace-config.gql b/packages/frontend/graphql/src/graphql/workspace-config.gql new file mode 100644 index 0000000000..ce7ff27d00 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-config.gql @@ -0,0 +1,6 @@ +query getWorkspaceConfig($id: String!) { + workspace(id: $id) { + enableAi + enableUrlPreview + } +} diff --git a/packages/frontend/graphql/src/graphql/workspace-enable-ai.gql b/packages/frontend/graphql/src/graphql/workspace-enable-ai.gql new file mode 100644 index 0000000000..864cd57ba5 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-enable-ai.gql @@ -0,0 +1,5 @@ +mutation setEnableAi($id: ID!, $enableAi: Boolean!) { + updateWorkspace(input: { id: $id, enableAi: $enableAi }) { + id + } +} diff --git a/packages/frontend/graphql/src/graphql/workspace-enable-url-preview-get.gql b/packages/frontend/graphql/src/graphql/workspace-enable-url-preview-get.gql deleted file mode 100644 index df9ac4e970..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-enable-url-preview-get.gql +++ /dev/null @@ -1,5 +0,0 @@ -query getEnableUrlPreview($id: String!) { - workspace(id: $id) { - enableUrlPreview - } -} diff --git a/packages/frontend/graphql/src/graphql/workspace-enable-url-preview-set.gql b/packages/frontend/graphql/src/graphql/workspace-enable-url-preview.gql similarity index 100% rename from packages/frontend/graphql/src/graphql/workspace-enable-url-preview-set.gql rename to packages/frontend/graphql/src/graphql/workspace-enable-url-preview.gql diff --git a/packages/frontend/graphql/src/graphql/workspace-invite-batch.gql b/packages/frontend/graphql/src/graphql/workspace-invite-batch.gql new file mode 100644 index 0000000000..331aaa17a6 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-invite-batch.gql @@ -0,0 +1,15 @@ +mutation inviteBatch( + $workspaceId: String! + $emails: [String!]! + $sendInviteMail: Boolean +) { + inviteBatch( + workspaceId: $workspaceId + emails: $emails + sendInviteMail: $sendInviteMail + ) { + email + inviteId + sentSuccess + } +} diff --git a/packages/frontend/graphql/src/graphql/workspace-invite-link.gql b/packages/frontend/graphql/src/graphql/workspace-invite-link.gql new file mode 100644 index 0000000000..17f02a903b --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-invite-link.gql @@ -0,0 +1,6 @@ +mutation inviteLink( + $workspaceId: String! + $expireTime: WorkspaceInviteLinkExpireTime! +) { + inviteLink(workspaceId: $workspaceId, expireTime: $expireTime) +} diff --git a/packages/frontend/graphql/src/graphql/workspace-invite-revoke-link.gql b/packages/frontend/graphql/src/graphql/workspace-invite-revoke-link.gql new file mode 100644 index 0000000000..a2f70f2d7b --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-invite-revoke-link.gql @@ -0,0 +1,3 @@ +mutation revokeInviteLink($workspaceId: String!) { + revokeInviteLink(workspaceId: $workspaceId) +} diff --git a/packages/frontend/graphql/src/graphql/workspace-team-approve.gql b/packages/frontend/graphql/src/graphql/workspace-team-approve.gql new file mode 100644 index 0000000000..1721373618 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-team-approve.gql @@ -0,0 +1,3 @@ +mutation approveWorkspaceTeamMember($workspaceId: String!, $userId: String!) { + approveMember(workspaceId: $workspaceId, userId: $userId) +} diff --git a/packages/frontend/graphql/src/graphql/workspace-team-grant.gql b/packages/frontend/graphql/src/graphql/workspace-team-grant.gql new file mode 100644 index 0000000000..d79d2b8f9b --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-team-grant.gql @@ -0,0 +1,11 @@ +mutation grantWorkspaceTeamMember( + $workspaceId: String! + $userId: String! + $permission: Permission! +) { + grantMember( + workspaceId: $workspaceId + userId: $userId + permission: $permission + ) +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index e12dc61401..4a18801c46 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -438,9 +438,21 @@ export interface InvitationWorkspaceType { name: Scalars['String']['output']; } +export interface InviteResult { + __typename?: 'InviteResult'; + email: Scalars['String']['output']; + /** Invite id, null if invite record create failed */ + inviteId: Maybe; + /** Invite email sent success */ + sentSuccess: Scalars['Boolean']['output']; +} + export interface InviteUserType { __typename?: 'InviteUserType'; - /** User accepted */ + /** + * User accepted + * @deprecated Use `status` instead + */ accepted: Scalars['Boolean']['output']; /** User avatar url */ avatarUrl: Maybe; @@ -462,6 +474,8 @@ export interface InviteUserType { name: Maybe; /** User permission in workspace */ permission: Permission; + /** Member invite status in workspace */ + status: WorkspaceMemberStatus; } export enum InvoiceStatus { @@ -519,6 +533,7 @@ export interface Mutation { __typename?: 'Mutation'; acceptInviteById: Scalars['Boolean']['output']; addWorkspaceFeature: Scalars['Int']['output']; + approveMember: Scalars['String']['output']; cancelSubscription: SubscriptionType; changeEmail: UserType; changePassword: Scalars['Boolean']['output']; @@ -547,7 +562,10 @@ export interface Mutation { deleteWorkspace: Scalars['Boolean']['output']; /** Create a chat session */ forkCopilotSession: Scalars['String']['output']; + grantMember: Scalars['String']['output']; invite: Scalars['String']['output']; + inviteBatch: Array; + inviteLink: Scalars['String']['output']; leaveWorkspace: Scalars['Boolean']['output']; publishPage: WorkspacePage; recoverDoc: Scalars['DateTime']['output']; @@ -556,6 +574,7 @@ export interface Mutation { removeWorkspaceFeature: Scalars['Int']['output']; resumeSubscription: SubscriptionType; revoke: Scalars['Boolean']['output']; + revokeInviteLink: Scalars['Boolean']['output']; /** @deprecated use revokePublicPage */ revokePage: Scalars['Boolean']['output']; revokePublicPage: WorkspacePage; @@ -598,6 +617,11 @@ export interface MutationAddWorkspaceFeatureArgs { workspaceId: Scalars['String']['input']; } +export interface MutationApproveMemberArgs { + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationCancelSubscriptionArgs { idempotencyKey?: InputMaybe; plan?: InputMaybe; @@ -665,6 +689,12 @@ export interface MutationForkCopilotSessionArgs { options: ForkChatSessionInput; } +export interface MutationGrantMemberArgs { + permission: Permission; + userId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationInviteArgs { email: Scalars['String']['input']; permission: Permission; @@ -672,6 +702,17 @@ export interface MutationInviteArgs { workspaceId: Scalars['String']['input']; } +export interface MutationInviteBatchArgs { + emails: Array; + sendInviteMail?: InputMaybe; + workspaceId: Scalars['String']['input']; +} + +export interface MutationInviteLinkArgs { + expireTime: WorkspaceInviteLinkExpireTime; + workspaceId: Scalars['String']['input']; +} + export interface MutationLeaveWorkspaceArgs { sendLeaveMail?: InputMaybe; workspaceId: Scalars['String']['input']; @@ -706,6 +747,10 @@ export interface MutationRevokeArgs { workspaceId: Scalars['String']['input']; } +export interface MutationRevokeInviteLinkArgs { + workspaceId: Scalars['String']['input']; +} + export interface MutationRevokePageArgs { pageId: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -1144,6 +1189,8 @@ export interface UpdateUserInput { } export interface UpdateWorkspaceInput { + /** Enable AI */ + enableAi?: InputMaybe; /** Enable url previous when sharing */ enableUrlPreview?: InputMaybe; id: Scalars['ID']['input']; @@ -1222,6 +1269,22 @@ export interface WorkspaceBlobSizes { size: Scalars['SafeInt']['output']; } +/** Workspace invite link expire time */ +export enum WorkspaceInviteLinkExpireTime { + OneDay = 'OneDay', + OneMonth = 'OneMonth', + OneWeek = 'OneWeek', + ThreeDays = 'ThreeDays', +} + +/** Member invite status in workspace */ +export enum WorkspaceMemberStatus { + Accepted = 'Accepted', + NeedMoreSeat = 'NeedMoreSeat', + Pending = 'Pending', + UnderReview = 'UnderReview', +} + export interface WorkspacePage { __typename?: 'WorkspacePage'; id: Scalars['String']['output']; @@ -1248,6 +1311,8 @@ export interface WorkspaceType { blobsSize: Scalars['Int']['output']; /** Workspace created date */ createdAt: Scalars['DateTime']['output']; + /** Enable AI */ + enableAi: Scalars['Boolean']['output']; /** Enable url previous when sharing */ enableUrlPreview: Scalars['Boolean']['output']; /** Enabled features of workspace */ @@ -1284,6 +1349,8 @@ export interface WorkspaceType { sharedPages: Array; /** The team subscription of the workspace, if exists. */ subscription: Maybe; + /** if workspace is team workspace */ + team: Scalars['Boolean']['output']; } export interface WorkspaceTypeHistoriesArgs { @@ -1706,6 +1773,7 @@ export type GetMembersByWorkspaceIdQuery = { inviteId: string; accepted: boolean; emailVerified: boolean | null; + status: WorkspaceMemberStatus; }>; }; }; @@ -1939,6 +2007,7 @@ export type GetWorkspacesQuery = { __typename?: 'WorkspaceType'; id: string; initialized: boolean; + team: boolean; owner: { __typename?: 'UserType'; id: string }; }>; }; @@ -2361,13 +2430,27 @@ export type VerifyEmailMutation = { verifyEmail: boolean; }; -export type GetEnableUrlPreviewQueryVariables = Exact<{ +export type GetWorkspaceConfigQueryVariables = Exact<{ id: Scalars['String']['input']; }>; -export type GetEnableUrlPreviewQuery = { +export type GetWorkspaceConfigQuery = { __typename?: 'Query'; - workspace: { __typename?: 'WorkspaceType'; enableUrlPreview: boolean }; + workspace: { + __typename?: 'WorkspaceType'; + enableAi: boolean; + enableUrlPreview: boolean; + }; +}; + +export type SetEnableAiMutationVariables = Exact<{ + id: Scalars['ID']['input']; + enableAi: Scalars['Boolean']['input']; +}>; + +export type SetEnableAiMutation = { + __typename?: 'Mutation'; + updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; export type SetEnableUrlPreviewMutationVariables = Exact<{ @@ -2469,6 +2552,41 @@ export type AcceptInviteByInviteIdMutation = { acceptInviteById: boolean; }; +export type InviteBatchMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + emails: Array | Scalars['String']['input']; + sendInviteMail?: InputMaybe; +}>; + +export type InviteBatchMutation = { + __typename?: 'Mutation'; + inviteBatch: Array<{ + __typename?: 'InviteResult'; + email: string; + inviteId: string | null; + sentSuccess: boolean; + }>; +}; + +export type InviteLinkMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + expireTime: WorkspaceInviteLinkExpireTime; +}>; + +export type InviteLinkMutation = { + __typename?: 'Mutation'; + inviteLink: string; +}; + +export type RevokeInviteLinkMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type RevokeInviteLinkMutation = { + __typename?: 'Mutation'; + revokeInviteLink: boolean; +}; + export type WorkspaceQuotaQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -2498,6 +2616,27 @@ export type WorkspaceQuotaQuery = { }; }; +export type ApproveWorkspaceTeamMemberMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + userId: Scalars['String']['input']; +}>; + +export type ApproveWorkspaceTeamMemberMutation = { + __typename?: 'Mutation'; + approveMember: string; +}; + +export type GrantWorkspaceTeamMemberMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + userId: Scalars['String']['input']; + permission: Permission; +}>; + +export type GrantWorkspaceTeamMemberMutation = { + __typename?: 'Mutation'; + grantMember: string; +}; + export type Queries = | { name: 'adminServerConfigQuery'; @@ -2675,9 +2814,9 @@ export type Queries = response: SubscriptionQuery; } | { - name: 'getEnableUrlPreviewQuery'; - variables: GetEnableUrlPreviewQueryVariables; - response: GetEnableUrlPreviewQuery; + name: 'getWorkspaceConfigQuery'; + variables: GetWorkspaceConfigQueryVariables; + response: GetWorkspaceConfigQuery; } | { name: 'enabledFeaturesQuery'; @@ -2891,6 +3030,11 @@ export type Mutations = variables: VerifyEmailMutationVariables; response: VerifyEmailMutation; } + | { + name: 'setEnableAiMutation'; + variables: SetEnableAiMutationVariables; + response: SetEnableAiMutation; + } | { name: 'setEnableUrlPreviewMutation'; variables: SetEnableUrlPreviewMutationVariables; @@ -2920,4 +3064,29 @@ export type Mutations = name: 'acceptInviteByInviteIdMutation'; variables: AcceptInviteByInviteIdMutationVariables; response: AcceptInviteByInviteIdMutation; + } + | { + name: 'inviteBatchMutation'; + variables: InviteBatchMutationVariables; + response: InviteBatchMutation; + } + | { + name: 'inviteLinkMutation'; + variables: InviteLinkMutationVariables; + response: InviteLinkMutation; + } + | { + name: 'revokeInviteLinkMutation'; + variables: RevokeInviteLinkMutationVariables; + response: RevokeInviteLinkMutation; + } + | { + name: 'approveWorkspaceTeamMemberMutation'; + variables: ApproveWorkspaceTeamMemberMutationVariables; + response: ApproveWorkspaceTeamMemberMutation; + } + | { + name: 'grantWorkspaceTeamMemberMutation'; + variables: GrantWorkspaceTeamMemberMutationVariables; + response: GrantWorkspaceTeamMemberMutation; }; diff --git a/tests/kit/utils/cloud.ts b/tests/kit/utils/cloud.ts index 277e7abf17..e110b5280d 100644 --- a/tests/kit/utils/cloud.ts +++ b/tests/kit/utils/cloud.ts @@ -90,6 +90,7 @@ export async function addUserToWorkspace( workspaceId: workspace.id, userId, accepted: true, + status: 'Accepted', type: permission, }, });