mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-28 03:12:19 +08:00
feat: impl unlimited features (#5659)
This commit is contained in:
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 ========
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user