diff --git a/packages/backend/server/src/core/features/feature.ts b/packages/backend/server/src/core/features/feature.ts index 99c78a4eba..48d89d53b8 100644 --- a/packages/backend/server/src/core/features/feature.ts +++ b/packages/backend/server/src/core/features/feature.ts @@ -50,7 +50,7 @@ export class UnlimitedWorkspaceFeatureConfig extends FeatureConfig { super(data); if (this.config.feature !== FeatureType.UnlimitedWorkspace) { - throw new Error('Invalid feature config: type is not EarlyAccess'); + throw new Error('Invalid feature config: type is not UnlimitedWorkspace'); } } } diff --git a/packages/backend/server/src/core/quota/index.ts b/packages/backend/server/src/core/quota/index.ts index c49462a968..a84d09a367 100644 --- a/packages/backend/server/src/core/quota/index.ts +++ b/packages/backend/server/src/core/quota/index.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; +import { FeatureModule } from '../features'; import { StorageModule } from '../storage'; import { PermissionService } from '../workspaces/permission'; import { QuotaService } from './service'; @@ -12,8 +13,7 @@ import { QuotaManagementService } from './storage'; * - quota statistics */ @Module({ - // FIXME: Quota really need to know `Storage`? - imports: [StorageModule], + imports: [FeatureModule, StorageModule], providers: [PermissionService, QuotaService, QuotaManagementService], exports: [QuotaService, QuotaManagementService], }) diff --git a/packages/backend/server/src/core/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts index 70199007f3..8e853e5174 100644 --- a/packages/backend/server/src/core/quota/storage.ts +++ b/packages/backend/server/src/core/quota/storage.ts @@ -1,13 +1,16 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { FeatureService, FeatureType } from '../features'; import { WorkspaceBlobStorage } from '../storage'; import { PermissionService } from '../workspaces/permission'; +import { OneGB } from './constant'; import { QuotaService } from './service'; -import { QuotaQueryType } from './types'; +import { formatSize, QuotaQueryType } from './types'; @Injectable() export class QuotaManagementService { constructor( + private readonly feature: FeatureService, private readonly quota: QuotaService, private readonly permissions: PermissionService, private readonly storage: WorkspaceBlobStorage @@ -25,7 +28,6 @@ export class QuotaManagementService { storageQuota: quota.feature.storageQuota, historyPeriod: quota.feature.historyPeriod, memberLimit: quota.feature.memberLimit, - humanReadableName: quota.feature.humanReadable.name, }; } @@ -46,12 +48,54 @@ export class QuotaManagementService { const { user: owner } = await this.permissions.getWorkspaceOwner(workspaceId); if (!owner) throw new NotFoundException('Workspace owner not found'); - const { humanReadableName, storageQuota, blobLimit } = - await this.getUserQuota(owner.id); + const { + feature: { + name, + blobLimit, + historyPeriod, + memberLimit, + storageQuota, + humanReadable, + }, + } = await this.quota.getUserQuota(owner.id); // get all workspaces size of owner used const usedSize = await this.getUserUsage(owner.id); - return { humanReadableName, storageQuota, usedSize, blobLimit }; + const quota = { + name, + blobLimit, + historyPeriod, + memberLimit, + storageQuota, + humanReadable, + usedSize, + }; + + // relax restrictions if workspace has unlimited feature + // todo(@darkskygit): need a mechanism to allow feature as a middleware to edit quota + const unlimited = await this.feature.hasWorkspaceFeature( + workspaceId, + FeatureType.UnlimitedWorkspace + ); + if (unlimited) { + return this.mergeUnlimitedQuota(quota); + } + + return quota; + } + + private mergeUnlimitedQuota(orig: QuotaQueryType) { + return { + ...orig, + storageQuota: 1000 * OneGB, + memberLimit: 1000, + humanReadable: { + ...orig.humanReadable, + name: 'Unlimited', + storageQuota: formatSize(1000 * OneGB), + memberLimit: '1000', + }, + }; } async checkBlobQuota(workspaceId: string, size: number) { diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index ab03a83484..8c5f83e292 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -7,6 +7,13 @@ import { ByteUnit, OneDay, OneKB } from './constant'; /// ======== quota define ======== +/** + * naming rule: + * we append Vx to the end of the feature name to indicate the version of the feature + * x is a number, start from 1, this number will be change only at the time we change the schema of config + * for example, we change the value of `blobLimit` from 10MB to 100MB, then we will only change `version` field from 1 to 2 + * but if we remove the `blobLimit` field or rename it, then we will change the Vx to Vx+1 + */ export enum QuotaType { FreePlanV1 = 'free_plan_v1', ProPlanV1 = 'pro_plan_v1', @@ -41,19 +48,46 @@ export type Quota = z.infer; /// ======== query types ======== +@ObjectType() +export class HumanReadableQuotaType { + @Field(() => String) + name!: string; + + @Field(() => String) + blobLimit!: string; + + @Field(() => String) + storageQuota!: string; + + @Field(() => String) + historyPeriod!: string; + + @Field(() => String) + memberLimit!: string; +} + @ObjectType() export class QuotaQueryType { @Field(() => String) - humanReadableName!: string; + name!: string; + + @Field(() => SafeIntResolver) + blobLimit!: number; + + @Field(() => SafeIntResolver) + historyPeriod!: number; + + @Field(() => SafeIntResolver) + memberLimit!: number; @Field(() => SafeIntResolver) storageQuota!: number; - @Field(() => SafeIntResolver) - usedSize!: number; + @Field(() => HumanReadableQuotaType) + humanReadable!: HumanReadableQuotaType; @Field(() => SafeIntResolver) - blobLimit!: number; + usedSize!: number; } /// ======== utils ======== diff --git a/packages/backend/server/src/core/workspaces/management.ts b/packages/backend/server/src/core/workspaces/management.ts index 635c606499..f4e2752290 100644 --- a/packages/backend/server/src/core/workspaces/management.ts +++ b/packages/backend/server/src/core/workspaces/management.ts @@ -119,7 +119,8 @@ export class WorkspaceManagementResolver { async availableFeatures( @CurrentUser() user: UserType ): Promise { - if (await this.feature.canEarlyAccess(user.email)) { + const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email); + if (isEarlyAccessUser) { return [FeatureType.Copilot]; } else { return []; diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 290f9241f3..6edcad2ac4 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -30,7 +30,6 @@ import { } from '../../../fundamentals'; import { Auth, CurrentUser, Public } from '../../auth'; import { AuthService } from '../../auth/service'; -import { FeatureManagementService, FeatureType } from '../../features'; import { QuotaManagementService, QuotaQueryType } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; import { UsersService, UserType } from '../../users'; @@ -60,7 +59,6 @@ export class WorkspaceResolver { private readonly mailer: MailService, private readonly prisma: PrismaService, private readonly permissions: PermissionService, - private readonly feature: FeatureManagementService, private readonly quota: QuotaManagementService, private readonly users: UsersService, private readonly event: EventEmitter, @@ -338,26 +336,20 @@ export class WorkspaceResolver { throw new ForbiddenException('Cannot change owner'); } - const unlimited = await this.feature.hasWorkspaceFeature( - workspaceId, - FeatureType.UnlimitedWorkspace - ); - if (!unlimited) { - // member limit check - const [memberCount, quota] = await Promise.all([ - this.prisma.workspaceUserPermission.count({ - where: { workspaceId }, - }), - this.quota.getUserQuota(user.id), - ]); - if (memberCount >= quota.memberLimit) { - throw new GraphQLError('Workspace member limit reached', { - extensions: { - status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE], - code: HttpStatus.PAYLOAD_TOO_LARGE, - }, - }); - } + // member limit check + const [memberCount, quota] = await Promise.all([ + this.prisma.workspaceUserPermission.count({ + where: { workspaceId }, + }), + this.quota.getWorkspaceUsage(workspaceId), + ]); + if (memberCount >= quota.memberLimit) { + throw new GraphQLError('Workspace member limit reached', { + extensions: { + status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE], + code: HttpStatus.PAYLOAD_TOO_LARGE, + }, + }); } let target = await this.users.findUserByEmail(email); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 5783aaa545..23e21f2aaf 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -24,6 +24,14 @@ enum FeatureType { UnlimitedWorkspace } +type HumanReadableQuotaType { + blobLimit: String! + historyPeriod: String! + memberLimit: String! + name: String! + storageQuota: String! +} + type InvitationType { """Invitee information""" invitee: UserType! @@ -191,7 +199,10 @@ type Query { type QuotaQueryType { blobLimit: SafeInt! - humanReadableName: String! + historyPeriod: Int! + humanReadable: HumanReadableQuotaType! + memberLimit: Int! + name: String! storageQuota: SafeInt! usedSize: SafeInt! } diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx index afef7cb2e0..3c8a9c2b99 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx @@ -22,7 +22,7 @@ import { useInviteMember } from '@affine/core/hooks/affine/use-invite-member'; import { useMemberCount } from '@affine/core/hooks/affine/use-member-count'; import { type Member, useMembers } from '@affine/core/hooks/affine/use-members'; import { useRevokeMemberPermission } from '@affine/core/hooks/affine/use-revoke-member-permission'; -import { useUserQuota } from '@affine/core/hooks/use-quota'; +import { useWorkspaceQuota } from '@affine/core/hooks/use-quota'; import { useUserSubscription } from '@affine/core/hooks/use-subscription'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { Permission, SubscriptionPlan } from '@affine/graphql'; @@ -77,7 +77,7 @@ export const CloudWorkspaceMembersPanel = ({ [] ); - const quota = useUserQuota(); + const quota = useWorkspaceQuota(workspaceId); const [subscription] = useUserSubscription(); const plan = subscription?.plan ?? SubscriptionPlan.Free; const isLimited = checkMemberCountLimit(memberCount, quota?.memberLimit); diff --git a/packages/frontend/core/src/hooks/use-quota.ts b/packages/frontend/core/src/hooks/use-quota.ts index 30d8dc08c9..9ea58f6994 100644 --- a/packages/frontend/core/src/hooks/use-quota.ts +++ b/packages/frontend/core/src/hooks/use-quota.ts @@ -1,4 +1,4 @@ -import { quotaQuery } from '@affine/graphql'; +import { quotaQuery, workspaceQuotaQuery } from '@affine/graphql'; import { useQuery } from './use-query'; @@ -9,3 +9,14 @@ export const useUserQuota = () => { return data.currentUser?.quota || null; }; + +export const useWorkspaceQuota = (id: string) => { + const { data } = useQuery({ + query: workspaceQuotaQuery, + variables: { + id, + }, + }); + + return data.workspace?.quota || null; +}; diff --git a/packages/frontend/core/src/hooks/use-workspace-quota.ts b/packages/frontend/core/src/hooks/use-workspace-quota.ts index 6420226402..aea5670ab1 100644 --- a/packages/frontend/core/src/hooks/use-workspace-quota.ts +++ b/packages/frontend/core/src/hooks/use-workspace-quota.ts @@ -17,24 +17,18 @@ export const useWorkspaceQuota = (workspaceId: string) => { }, []); const quotaData = data.workspace.quota; - const blobLimit = BigInt(quotaData.blobLimit); - const storageQuota = BigInt(quotaData.storageQuota); - const usedSize = BigInt(quotaData.usedSize); - - const humanReadableBlobLimit = changeToHumanReadable(blobLimit.toString()); - const humanReadableStorageQuota = changeToHumanReadable( - storageQuota.toString() + const humanReadableUsedSize = changeToHumanReadable( + quotaData.usedSize.toString() ); - const humanReadableUsedSize = changeToHumanReadable(usedSize.toString()); return { - blobLimit, - storageQuota, - usedSize, + blobLimit: quotaData.blobLimit, + storageQuota: quotaData.storageQuota, + usedSize: quotaData.usedSize, humanReadable: { - name: quotaData.humanReadableName, - blobLimit: humanReadableBlobLimit, - storageQuota: humanReadableStorageQuota, + name: quotaData.humanReadable.name, + blobLimit: quotaData.humanReadable.blobLimit, + storageQuota: quotaData.humanReadable.storageQuota, usedSize: humanReadableUsedSize, }, }; diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 59c0a5ac24..f8b7a049cd 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -905,10 +905,19 @@ export const workspaceQuotaQuery = { query workspaceQuota($id: String!) { workspace(id: $id) { quota { - humanReadableName - storageQuota - usedSize + name blobLimit + storageQuota + historyPeriod + memberLimit + humanReadable { + name + blobLimit + storageQuota + historyPeriod + memberLimit + } + usedSize } } }`, diff --git a/packages/frontend/graphql/src/graphql/workspace-quota.gql b/packages/frontend/graphql/src/graphql/workspace-quota.gql index 7b9cc193e1..5ce5e63195 100644 --- a/packages/frontend/graphql/src/graphql/workspace-quota.gql +++ b/packages/frontend/graphql/src/graphql/workspace-quota.gql @@ -1,10 +1,19 @@ query workspaceQuota($id: String!) { workspace(id: $id) { quota { - humanReadableName - storageQuota - usedSize + name blobLimit + storageQuota + historyPeriod + memberLimit + humanReadable { + name + blobLimit + storageQuota + historyPeriod + memberLimit + } + usedSize } } } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index af7a63be6e..4dd041c7b3 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -854,10 +854,20 @@ export type WorkspaceQuotaQuery = { __typename?: 'WorkspaceType'; quota: { __typename?: 'QuotaQueryType'; - humanReadableName: string; - storageQuota: number; - usedSize: number; + name: string; blobLimit: number; + storageQuota: number; + historyPeriod: number; + memberLimit: number; + usedSize: number; + humanReadable: { + __typename?: 'HumanReadableQuotaType'; + name: string; + blobLimit: string; + storageQuota: string; + historyPeriod: string; + memberLimit: string; + }; }; }; };