feat: impl unlimited features (#5659)

This commit is contained in:
DarkSky
2024-01-26 08:28:53 +00:00
parent 04b9029d1b
commit 070d5ca471
13 changed files with 177 additions and 62 deletions

View File

@@ -50,7 +50,7 @@ export class UnlimitedWorkspaceFeatureConfig extends FeatureConfig {
super(data); super(data);
if (this.config.feature !== FeatureType.UnlimitedWorkspace) { 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');
} }
} }
} }

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FeatureModule } from '../features';
import { StorageModule } from '../storage'; import { StorageModule } from '../storage';
import { PermissionService } from '../workspaces/permission'; import { PermissionService } from '../workspaces/permission';
import { QuotaService } from './service'; import { QuotaService } from './service';
@@ -12,8 +13,7 @@ import { QuotaManagementService } from './storage';
* - quota statistics * - quota statistics
*/ */
@Module({ @Module({
// FIXME: Quota really need to know `Storage`? imports: [FeatureModule, StorageModule],
imports: [StorageModule],
providers: [PermissionService, QuotaService, QuotaManagementService], providers: [PermissionService, QuotaService, QuotaManagementService],
exports: [QuotaService, QuotaManagementService], exports: [QuotaService, QuotaManagementService],
}) })

View File

@@ -1,13 +1,16 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { FeatureService, FeatureType } from '../features';
import { WorkspaceBlobStorage } from '../storage'; import { WorkspaceBlobStorage } from '../storage';
import { PermissionService } from '../workspaces/permission'; import { PermissionService } from '../workspaces/permission';
import { OneGB } from './constant';
import { QuotaService } from './service'; import { QuotaService } from './service';
import { QuotaQueryType } from './types'; import { formatSize, QuotaQueryType } from './types';
@Injectable() @Injectable()
export class QuotaManagementService { export class QuotaManagementService {
constructor( constructor(
private readonly feature: FeatureService,
private readonly quota: QuotaService, private readonly quota: QuotaService,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly storage: WorkspaceBlobStorage private readonly storage: WorkspaceBlobStorage
@@ -25,7 +28,6 @@ export class QuotaManagementService {
storageQuota: quota.feature.storageQuota, storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod, historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit, memberLimit: quota.feature.memberLimit,
humanReadableName: quota.feature.humanReadable.name,
}; };
} }
@@ -46,12 +48,54 @@ export class QuotaManagementService {
const { user: owner } = const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId); await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) throw new NotFoundException('Workspace owner not found'); if (!owner) throw new NotFoundException('Workspace owner not found');
const { humanReadableName, storageQuota, blobLimit } = const {
await this.getUserQuota(owner.id); feature: {
name,
blobLimit,
historyPeriod,
memberLimit,
storageQuota,
humanReadable,
},
} = await this.quota.getUserQuota(owner.id);
// get all workspaces size of owner used // get all workspaces size of owner used
const usedSize = await this.getUserUsage(owner.id); 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) { async checkBlobQuota(workspaceId: string, size: number) {

View File

@@ -7,6 +7,13 @@ import { ByteUnit, OneDay, OneKB } from './constant';
/// ======== quota define ======== /// ======== 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 { export enum QuotaType {
FreePlanV1 = 'free_plan_v1', FreePlanV1 = 'free_plan_v1',
ProPlanV1 = 'pro_plan_v1', ProPlanV1 = 'pro_plan_v1',
@@ -41,19 +48,46 @@ export type Quota = z.infer<typeof QuotaSchema>;
/// ======== query types ======== /// ======== 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() @ObjectType()
export class QuotaQueryType { export class QuotaQueryType {
@Field(() => String) @Field(() => String)
humanReadableName!: string; name!: string;
@Field(() => SafeIntResolver)
blobLimit!: number;
@Field(() => SafeIntResolver)
historyPeriod!: number;
@Field(() => SafeIntResolver)
memberLimit!: number;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
storageQuota!: number; storageQuota!: number;
@Field(() => SafeIntResolver) @Field(() => HumanReadableQuotaType)
usedSize!: number; humanReadable!: HumanReadableQuotaType;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
blobLimit!: number; usedSize!: number;
} }
/// ======== utils ======== /// ======== utils ========

View File

@@ -119,7 +119,8 @@ export class WorkspaceManagementResolver {
async availableFeatures( async availableFeatures(
@CurrentUser() user: UserType @CurrentUser() user: UserType
): Promise<FeatureType[]> { ): Promise<FeatureType[]> {
if (await this.feature.canEarlyAccess(user.email)) { const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
if (isEarlyAccessUser) {
return [FeatureType.Copilot]; return [FeatureType.Copilot];
} else { } else {
return []; return [];

View File

@@ -30,7 +30,6 @@ import {
} from '../../../fundamentals'; } from '../../../fundamentals';
import { Auth, CurrentUser, Public } from '../../auth'; import { Auth, CurrentUser, Public } from '../../auth';
import { AuthService } from '../../auth/service'; import { AuthService } from '../../auth/service';
import { FeatureManagementService, FeatureType } from '../../features';
import { QuotaManagementService, QuotaQueryType } from '../../quota'; import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobStorage } from '../../storage';
import { UsersService, UserType } from '../../users'; import { UsersService, UserType } from '../../users';
@@ -60,7 +59,6 @@ export class WorkspaceResolver {
private readonly mailer: MailService, private readonly mailer: MailService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaManagementService, private readonly quota: QuotaManagementService,
private readonly users: UsersService, private readonly users: UsersService,
private readonly event: EventEmitter, private readonly event: EventEmitter,
@@ -338,26 +336,20 @@ export class WorkspaceResolver {
throw new ForbiddenException('Cannot change owner'); throw new ForbiddenException('Cannot change owner');
} }
const unlimited = await this.feature.hasWorkspaceFeature( // member limit check
workspaceId, const [memberCount, quota] = await Promise.all([
FeatureType.UnlimitedWorkspace this.prisma.workspaceUserPermission.count({
); where: { workspaceId },
if (!unlimited) { }),
// member limit check this.quota.getWorkspaceUsage(workspaceId),
const [memberCount, quota] = await Promise.all([ ]);
this.prisma.workspaceUserPermission.count({ if (memberCount >= quota.memberLimit) {
where: { workspaceId }, throw new GraphQLError('Workspace member limit reached', {
}), extensions: {
this.quota.getUserQuota(user.id), status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
]); code: HttpStatus.PAYLOAD_TOO_LARGE,
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); let target = await this.users.findUserByEmail(email);

View File

@@ -24,6 +24,14 @@ enum FeatureType {
UnlimitedWorkspace UnlimitedWorkspace
} }
type HumanReadableQuotaType {
blobLimit: String!
historyPeriod: String!
memberLimit: String!
name: String!
storageQuota: String!
}
type InvitationType { type InvitationType {
"""Invitee information""" """Invitee information"""
invitee: UserType! invitee: UserType!
@@ -191,7 +199,10 @@ type Query {
type QuotaQueryType { type QuotaQueryType {
blobLimit: SafeInt! blobLimit: SafeInt!
humanReadableName: String! historyPeriod: Int!
humanReadable: HumanReadableQuotaType!
memberLimit: Int!
name: String!
storageQuota: SafeInt! storageQuota: SafeInt!
usedSize: SafeInt! usedSize: SafeInt!
} }

View File

@@ -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 { useMemberCount } from '@affine/core/hooks/affine/use-member-count';
import { type Member, useMembers } from '@affine/core/hooks/affine/use-members'; import { type Member, useMembers } from '@affine/core/hooks/affine/use-members';
import { useRevokeMemberPermission } from '@affine/core/hooks/affine/use-revoke-member-permission'; 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 { useUserSubscription } from '@affine/core/hooks/use-subscription';
import { WorkspaceFlavour } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace';
import { Permission, SubscriptionPlan } from '@affine/graphql'; import { Permission, SubscriptionPlan } from '@affine/graphql';
@@ -77,7 +77,7 @@ export const CloudWorkspaceMembersPanel = ({
[] []
); );
const quota = useUserQuota(); const quota = useWorkspaceQuota(workspaceId);
const [subscription] = useUserSubscription(); const [subscription] = useUserSubscription();
const plan = subscription?.plan ?? SubscriptionPlan.Free; const plan = subscription?.plan ?? SubscriptionPlan.Free;
const isLimited = checkMemberCountLimit(memberCount, quota?.memberLimit); const isLimited = checkMemberCountLimit(memberCount, quota?.memberLimit);

View File

@@ -1,4 +1,4 @@
import { quotaQuery } from '@affine/graphql'; import { quotaQuery, workspaceQuotaQuery } from '@affine/graphql';
import { useQuery } from './use-query'; import { useQuery } from './use-query';
@@ -9,3 +9,14 @@ export const useUserQuota = () => {
return data.currentUser?.quota || null; return data.currentUser?.quota || null;
}; };
export const useWorkspaceQuota = (id: string) => {
const { data } = useQuery({
query: workspaceQuotaQuery,
variables: {
id,
},
});
return data.workspace?.quota || null;
};

View File

@@ -17,24 +17,18 @@ export const useWorkspaceQuota = (workspaceId: string) => {
}, []); }, []);
const quotaData = data.workspace.quota; const quotaData = data.workspace.quota;
const blobLimit = BigInt(quotaData.blobLimit); const humanReadableUsedSize = changeToHumanReadable(
const storageQuota = BigInt(quotaData.storageQuota); quotaData.usedSize.toString()
const usedSize = BigInt(quotaData.usedSize);
const humanReadableBlobLimit = changeToHumanReadable(blobLimit.toString());
const humanReadableStorageQuota = changeToHumanReadable(
storageQuota.toString()
); );
const humanReadableUsedSize = changeToHumanReadable(usedSize.toString());
return { return {
blobLimit, blobLimit: quotaData.blobLimit,
storageQuota, storageQuota: quotaData.storageQuota,
usedSize, usedSize: quotaData.usedSize,
humanReadable: { humanReadable: {
name: quotaData.humanReadableName, name: quotaData.humanReadable.name,
blobLimit: humanReadableBlobLimit, blobLimit: quotaData.humanReadable.blobLimit,
storageQuota: humanReadableStorageQuota, storageQuota: quotaData.humanReadable.storageQuota,
usedSize: humanReadableUsedSize, usedSize: humanReadableUsedSize,
}, },
}; };

View File

@@ -905,10 +905,19 @@ export const workspaceQuotaQuery = {
query workspaceQuota($id: String!) { query workspaceQuota($id: String!) {
workspace(id: $id) { workspace(id: $id) {
quota { quota {
humanReadableName name
storageQuota
usedSize
blobLimit blobLimit
storageQuota
historyPeriod
memberLimit
humanReadable {
name
blobLimit
storageQuota
historyPeriod
memberLimit
}
usedSize
} }
} }
}`, }`,

View File

@@ -1,10 +1,19 @@
query workspaceQuota($id: String!) { query workspaceQuota($id: String!) {
workspace(id: $id) { workspace(id: $id) {
quota { quota {
humanReadableName name
storageQuota
usedSize
blobLimit blobLimit
storageQuota
historyPeriod
memberLimit
humanReadable {
name
blobLimit
storageQuota
historyPeriod
memberLimit
}
usedSize
} }
} }
} }

View File

@@ -854,10 +854,20 @@ export type WorkspaceQuotaQuery = {
__typename?: 'WorkspaceType'; __typename?: 'WorkspaceType';
quota: { quota: {
__typename?: 'QuotaQueryType'; __typename?: 'QuotaQueryType';
humanReadableName: string; name: string;
storageQuota: number;
usedSize: number;
blobLimit: number; blobLimit: number;
storageQuota: number;
historyPeriod: number;
memberLimit: number;
usedSize: number;
humanReadable: {
__typename?: 'HumanReadableQuotaType';
name: string;
blobLimit: string;
storageQuota: string;
historyPeriod: string;
memberLimit: string;
};
}; };
}; };
}; };