mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-31 13:49:12 +08:00
refactor(server): use feature model (#9932)
This commit is contained in:
@@ -4,9 +4,7 @@ import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, MailService, SignUpForbidden } from '../../base';
|
||||
import { Models, type User, type UserSession } from '../../models';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
import { QuotaService } from '../quota/service';
|
||||
import { QuotaType } from '../quota/types';
|
||||
import { FeatureService } from '../features';
|
||||
import type { CurrentUser } from './session';
|
||||
|
||||
export function sessionUser(
|
||||
@@ -45,8 +43,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
private readonly config: Config,
|
||||
private readonly models: Models,
|
||||
private readonly mailer: MailService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaService
|
||||
private readonly feature: FeatureService
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -61,17 +58,24 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
password,
|
||||
});
|
||||
}
|
||||
await this.quota.switchUserQuota(devUser.id, QuotaType.ProPlanV1);
|
||||
await this.feature.addAdmin(devUser.id);
|
||||
await this.feature.addCopilot(devUser.id);
|
||||
await this.models.userFeature.add(
|
||||
devUser.id,
|
||||
'administrator',
|
||||
'dev user'
|
||||
);
|
||||
await this.models.userFeature.add(
|
||||
devUser.id,
|
||||
'unlimited_copilot',
|
||||
'dev user'
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canSignIn(email: string) {
|
||||
return this.feature.canEarlyAccess(email);
|
||||
async canSignIn(email: string) {
|
||||
return await this.feature.canEarlyAccess(email);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,16 +7,16 @@ import { Injectable, UseGuards } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ActionForbidden, getRequestResponseFromContext } from '../../base';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
import { FeatureService } from '../features/service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate, OnModuleInit {
|
||||
private feature!: FeatureManagementService;
|
||||
private feature!: FeatureService;
|
||||
|
||||
constructor(private readonly ref: ModuleRef) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.feature = this.ref.get(FeatureManagementService, { strict: false });
|
||||
this.feature = this.ref.get(FeatureService, { strict: false });
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
|
||||
@@ -13,10 +13,10 @@ import { RuntimeConfig, RuntimeConfigType } from '@prisma/client';
|
||||
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, Runtime, URLHelper } from '../../base';
|
||||
import { Feature } from '../../models';
|
||||
import { Public } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { FeatureType } from '../features';
|
||||
import { AvailableUserFeatureConfig } from '../features/resolver';
|
||||
import { AvailableUserFeatureConfig } from '../features';
|
||||
import { ServerFlags } from './config';
|
||||
import { ENABLED_FEATURES } from './server-feature';
|
||||
import { ServerService } from './service';
|
||||
@@ -139,11 +139,7 @@ export class ServerConfigResolver {
|
||||
|
||||
@Resolver(() => ServerConfigType)
|
||||
export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
@ResolveField(() => [Feature], {
|
||||
description: 'Features for user that can be configured',
|
||||
})
|
||||
override availableUserFeatures() {
|
||||
|
||||
@@ -89,7 +89,7 @@ export class DocStorageOptions implements IDocStorageOptions {
|
||||
historyMaxAge = async (spaceId: string) => {
|
||||
const owner = await this.permission.getWorkspaceOwner(spaceId);
|
||||
const quota = await this.quota.getUserQuota(owner.id);
|
||||
return quota.feature.historyPeriod;
|
||||
return quota.historyPeriod;
|
||||
};
|
||||
|
||||
historyMinInterval = (_spaceId: string) => {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { PrismaTransaction } from '../../base';
|
||||
import { Feature, FeatureSchema, FeatureType } from './types';
|
||||
|
||||
class FeatureConfig<T extends FeatureType> {
|
||||
readonly config: Feature & { feature: T };
|
||||
|
||||
constructor(data: any) {
|
||||
const config = FeatureSchema.safeParse(data);
|
||||
|
||||
if (config.success) {
|
||||
// @ts-expect-error allow
|
||||
this.config = config.data;
|
||||
} else {
|
||||
throw new Error(`Invalid quota config: ${config.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/// feature name of quota
|
||||
get name() {
|
||||
return this.config.feature;
|
||||
}
|
||||
}
|
||||
|
||||
export type FeatureConfigType<F extends FeatureType> = FeatureConfig<F>;
|
||||
|
||||
const FeatureCache = new Map<number, FeatureConfigType<FeatureType>>();
|
||||
|
||||
export async function getFeature(prisma: PrismaTransaction, featureId: number) {
|
||||
const cachedFeature = FeatureCache.get(featureId);
|
||||
|
||||
if (cachedFeature) {
|
||||
return cachedFeature;
|
||||
}
|
||||
|
||||
const feature = await prisma.feature.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
});
|
||||
if (!feature) {
|
||||
// this should unreachable
|
||||
throw new Error(`Quota config ${featureId} not found`);
|
||||
}
|
||||
|
||||
const config = new FeatureConfig(feature);
|
||||
// we always edit quota config as a new quota config
|
||||
// so we can cache it by featureId
|
||||
FeatureCache.set(featureId, config);
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -1,38 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserModule } from '../user';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import {
|
||||
AdminFeatureManagementResolver,
|
||||
FeatureManagementResolver,
|
||||
UserFeatureResolver,
|
||||
} from './resolver';
|
||||
import { FeatureService } from './service';
|
||||
import { EarlyAccessType, FeatureService } from './service';
|
||||
|
||||
/**
|
||||
* Feature module provider pre-user feature flag management.
|
||||
* includes:
|
||||
* - feature query/update/permit
|
||||
* - feature statistics
|
||||
*/
|
||||
@Module({
|
||||
imports: [UserModule],
|
||||
providers: [
|
||||
FeatureService,
|
||||
FeatureManagementService,
|
||||
FeatureManagementResolver,
|
||||
UserFeatureResolver,
|
||||
AdminFeatureManagementResolver,
|
||||
FeatureService,
|
||||
],
|
||||
exports: [FeatureService, FeatureManagementService],
|
||||
exports: [FeatureService],
|
||||
})
|
||||
export class FeatureModule {}
|
||||
|
||||
export type { FeatureConfigType } from './feature';
|
||||
export {
|
||||
type CommonFeature,
|
||||
commonFeatureSchema,
|
||||
type FeatureConfig,
|
||||
FeatureKind,
|
||||
Features,
|
||||
FeatureType,
|
||||
} from './types';
|
||||
export { EarlyAccessType, FeatureManagementService, FeatureService };
|
||||
export { EarlyAccessType, FeatureService };
|
||||
export { AvailableUserFeatureConfig } from './types';
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Runtime } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
const STAFF = ['@toeverything.info', '@affine.pro'];
|
||||
|
||||
export enum EarlyAccessType {
|
||||
App = 'app',
|
||||
AI = 'ai',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeatureManagementService {
|
||||
protected logger = new Logger(FeatureManagementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly models: Models,
|
||||
private readonly runtime: Runtime
|
||||
) {}
|
||||
|
||||
// ======== Admin ========
|
||||
|
||||
isStaff(email: string) {
|
||||
for (const domain of STAFF) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isAdmin(userId: string) {
|
||||
return this.feature.hasUserFeature(userId, FeatureType.Admin);
|
||||
}
|
||||
|
||||
addAdmin(userId: string) {
|
||||
return this.feature.addUserFeature(userId, FeatureType.Admin, 'Admin user');
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
async addEarlyAccess(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.feature.addUserFeature(
|
||||
userId,
|
||||
type === EarlyAccessType.App
|
||||
? FeatureType.EarlyAccess
|
||||
: FeatureType.AIEarlyAccess,
|
||||
'Early access user'
|
||||
);
|
||||
}
|
||||
|
||||
async removeEarlyAccess(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.feature.removeUserFeature(
|
||||
userId,
|
||||
type === EarlyAccessType.App
|
||||
? FeatureType.EarlyAccess
|
||||
: FeatureType.AIEarlyAccess
|
||||
);
|
||||
}
|
||||
|
||||
async listEarlyAccess(type: EarlyAccessType = EarlyAccessType.App) {
|
||||
return this.feature.listUsersByFeature(
|
||||
type === EarlyAccessType.App
|
||||
? FeatureType.EarlyAccess
|
||||
: FeatureType.AIEarlyAccess
|
||||
);
|
||||
}
|
||||
|
||||
async isEarlyAccessUser(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return await this.feature
|
||||
.hasUserFeature(
|
||||
userId,
|
||||
type === EarlyAccessType.App
|
||||
? FeatureType.EarlyAccess
|
||||
: FeatureType.AIEarlyAccess
|
||||
)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
/// check early access by email
|
||||
async canEarlyAccess(
|
||||
email: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
const earlyAccessControlEnabled = await this.runtime.fetch(
|
||||
'flags/earlyAccessControl'
|
||||
);
|
||||
|
||||
if (earlyAccessControlEnabled && !this.isStaff(email)) {
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return this.isEarlyAccessUser(user.id, type);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ======== CopilotFeature ========
|
||||
async addCopilot(userId: string, reason = 'Copilot plan user') {
|
||||
return this.feature.addUserFeature(
|
||||
userId,
|
||||
FeatureType.UnlimitedCopilot,
|
||||
reason
|
||||
);
|
||||
}
|
||||
|
||||
async removeCopilot(userId: string) {
|
||||
return this.feature.removeUserFeature(userId, FeatureType.UnlimitedCopilot);
|
||||
}
|
||||
|
||||
async isCopilotUser(userId: string) {
|
||||
return await this.feature.hasUserFeature(
|
||||
userId,
|
||||
FeatureType.UnlimitedCopilot
|
||||
);
|
||||
}
|
||||
|
||||
// ======== User Feature ========
|
||||
async getActivatedUserFeatures(userId: string): Promise<FeatureType[]> {
|
||||
const features = await this.feature.getUserActivatedFeatures(userId);
|
||||
return features.map(f => f.feature.name);
|
||||
}
|
||||
|
||||
// ======== Workspace Feature ========
|
||||
async addWorkspaceFeatures(
|
||||
workspaceId: string,
|
||||
feature: FeatureType,
|
||||
reason?: string
|
||||
) {
|
||||
return this.feature.addWorkspaceFeature(
|
||||
workspaceId,
|
||||
feature,
|
||||
reason || 'add feature by api'
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkspaceFeatures(workspaceId: string) {
|
||||
const features = await this.feature.getWorkspaceFeatures(workspaceId);
|
||||
return features.filter(f => f.activated).map(f => f.feature.name);
|
||||
}
|
||||
|
||||
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.feature.hasWorkspaceFeature(workspaceId, feature);
|
||||
}
|
||||
|
||||
async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.feature
|
||||
.removeWorkspaceFeature(workspaceId, feature)
|
||||
.then(c => c > 0);
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(feature: FeatureType) {
|
||||
return this.feature.listWorkspacesByFeature(feature);
|
||||
}
|
||||
}
|
||||
@@ -8,70 +8,91 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { difference } from 'lodash-es';
|
||||
|
||||
import { Config } from '../../base';
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
type UserFeatureName,
|
||||
type WorkspaceFeatureName,
|
||||
} from '../../models';
|
||||
import { Admin } from '../common';
|
||||
import { UserType } from '../user/types';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
import { AvailableUserFeatureConfig } from './types';
|
||||
|
||||
registerEnumType(EarlyAccessType, {
|
||||
name: 'EarlyAccessType',
|
||||
registerEnumType(Feature, {
|
||||
name: 'FeatureType',
|
||||
});
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class FeatureManagementResolver {
|
||||
constructor(private readonly feature: FeatureManagementService) {}
|
||||
export class UserFeatureResolver extends AvailableUserFeatureConfig {
|
||||
constructor(private readonly models: Models) {
|
||||
super();
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
@ResolveField(() => [Feature], {
|
||||
name: 'features',
|
||||
description: 'Enabled features of a user',
|
||||
})
|
||||
async userFeatures(@Parent() user: UserType) {
|
||||
return this.feature.getActivatedUserFeatures(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
export class AvailableUserFeatureConfig {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
async availableUserFeatures() {
|
||||
return this.config.isSelfhosted
|
||||
? [FeatureType.Admin, FeatureType.UnlimitedCopilot]
|
||||
: [FeatureType.EarlyAccess, FeatureType.AIEarlyAccess, FeatureType.Admin];
|
||||
const features = await this.models.userFeature.list(user.id);
|
||||
const availableUserFeatures = this.availableUserFeatures();
|
||||
return features.filter(feature => availableUserFeatures.has(feature));
|
||||
}
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Resolver(() => Boolean)
|
||||
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly feature: FeatureService
|
||||
) {
|
||||
super(config);
|
||||
constructor(private readonly models: Models) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Mutation(() => [FeatureType], {
|
||||
@Mutation(() => [Feature], {
|
||||
description: 'update user enabled feature',
|
||||
})
|
||||
async updateUserFeatures(
|
||||
@Args('id') id: string,
|
||||
@Args({ name: 'features', type: () => [FeatureType] })
|
||||
features: FeatureType[]
|
||||
@Args({ name: 'features', type: () => [Feature] })
|
||||
features: UserFeatureName[]
|
||||
) {
|
||||
const configurableFeatures = await this.availableUserFeatures();
|
||||
const configurableUserFeatures = this.configurableUserFeatures();
|
||||
const removed = difference(Array.from(configurableUserFeatures), features);
|
||||
|
||||
const removed = difference(configurableFeatures, features);
|
||||
await Promise.all(
|
||||
features.map(feature =>
|
||||
this.feature.addUserFeature(id, feature, 'admin panel')
|
||||
)
|
||||
features.map(async feature => {
|
||||
if (configurableUserFeatures.has(feature)) {
|
||||
return this.models.userFeature.add(id, feature, 'admin panel');
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
removed.map(feature => this.feature.removeUserFeature(id, feature))
|
||||
removed.map(feature => this.models.userFeature.remove(id, feature))
|
||||
);
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async addWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
|
||||
) {
|
||||
await this.models.workspaceFeature.add(
|
||||
workspaceId,
|
||||
feature,
|
||||
'by administrator'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async removeWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => Feature }) feature: WorkspaceFeatureName
|
||||
) {
|
||||
await this.models.workspaceFeature.remove(workspaceId, feature);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,355 +1,90 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { CannotDeleteAllAdminAccount } from '../../base';
|
||||
import { WorkspaceFeatureType } from '../workspaces/types';
|
||||
import { FeatureConfigType, getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
import { Runtime } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
|
||||
const STAFF = ['@toeverything.info', '@affine.pro'];
|
||||
|
||||
export enum EarlyAccessType {
|
||||
App = 'app',
|
||||
AI = 'ai',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeatureService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
protected logger = new Logger(FeatureService.name);
|
||||
|
||||
async getFeature<F extends FeatureType>(feature: F) {
|
||||
const data = await this.prisma.feature.findFirst({
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
select: { id: true },
|
||||
orderBy: { version: 'desc' },
|
||||
});
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly runtime: Runtime
|
||||
) {}
|
||||
|
||||
if (data) {
|
||||
return getFeature(this.prisma, data.id) as Promise<FeatureConfigType<F>>;
|
||||
// ======== Admin ========
|
||||
isStaff(email: string) {
|
||||
for (const domain of STAFF) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ======== User Features ========
|
||||
isAdmin(userId: string) {
|
||||
return this.models.userFeature.has(userId, 'administrator');
|
||||
}
|
||||
|
||||
async addUserFeature(
|
||||
addAdmin(userId: string) {
|
||||
return this.models.userFeature.add(userId, 'administrator', 'Admin user');
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
async addEarlyAccess(
|
||||
userId: string,
|
||||
feature: FeatureType,
|
||||
reason: string,
|
||||
expiredAt?: Date | string
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.userFeature.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
const featureId = await tx.feature
|
||||
.findFirst({
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
.then(r => r?.id);
|
||||
|
||||
if (!featureId) {
|
||||
throw new Error(`Feature ${feature} not found`);
|
||||
}
|
||||
|
||||
return tx.userFeature
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
expiredAt,
|
||||
activated: true,
|
||||
userId,
|
||||
featureId,
|
||||
},
|
||||
})
|
||||
.then(r => r.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserFeature(userId: string, feature: FeatureType) {
|
||||
if (feature === FeatureType.Admin) {
|
||||
await this.ensureNotLastAdmin(userId);
|
||||
}
|
||||
return this.prisma.userFeature
|
||||
.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
data: {
|
||||
activated: false,
|
||||
},
|
||||
})
|
||||
.then(r => r.count);
|
||||
}
|
||||
|
||||
async ensureNotLastAdmin(userId: string) {
|
||||
const count = await this.prisma.userFeature.count({
|
||||
where: {
|
||||
userId: { not: userId },
|
||||
feature: { feature: FeatureType.Admin, type: FeatureKind.Feature },
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
throw new CannotDeleteAllAdminAccount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get user's features, will included inactivated features
|
||||
* @param userId user id
|
||||
* @returns list of features
|
||||
*/
|
||||
async getUserFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Feature },
|
||||
},
|
||||
select: {
|
||||
activated: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const configs = await Promise.all(
|
||||
features.map(async feature => ({
|
||||
...feature,
|
||||
feature: await getFeature(this.prisma, feature.featureId),
|
||||
}))
|
||||
return this.models.userFeature.add(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access',
|
||||
'Early access user'
|
||||
);
|
||||
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async getUserActivatedFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Feature },
|
||||
activated: true,
|
||||
OR: [{ expiredAt: null }, { expiredAt: { gt: new Date() } }],
|
||||
},
|
||||
select: {
|
||||
activated: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const configs = await Promise.all(
|
||||
features.map(async feature => ({
|
||||
...feature,
|
||||
feature: await getFeature(this.prisma, feature.featureId),
|
||||
}))
|
||||
);
|
||||
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listUsersByFeature(feature: FeatureType) {
|
||||
return this.prisma.userFeature
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
feature: {
|
||||
feature: feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(users => users.map(user => user.user));
|
||||
}
|
||||
|
||||
async hasUserFeature(userId: string, feature: FeatureType) {
|
||||
return this.prisma.userFeature
|
||||
.count({
|
||||
where: {
|
||||
userId,
|
||||
activated: true,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
OR: [{ expiredAt: null }, { expiredAt: { gt: new Date() } }],
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
}
|
||||
|
||||
// ======== Workspace Features ========
|
||||
|
||||
async addWorkspaceFeature(
|
||||
workspaceId: string,
|
||||
feature: FeatureType,
|
||||
reason: string,
|
||||
expiredAt?: Date | string
|
||||
async removeEarlyAccess(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.workspaceFeature.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
// use latest version of feature
|
||||
const featureId = await tx.feature
|
||||
.findFirst({
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
select: { id: true },
|
||||
orderBy: { version: 'desc' },
|
||||
})
|
||||
.then(r => r?.id);
|
||||
|
||||
if (!featureId) {
|
||||
throw new Error(`Feature ${feature} not found`);
|
||||
}
|
||||
|
||||
return tx.workspaceFeature
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
expiredAt,
|
||||
activated: true,
|
||||
workspaceId,
|
||||
featureId,
|
||||
},
|
||||
})
|
||||
.then(r => r.id);
|
||||
}
|
||||
});
|
||||
return this.models.userFeature.remove(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
|
||||
);
|
||||
}
|
||||
|
||||
async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeature
|
||||
.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
data: {
|
||||
activated: false,
|
||||
},
|
||||
})
|
||||
.then(r => r.count);
|
||||
async isEarlyAccessUser(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return await this.models.userFeature.has(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* get workspace's features, will included inactivated features
|
||||
* @param workspaceId workspace id
|
||||
* @returns list of features
|
||||
*/
|
||||
async getWorkspaceFeatures(workspaceId: string) {
|
||||
const features = await this.prisma.workspaceFeature.findMany({
|
||||
where: {
|
||||
workspace: { id: workspaceId },
|
||||
feature: {
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
activated: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const configs = await Promise.all(
|
||||
features.map(async feature => ({
|
||||
...feature,
|
||||
feature: await getFeature(this.prisma, feature.featureId),
|
||||
}))
|
||||
async canEarlyAccess(
|
||||
email: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
const earlyAccessControlEnabled = await this.runtime.fetch(
|
||||
'flags/earlyAccessControl'
|
||||
);
|
||||
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listWorkspacesByFeature(
|
||||
feature: FeatureType
|
||||
): Promise<WorkspaceFeatureType[]> {
|
||||
return this.prisma.workspaceFeature
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
feature: {
|
||||
feature: feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
public: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(wss => wss.map(ws => ws.workspace));
|
||||
}
|
||||
|
||||
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeature
|
||||
.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
activated: true,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
if (earlyAccessControlEnabled && !this.isStaff(email)) {
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return this.isEarlyAccessUser(user.id, type);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../base';
|
||||
import { Feature, UserFeatureName } from '../../models';
|
||||
|
||||
@Injectable()
|
||||
export class AvailableUserFeatureConfig {
|
||||
@Inject(Config) private readonly config!: Config;
|
||||
|
||||
availableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set([
|
||||
Feature.Admin,
|
||||
Feature.UnlimitedCopilot,
|
||||
Feature.EarlyAccess,
|
||||
Feature.AIEarlyAccess,
|
||||
]);
|
||||
}
|
||||
|
||||
configurableUserFeatures(): Set<UserFeatureName> {
|
||||
return new Set(
|
||||
this.config.isSelfhosted
|
||||
? [Feature.Admin, Feature.UnlimitedCopilot]
|
||||
: [
|
||||
Feature.EarlyAccess,
|
||||
Feature.AIEarlyAccess,
|
||||
Feature.Admin,
|
||||
Feature.UnlimitedCopilot,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureAdministrator = z.object({
|
||||
feature: z.literal(FeatureType.Admin),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum FeatureType {
|
||||
// user feature
|
||||
Admin = 'administrator',
|
||||
EarlyAccess = 'early_access',
|
||||
AIEarlyAccess = 'ai_early_access',
|
||||
UnlimitedCopilot = 'unlimited_copilot',
|
||||
// workspace feature
|
||||
Copilot = 'copilot',
|
||||
UnlimitedWorkspace = 'unlimited_workspace',
|
||||
}
|
||||
|
||||
registerEnumType(FeatureType, {
|
||||
name: 'FeatureType',
|
||||
description: 'The type of workspace feature',
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureCopilot = z.object({
|
||||
feature: z.literal(FeatureType.Copilot),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureEarlyAccess = z.object({
|
||||
feature: z.literal(FeatureType.EarlyAccess),
|
||||
configs: z.object({
|
||||
// field polyfill, make it optional in the future
|
||||
whitelist: z.string().array(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const featureAIEarlyAccess = z.object({
|
||||
feature: z.literal(FeatureType.AIEarlyAccess),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { featureAdministrator } from './admin';
|
||||
import { FeatureType } from './common';
|
||||
import { featureCopilot } from './copilot';
|
||||
import { featureAIEarlyAccess, featureEarlyAccess } from './early-access';
|
||||
import { featureUnlimitedCopilot } from './unlimited-copilot';
|
||||
import { featureUnlimitedWorkspace } from './unlimited-workspace';
|
||||
|
||||
/// ======== common schema ========
|
||||
|
||||
export enum FeatureKind {
|
||||
Feature,
|
||||
Quota,
|
||||
}
|
||||
|
||||
export const commonFeatureSchema = z.object({
|
||||
feature: z.string(),
|
||||
type: z.nativeEnum(FeatureKind),
|
||||
version: z.number(),
|
||||
configs: z.unknown(),
|
||||
});
|
||||
|
||||
export type CommonFeature = z.infer<typeof commonFeatureSchema>;
|
||||
|
||||
/// ======== feature define ========
|
||||
|
||||
export const Features: Feature[] = [
|
||||
{
|
||||
feature: FeatureType.Copilot,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {
|
||||
whitelist: ['@toeverything.info'],
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
version: 2,
|
||||
configs: {
|
||||
whitelist: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.UnlimitedWorkspace,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.UnlimitedCopilot,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.AIEarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.Admin,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
];
|
||||
|
||||
/// ======== 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(FeatureConfigSchema);
|
||||
|
||||
export type FeatureConfig<F extends FeatureType> = (z.infer<
|
||||
typeof FeatureConfigSchema
|
||||
> & { feature: F })['configs'];
|
||||
|
||||
export type Feature = z.infer<typeof FeatureSchema>;
|
||||
|
||||
export { FeatureType };
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureUnlimitedCopilot = z.object({
|
||||
feature: z.literal(FeatureType.UnlimitedCopilot),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureUnlimitedWorkspace = z.object({
|
||||
feature: z.literal(FeatureType.UnlimitedWorkspace),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export const OneKB = 1024;
|
||||
export const OneMB = OneKB * OneKB;
|
||||
export const OneGB = OneKB * OneMB;
|
||||
export const OneDay = 1000 * 60 * 60 * 24;
|
||||
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../features';
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { QuotaManagementResolver } from './resolver';
|
||||
import { QuotaResolver } from './resolver';
|
||||
import { QuotaService } from './service';
|
||||
import { QuotaManagementService } from './storage';
|
||||
|
||||
/**
|
||||
* Quota module provider pre-user quota management.
|
||||
@@ -14,18 +12,11 @@ import { QuotaManagementService } from './storage';
|
||||
* - quota statistics
|
||||
*/
|
||||
@Module({
|
||||
imports: [FeatureModule, StorageModule, PermissionModule],
|
||||
providers: [QuotaService, QuotaManagementResolver, QuotaManagementService],
|
||||
exports: [QuotaService, QuotaManagementService],
|
||||
imports: [StorageModule, PermissionModule],
|
||||
providers: [QuotaService, QuotaResolver],
|
||||
exports: [QuotaService],
|
||||
})
|
||||
export class QuotaModule {}
|
||||
|
||||
export { QuotaManagementService, QuotaService };
|
||||
export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema';
|
||||
export {
|
||||
formatDate,
|
||||
formatSize,
|
||||
type QuotaBusinessType,
|
||||
QuotaQueryType,
|
||||
QuotaType,
|
||||
} from './types';
|
||||
export { QuotaService };
|
||||
export { WorkspaceQuotaHumanReadableType, WorkspaceQuotaType } from './types';
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { pick } from 'lodash-es';
|
||||
|
||||
import { PrismaTransaction } from '../../base';
|
||||
import { formatDate, formatSize, Quota, QuotaSchema } from './types';
|
||||
|
||||
const QuotaCache = new Map<number, QuotaConfig>();
|
||||
|
||||
export class QuotaConfig {
|
||||
readonly config: Quota;
|
||||
readonly override?: Partial<Quota['configs']>;
|
||||
|
||||
static async get(tx: PrismaTransaction, featureId: number) {
|
||||
const cachedQuota = QuotaCache.get(featureId);
|
||||
|
||||
if (cachedQuota) {
|
||||
return cachedQuota;
|
||||
}
|
||||
|
||||
const quota = await tx.feature.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!quota) {
|
||||
throw new Error(`Quota config ${featureId} not found`);
|
||||
}
|
||||
|
||||
const config = new QuotaConfig(quota);
|
||||
// we always edit quota config as a new quota config
|
||||
// so we can cache it by featureId
|
||||
QuotaCache.set(featureId, config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private constructor(data: any, override?: any) {
|
||||
const config = QuotaSchema.safeParse(data);
|
||||
if (config.success) {
|
||||
this.config = config.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid quota config: ${config.error.message}, ${JSON.stringify(
|
||||
data
|
||||
)})}`
|
||||
);
|
||||
}
|
||||
if (override) {
|
||||
const overrideConfig = QuotaSchema.safeParse({
|
||||
...config.data,
|
||||
configs: Object.assign({}, config.data.configs, override),
|
||||
});
|
||||
if (overrideConfig.success) {
|
||||
this.override = pick(
|
||||
overrideConfig.data.configs,
|
||||
Object.keys(override)
|
||||
);
|
||||
} 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() {
|
||||
return this.config.version;
|
||||
}
|
||||
|
||||
/// feature name of quota
|
||||
get name() {
|
||||
return this.config.feature;
|
||||
}
|
||||
|
||||
get blobLimit() {
|
||||
return this.override?.blobLimit || this.config.configs.blobLimit;
|
||||
}
|
||||
|
||||
get businessBlobLimit() {
|
||||
return (
|
||||
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() {
|
||||
const baseQuota =
|
||||
this.override?.storageQuota || this.config.configs.storageQuota;
|
||||
return baseQuota + this.additionalQuota;
|
||||
}
|
||||
|
||||
get historyPeriod() {
|
||||
return this.override?.historyPeriod || this.config.configs.historyPeriod;
|
||||
}
|
||||
|
||||
get memberLimit() {
|
||||
return this.override?.memberLimit || this.config.configs.memberLimit;
|
||||
}
|
||||
|
||||
get copilotActionLimit() {
|
||||
if ('copilotActionLimit' in this.config.configs) {
|
||||
return this.config.configs.copilotActionLimit || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get humanReadable() {
|
||||
return {
|
||||
name: this.config.configs.name,
|
||||
blobLimit: formatSize(this.blobLimit),
|
||||
storageQuota: formatSize(this.storageQuota),
|
||||
historyPeriod: formatDate(this.historyPeriod),
|
||||
memberLimit: this.memberLimit.toString(),
|
||||
copilotActionLimit: this.copilotActionLimit
|
||||
? `${this.copilotActionLimit} times`
|
||||
: 'Unlimited',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,29 @@
|
||||
import {
|
||||
Field,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import { ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { EarlyAccessType } from '../features';
|
||||
import { UserType } from '../user';
|
||||
import { QuotaService } from './service';
|
||||
import { QuotaManagementService } from './storage';
|
||||
|
||||
registerEnumType(EarlyAccessType, {
|
||||
name: 'EarlyAccessType',
|
||||
});
|
||||
|
||||
@ObjectType('UserQuotaHumanReadable')
|
||||
class UserQuotaHumanReadableType {
|
||||
@Field({ name: 'name' })
|
||||
name!: string;
|
||||
|
||||
@Field({ name: 'blobLimit' })
|
||||
blobLimit!: string;
|
||||
|
||||
@Field({ name: 'storageQuota' })
|
||||
storageQuota!: string;
|
||||
|
||||
@Field({ name: 'historyPeriod' })
|
||||
historyPeriod!: string;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: string;
|
||||
}
|
||||
|
||||
@ObjectType('UserQuota')
|
||||
class UserQuotaType {
|
||||
@Field({ name: 'name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'blobLimit' })
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'storageQuota' })
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'historyPeriod' })
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: number;
|
||||
|
||||
@Field({ name: 'humanReadable' })
|
||||
humanReadable!: UserQuotaHumanReadableType;
|
||||
}
|
||||
|
||||
@ObjectType('UserQuotaUsage')
|
||||
class UserQuotaUsageType {
|
||||
@Field(() => SafeIntResolver, { name: 'storageQuota' })
|
||||
storageQuota!: number;
|
||||
}
|
||||
import { UserQuotaType, UserQuotaUsageType } from './types';
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class QuotaManagementResolver {
|
||||
constructor(
|
||||
private readonly quota: QuotaService,
|
||||
private readonly management: QuotaManagementService
|
||||
) {}
|
||||
export class QuotaResolver {
|
||||
constructor(private readonly quota: QuotaService) {}
|
||||
|
||||
@ResolveField(() => UserQuotaType, { name: 'quota', nullable: true })
|
||||
async getQuota(@CurrentUser() me: UserType) {
|
||||
const quota = await this.quota.getUserQuota(me.id);
|
||||
@ResolveField(() => UserQuotaType, { name: 'quota' })
|
||||
async getQuota(@CurrentUser() me: UserType): Promise<UserQuotaType> {
|
||||
const quota = await this.quota.getUserQuotaWithUsage(me.id);
|
||||
|
||||
return quota.feature;
|
||||
return {
|
||||
...quota,
|
||||
humanReadable: this.quota.formatUserQuota(quota),
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => UserQuotaUsageType, { name: 'quotaUsage' })
|
||||
async getQuotaUsage(
|
||||
@CurrentUser() me: UserType
|
||||
): Promise<UserQuotaUsageType> {
|
||||
const usage = await this.management.getUserStorageUsage(me.id);
|
||||
const usage = await this.quota.getUserStorageUsage(me.id);
|
||||
|
||||
return {
|
||||
storageQuota: usage,
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { FeatureKind } from '../features/types';
|
||||
import { OneDay, OneGB, OneMB } from './constant';
|
||||
import { Quota, QuotaType } from './types';
|
||||
|
||||
export const Quotas: Quota[] = [
|
||||
{
|
||||
feature: QuotaType.FreePlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 1,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
// single blob limit 10MB
|
||||
blobLimit: 10 * OneMB,
|
||||
// total blob limit 10GB
|
||||
storageQuota: 10 * OneGB,
|
||||
// history period of validity 7 days
|
||||
historyPeriod: 7 * OneDay,
|
||||
// member limit 3
|
||||
memberLimit: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.ProPlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 1,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Pro',
|
||||
// single blob limit 100MB
|
||||
blobLimit: 100 * OneMB,
|
||||
// total blob limit 100GB
|
||||
storageQuota: 100 * OneGB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
// member limit 10
|
||||
memberLimit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.RestrictedPlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 1,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Restricted',
|
||||
// single blob limit 10MB
|
||||
blobLimit: OneMB,
|
||||
// total blob limit 1GB
|
||||
storageQuota: 10 * OneMB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
// member limit 10
|
||||
memberLimit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.FreePlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 2,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
// single blob limit 10MB
|
||||
blobLimit: 100 * OneMB,
|
||||
// total blob limit 10GB
|
||||
storageQuota: 10 * OneGB,
|
||||
// history period of validity 7 days
|
||||
historyPeriod: 7 * OneDay,
|
||||
// member limit 3
|
||||
memberLimit: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.FreePlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 3,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
// single blob limit 10MB
|
||||
blobLimit: 10 * OneMB,
|
||||
// server limit will larger then client to handle a edge case:
|
||||
// when a user downgrades from pro to free, he can still continue
|
||||
// to upload previously added files that exceed the free limit
|
||||
// NOTE: this is a product decision, may change in future
|
||||
businessBlobLimit: 100 * OneMB,
|
||||
// total blob limit 10GB
|
||||
storageQuota: 10 * OneGB,
|
||||
// history period of validity 7 days
|
||||
historyPeriod: 7 * OneDay,
|
||||
// member limit 3
|
||||
memberLimit: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.FreePlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 4,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
// single blob limit 10MB
|
||||
blobLimit: 10 * OneMB,
|
||||
// server limit will larger then client to handle a edge case:
|
||||
// when a user downgrades from pro to free, he can still continue
|
||||
// to upload previously added files that exceed the free limit
|
||||
// NOTE: this is a product decision, may change in future
|
||||
businessBlobLimit: 100 * OneMB,
|
||||
// total blob limit 10GB
|
||||
storageQuota: 10 * OneGB,
|
||||
// history period of validity 7 days
|
||||
historyPeriod: 7 * OneDay,
|
||||
// member limit 3
|
||||
memberLimit: 3,
|
||||
// copilot action limit 10
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.ProPlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 2,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Pro',
|
||||
// single blob limit 100MB
|
||||
blobLimit: 100 * OneMB,
|
||||
// total blob limit 100GB
|
||||
storageQuota: 100 * OneGB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
// member limit 10
|
||||
memberLimit: 10,
|
||||
// copilot action limit 10
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.RestrictedPlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 2,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Restricted',
|
||||
// single blob limit 1MB
|
||||
blobLimit: OneMB,
|
||||
// total blob limit 10MB
|
||||
storageQuota: 10 * OneMB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
// member limit 10
|
||||
memberLimit: 10,
|
||||
// copilot action limit 10
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.LifetimeProPlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 1,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Lifetime Pro',
|
||||
// single blob limit 100MB
|
||||
blobLimit: 100 * OneMB,
|
||||
// total blob limit 1TB
|
||||
storageQuota: 1024 * OneGB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
// member limit 10
|
||||
memberLimit: 10,
|
||||
// copilot action limit 10
|
||||
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<Q extends QuotaType>(type: Q): Quota<Q> {
|
||||
const quota = Quotas.filter(f => f.feature === type);
|
||||
quota.sort((a, b) => b.version - a.version);
|
||||
return quota[0] as Quota<Q>;
|
||||
}
|
||||
|
||||
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
||||
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
|
||||
export const LifetimeProPlan = getLatestQuota(QuotaType.LifetimeProPlanV1);
|
||||
|
||||
export const Quota_FreePlanV1_1 = {
|
||||
feature: Quotas[5].feature,
|
||||
version: Quotas[5].version,
|
||||
};
|
||||
|
||||
export const Quota_ProPlanV1 = {
|
||||
feature: Quotas[6].feature,
|
||||
version: Quotas[6].version,
|
||||
};
|
||||
@@ -1,329 +1,269 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { PrismaTransaction } from '../../base';
|
||||
import { FeatureKind } from '../features/types';
|
||||
import { QuotaConfig } from './quota';
|
||||
import { QuotaType } from './types';
|
||||
import { InternalServerError, MemberQuotaExceeded, OnEvent } from '../../base';
|
||||
import {
|
||||
Models,
|
||||
type UserQuota,
|
||||
WorkspaceQuota as BaseWorkspaceQuota,
|
||||
} from '../../models';
|
||||
import { PermissionService } from '../permission';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import {
|
||||
UserQuotaHumanReadableType,
|
||||
UserQuotaType,
|
||||
WorkspaceQuotaHumanReadableType,
|
||||
WorkspaceQuotaType,
|
||||
} from './types';
|
||||
import { formatDate, formatSize } from './utils';
|
||||
|
||||
type UserQuotaWithUsage = Omit<UserQuotaType, 'humanReadable'>;
|
||||
type WorkspaceQuota = Omit<BaseWorkspaceQuota, 'seatQuota'> & {
|
||||
ownerQuota?: string;
|
||||
};
|
||||
type WorkspaceQuotaWithUsage = Omit<WorkspaceQuotaType, 'humanReadable'>;
|
||||
|
||||
@Injectable()
|
||||
export class QuotaService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
protected logger = new Logger(QuotaService.name);
|
||||
|
||||
async getQuota<Q extends QuotaType>(
|
||||
quota: Q,
|
||||
tx?: PrismaTransaction
|
||||
): Promise<QuotaConfig | undefined> {
|
||||
const executor = tx ?? this.prisma;
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly storage: WorkspaceBlobStorage
|
||||
) {}
|
||||
|
||||
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;
|
||||
@OnEvent('user.postCreated')
|
||||
async onUserCreated({ id }: Events['user.postCreated']) {
|
||||
await this.setupUserBaseQuota(id);
|
||||
}
|
||||
|
||||
// ======== User Quota ========
|
||||
|
||||
// get activated user quota
|
||||
async getUserQuota(userId: string) {
|
||||
const quota = await this.prisma.userFeature.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Quota },
|
||||
activated: true,
|
||||
},
|
||||
select: {
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
});
|
||||
async getUserQuota(userId: string): Promise<UserQuota> {
|
||||
let quota = await this.models.userFeature.getQuota(userId);
|
||||
|
||||
// not possible, but just in case, we do a little fix for user to avoid system dump
|
||||
if (!quota) {
|
||||
// this should unreachable
|
||||
throw new Error(`User ${userId} has no quota`);
|
||||
await this.setupUserBaseQuota(userId);
|
||||
quota = await this.models.userFeature.getQuota(userId);
|
||||
}
|
||||
|
||||
const feature = await QuotaConfig.get(this.prisma, quota.featureId);
|
||||
return { ...quota, feature };
|
||||
}
|
||||
|
||||
// get user all quota records
|
||||
async getUserQuotas(userId: string) {
|
||||
const quotas = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Quota },
|
||||
},
|
||||
select: {
|
||||
activated: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const configs = await Promise.all(
|
||||
quotas.map(async quota => {
|
||||
try {
|
||||
return {
|
||||
...quota,
|
||||
feature: await QuotaConfig.get(this.prisma, quota.featureId),
|
||||
};
|
||||
} catch {}
|
||||
return null as unknown as typeof quota & {
|
||||
feature: QuotaConfig;
|
||||
};
|
||||
})
|
||||
const unlimitedCopilot = await this.models.userFeature.has(
|
||||
userId,
|
||||
'unlimited_copilot'
|
||||
);
|
||||
|
||||
return configs.filter(quota => !!quota);
|
||||
}
|
||||
|
||||
// switch user to a new quota
|
||||
// currently each user can only have one quota
|
||||
async switchUserQuota(
|
||||
userId: string,
|
||||
quota: QuotaType,
|
||||
reason?: string,
|
||||
expiredAt?: Date
|
||||
) {
|
||||
await this.prisma.$transaction(async tx => {
|
||||
const hasSameActivatedQuota = await this.hasUserQuota(userId, 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 user
|
||||
await tx.userFeature.updateMany({
|
||||
where: {
|
||||
id: undefined,
|
||||
userId,
|
||||
feature: {
|
||||
type: FeatureKind.Quota,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
activated: false,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userFeature.create({
|
||||
data: {
|
||||
userId,
|
||||
featureId,
|
||||
reason: reason ?? 'switch quota',
|
||||
activated: true,
|
||||
expiredAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async hasUserQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
|
||||
const executor = tx ?? this.prisma;
|
||||
|
||||
return executor.userFeature
|
||||
.count({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
feature: quota,
|
||||
type: FeatureKind.Quota,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
})
|
||||
.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);
|
||||
}
|
||||
|
||||
/// check if workspaces have quota
|
||||
/// return workspaces's id that have quota
|
||||
async hasWorkspacesQuota(
|
||||
workspaces: string[],
|
||||
quota?: QuotaType
|
||||
): Promise<string[]> {
|
||||
const workspaceIds = await this.prisma.workspaceFeature.findMany({
|
||||
where: {
|
||||
workspaceId: { in: workspaces },
|
||||
feature: { feature: quota, type: FeatureKind.Quota },
|
||||
activated: true,
|
||||
},
|
||||
select: { workspaceId: true },
|
||||
});
|
||||
return Array.from(new Set(workspaceIds.map(w => w.workspaceId)));
|
||||
}
|
||||
|
||||
async getWorkspaceConfig<Q extends QuotaType>(
|
||||
workspaceId: string,
|
||||
type: Q
|
||||
): Promise<QuotaConfig | undefined> {
|
||||
const quota = await this.getQuota(type);
|
||||
if (quota) {
|
||||
const configs = await this.prisma.workspaceFeature
|
||||
.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: { feature: type, type: FeatureKind.Quota },
|
||||
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'}`
|
||||
if (!quota) {
|
||||
throw new InternalServerError(
|
||||
'User quota not found and can not be created.'
|
||||
);
|
||||
}
|
||||
const r = await this.prisma.workspaceFeature.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: { feature: quota, type: FeatureKind.Quota },
|
||||
activated: true,
|
||||
},
|
||||
data: { configs },
|
||||
});
|
||||
return r.count;
|
||||
|
||||
return {
|
||||
...quota.configs,
|
||||
copilotActionLimit: unlimitedCopilot
|
||||
? undefined
|
||||
: quota.configs.copilotActionLimit,
|
||||
} as UserQuotaWithUsage;
|
||||
}
|
||||
|
||||
async getUserQuotaWithUsage(userId: string): Promise<UserQuotaWithUsage> {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
const usedStorageQuota = await this.getUserStorageUsage(userId);
|
||||
|
||||
return { ...quota, usedStorageQuota };
|
||||
}
|
||||
|
||||
async getUserStorageUsage(userId: string) {
|
||||
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
||||
const workspacesWithQuota =
|
||||
await this.models.workspaceFeature.batchHasQuota(workspaces);
|
||||
|
||||
const sizes = await Promise.allSettled(
|
||||
workspaces
|
||||
.filter(w => !workspacesWithQuota.includes(w))
|
||||
.map(workspace => this.storage.totalSize(workspace))
|
||||
);
|
||||
|
||||
return sizes.reduce((total, size) => {
|
||||
if (size.status === 'fulfilled') {
|
||||
// ensure that size is within the safe range of gql
|
||||
const totalSize = total + size.value;
|
||||
if (Number.isSafeInteger(totalSize)) {
|
||||
return totalSize;
|
||||
} else {
|
||||
this.logger.error(`Workspace size is invalid: ${size.value}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Failed to get workspace size: ${size.reason}`);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async getWorkspaceStorageUsage(workspaceId: string) {
|
||||
const totalSize = await this.storage.totalSize(workspaceId);
|
||||
// ensure that size is within the safe range of gql
|
||||
if (Number.isSafeInteger(totalSize)) {
|
||||
return totalSize;
|
||||
} else {
|
||||
this.logger.error(`Workspace size is invalid: ${totalSize}`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async getWorkspaceQuota(workspaceId: string): Promise<WorkspaceQuota> {
|
||||
const quota = await this.models.workspaceFeature.getQuota(workspaceId);
|
||||
|
||||
if (!quota) {
|
||||
// get and convert to workspace quota from owner's quota
|
||||
// TODO(@forehalo): replace it with `WorkspaceRoleModel` when it's ready
|
||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
const ownerQuota = await this.getUserQuota(owner.id);
|
||||
|
||||
return {
|
||||
...ownerQuota,
|
||||
ownerQuota: owner.id,
|
||||
};
|
||||
}
|
||||
|
||||
return quota.configs;
|
||||
}
|
||||
|
||||
async getWorkspaceQuotaWithUsage(
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceQuotaWithUsage> {
|
||||
const quota = await this.getWorkspaceQuota(workspaceId);
|
||||
const usedStorageQuota = quota.ownerQuota
|
||||
? await this.getUserStorageUsage(quota.ownerQuota)
|
||||
: await this.getWorkspaceStorageUsage(workspaceId);
|
||||
const memberCount =
|
||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
||||
|
||||
return {
|
||||
...quota,
|
||||
usedStorageQuota,
|
||||
memberCount,
|
||||
usedSize: usedStorageQuota,
|
||||
};
|
||||
}
|
||||
|
||||
formatUserQuota(
|
||||
quota: Omit<UserQuotaType, 'humanReadable'>
|
||||
): UserQuotaHumanReadableType {
|
||||
return {
|
||||
name: quota.name,
|
||||
blobLimit: formatSize(quota.blobLimit),
|
||||
storageQuota: formatSize(quota.storageQuota),
|
||||
usedStorageQuota: formatSize(quota.usedStorageQuota),
|
||||
historyPeriod: formatDate(quota.historyPeriod),
|
||||
memberLimit: quota.memberLimit.toString(),
|
||||
copilotActionLimit: quota.copilotActionLimit
|
||||
? `${quota.copilotActionLimit} times`
|
||||
: 'Unlimited',
|
||||
};
|
||||
}
|
||||
|
||||
async getWorkspaceSeatQuota(workspaceId: string) {
|
||||
const quota = await this.getWorkspaceQuota(workspaceId);
|
||||
const memberCount =
|
||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
||||
|
||||
return {
|
||||
memberCount,
|
||||
memberLimit: quota.memberLimit,
|
||||
};
|
||||
}
|
||||
|
||||
async tryCheckSeat(workspaceId: string, excludeSelf = false) {
|
||||
const quota = await this.getWorkspaceSeatQuota(workspaceId);
|
||||
|
||||
return quota.memberCount - (excludeSelf ? 1 : 0) < quota.memberLimit;
|
||||
}
|
||||
|
||||
async checkSeat(workspaceId: string, excludeSelf = false) {
|
||||
const available = await this.tryCheckSeat(workspaceId, excludeSelf);
|
||||
|
||||
if (!available) {
|
||||
throw new MemberQuotaExceeded();
|
||||
}
|
||||
}
|
||||
|
||||
formatWorkspaceQuota(
|
||||
quota: Omit<WorkspaceQuotaType, 'humanReadable'>
|
||||
): WorkspaceQuotaHumanReadableType {
|
||||
return {
|
||||
name: quota.name,
|
||||
blobLimit: formatSize(quota.blobLimit),
|
||||
storageQuota: formatSize(quota.storageQuota),
|
||||
storageQuotaUsed: formatSize(quota.usedStorageQuota),
|
||||
historyPeriod: formatDate(quota.historyPeriod),
|
||||
memberLimit: quota.memberLimit.toString(),
|
||||
memberCount: quota.memberCount.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getUserQuotaCalculator(userId: string) {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
const usedSize = await this.getUserStorageUsage(userId);
|
||||
|
||||
return this.generateQuotaCalculator(
|
||||
quota.storageQuota,
|
||||
quota.blobLimit,
|
||||
usedSize
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkspaceQuotaCalculator(workspaceId: string) {
|
||||
const quota = await this.getWorkspaceQuota(workspaceId);
|
||||
const unlimited = await this.models.workspaceFeature.has(
|
||||
workspaceId,
|
||||
'unlimited_workspace'
|
||||
);
|
||||
|
||||
// quota check will be disabled for unlimited workspace
|
||||
// we save a complicated db read for used size
|
||||
if (unlimited) {
|
||||
return this.generateQuotaCalculator(0, quota.blobLimit, 0, true);
|
||||
}
|
||||
|
||||
const usedSize = quota.ownerQuota
|
||||
? await this.getUserStorageUsage(quota.ownerQuota)
|
||||
: await this.getWorkspaceStorageUsage(workspaceId);
|
||||
|
||||
return this.generateQuotaCalculator(
|
||||
quota.storageQuota,
|
||||
quota.blobLimit,
|
||||
usedSize
|
||||
);
|
||||
}
|
||||
|
||||
private async setupUserBaseQuota(userId: string) {
|
||||
await this.models.userFeature.add(userId, 'free_plan_v1', 'sign up');
|
||||
}
|
||||
|
||||
private generateQuotaCalculator(
|
||||
storageQuota: number,
|
||||
blobLimit: number,
|
||||
usedQuota: number,
|
||||
unlimited = false
|
||||
) {
|
||||
const checkExceeded = (recvSize: number) => {
|
||||
const currentSize = usedQuota + recvSize;
|
||||
// only skip total storage check if workspace has unlimited feature
|
||||
if (currentSize > storageQuota && !unlimited) {
|
||||
this.logger.warn(
|
||||
`storage size limit exceeded: ${currentSize} > ${storageQuota}`
|
||||
);
|
||||
return true;
|
||||
} else if (recvSize > blobLimit) {
|
||||
this.logger.warn(
|
||||
`blob size limit exceeded: ${recvSize} > ${blobLimit}`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
return checkExceeded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { MemberQuotaExceeded } from '../../base';
|
||||
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, Quota, type QuotaBusinessType, QuotaType } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class QuotaManagementService {
|
||||
protected logger = new Logger(QuotaManagementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly storage: WorkspaceBlobStorage
|
||||
) {}
|
||||
|
||||
async getUserQuota(userId: string) {
|
||||
const quota = await this.quota.getUserQuota(userId);
|
||||
|
||||
return {
|
||||
name: quota.feature.name,
|
||||
reason: quota.reason,
|
||||
createAt: quota.createdAt,
|
||||
expiredAt: quota.expiredAt,
|
||||
blobLimit: quota.feature.blobLimit,
|
||||
businessBlobLimit: quota.feature.businessBlobLimit,
|
||||
storageQuota: quota.feature.storageQuota,
|
||||
historyPeriod: quota.feature.historyPeriod,
|
||||
memberLimit: quota.feature.memberLimit,
|
||||
copilotActionLimit: quota.feature.copilotActionLimit,
|
||||
};
|
||||
}
|
||||
|
||||
async getWorkspaceConfig<Q extends QuotaType>(
|
||||
workspaceId: string,
|
||||
quota: Q
|
||||
): Promise<QuotaConfig | undefined> {
|
||||
return this.quota.getWorkspaceConfig(workspaceId, quota);
|
||||
}
|
||||
|
||||
async updateWorkspaceConfig<Q extends QuotaType>(
|
||||
workspaceId: string,
|
||||
quota: Q,
|
||||
configs: Partial<Quota<Q>['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 getUserStorageUsage(userId: string) {
|
||||
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
||||
const workspacesWithQuota = await this.quota.hasWorkspacesQuota(workspaces);
|
||||
|
||||
const sizes = await Promise.allSettled(
|
||||
workspaces
|
||||
.filter(w => !workspacesWithQuota.includes(w))
|
||||
.map(workspace => this.storage.totalSize(workspace))
|
||||
);
|
||||
|
||||
return sizes.reduce((total, size) => {
|
||||
if (size.status === 'fulfilled') {
|
||||
// ensure that size is within the safe range of gql
|
||||
const totalSize = total + size.value;
|
||||
if (Number.isSafeInteger(totalSize)) {
|
||||
return totalSize;
|
||||
} else {
|
||||
this.logger.error(`Workspace size is invalid: ${size.value}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Failed to get workspace size: ${size.reason}`);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async getWorkspaceStorageUsage(workspaceId: string) {
|
||||
const totalSize = await this.storage.totalSize(workspaceId);
|
||||
// ensure that size is within the safe range of gql
|
||||
if (Number.isSafeInteger(totalSize)) {
|
||||
return totalSize;
|
||||
} else {
|
||||
this.logger.error(`Workspace size is invalid: ${totalSize}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private generateQuotaCalculator(
|
||||
quota: number,
|
||||
blobLimit: number,
|
||||
usedSize: number,
|
||||
unlimited = false
|
||||
) {
|
||||
const checkExceeded = (recvSize: number) => {
|
||||
const total = usedSize + recvSize;
|
||||
// only skip total storage check if workspace has unlimited feature
|
||||
if (total > quota && !unlimited) {
|
||||
this.logger.warn(`storage size limit exceeded: ${total} > ${quota}`);
|
||||
return true;
|
||||
} else if (recvSize > blobLimit) {
|
||||
this.logger.warn(
|
||||
`blob size limit exceeded: ${recvSize} > ${blobLimit}`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
return checkExceeded;
|
||||
}
|
||||
|
||||
async getQuotaCalculator(userId: string) {
|
||||
const quota = await this.getUserQuota(userId);
|
||||
const { storageQuota, businessBlobLimit } = quota;
|
||||
const usedSize = await this.getUserStorageUsage(userId);
|
||||
|
||||
return this.generateQuotaCalculator(
|
||||
storageQuota,
|
||||
businessBlobLimit,
|
||||
usedSize
|
||||
);
|
||||
}
|
||||
|
||||
async getQuotaCalculatorByWorkspace(workspaceId: string) {
|
||||
const { storageQuota, usedSize, businessBlobLimit, unlimited } =
|
||||
await this.getWorkspaceUsage(workspaceId);
|
||||
|
||||
return this.generateQuotaCalculator(
|
||||
storageQuota,
|
||||
businessBlobLimit,
|
||||
usedSize,
|
||||
unlimited
|
||||
);
|
||||
}
|
||||
|
||||
private async getWorkspaceQuota(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<{ quota: QuotaConfig; fromUser: boolean }> {
|
||||
const { feature: workspaceQuota } =
|
||||
(await this.quota.getWorkspaceQuota(workspaceId)) || {};
|
||||
const { feature: userQuota } = await this.quota.getUserQuota(userId);
|
||||
if (workspaceQuota) {
|
||||
return {
|
||||
quota: workspaceQuota.withOverride({
|
||||
// override user quota with workspace quota
|
||||
copilotActionLimit: userQuota.copilotActionLimit,
|
||||
}),
|
||||
fromUser: false,
|
||||
};
|
||||
}
|
||||
return { quota: userQuota, fromUser: true };
|
||||
}
|
||||
|
||||
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<QuotaBusinessType> {
|
||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
const memberCount =
|
||||
await this.permissions.getWorkspaceMemberCount(workspaceId);
|
||||
const {
|
||||
quota: {
|
||||
name,
|
||||
blobLimit,
|
||||
businessBlobLimit,
|
||||
historyPeriod,
|
||||
memberLimit,
|
||||
storageQuota,
|
||||
copilotActionLimit,
|
||||
humanReadable,
|
||||
},
|
||||
fromUser,
|
||||
} = await this.getWorkspaceQuota(owner.id, workspaceId);
|
||||
|
||||
const usedSize = fromUser
|
||||
? // get all workspaces size of owner used
|
||||
await this.getUserStorageUsage(owner.id)
|
||||
: // get workspace size
|
||||
await this.getWorkspaceStorageUsage(workspaceId);
|
||||
// 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
|
||||
);
|
||||
|
||||
const quota: QuotaBusinessType = {
|
||||
name,
|
||||
blobLimit,
|
||||
businessBlobLimit,
|
||||
historyPeriod,
|
||||
memberLimit,
|
||||
storageQuota,
|
||||
copilotActionLimit,
|
||||
humanReadable,
|
||||
usedSize,
|
||||
unlimited,
|
||||
memberCount,
|
||||
};
|
||||
|
||||
if (quota.unlimited) {
|
||||
return this.mergeUnlimitedQuota(quota);
|
||||
}
|
||||
|
||||
return quota;
|
||||
}
|
||||
|
||||
private mergeUnlimitedQuota(orig: QuotaBusinessType): QuotaBusinessType {
|
||||
return {
|
||||
...orig,
|
||||
storageQuota: 1000 * OneGB,
|
||||
memberLimit: 1000,
|
||||
humanReadable: {
|
||||
...orig.humanReadable,
|
||||
name: 'Unlimited',
|
||||
storageQuota: formatSize(1000 * OneGB),
|
||||
memberLimit: '1000',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async checkBlobQuota(workspaceId: string, size: number) {
|
||||
const { storageQuota, usedSize } =
|
||||
await this.getWorkspaceUsage(workspaceId);
|
||||
|
||||
return storageQuota - (size + usedSize);
|
||||
}
|
||||
}
|
||||
@@ -1,142 +1,123 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { commonFeatureSchema, FeatureKind } from '../features/types';
|
||||
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',
|
||||
TeamPlanV1 = 'team_plan_v1',
|
||||
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
|
||||
// only for test, smaller quota
|
||||
RestrictedPlanV1 = 'restricted_plan_v1',
|
||||
}
|
||||
|
||||
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: userQuota,
|
||||
});
|
||||
|
||||
const workspaceQuotaPlan = z.object({
|
||||
feature: z.enum([QuotaType.TeamPlanV1]),
|
||||
configs: basicQuota,
|
||||
});
|
||||
|
||||
/// ======== schema infer ========
|
||||
|
||||
export const QuotaSchema = commonFeatureSchema
|
||||
.extend({
|
||||
type: z.literal(FeatureKind.Quota),
|
||||
})
|
||||
.and(z.discriminatedUnion('feature', [userQuotaPlan, workspaceQuotaPlan]));
|
||||
|
||||
export type Quota<Q extends QuotaType = QuotaType> = z.infer<
|
||||
typeof QuotaSchema
|
||||
> & { feature: Q };
|
||||
export type QuotaConfigType = Quota['configs'];
|
||||
|
||||
/// ======== query types ========
|
||||
import { UserQuota, WorkspaceQuota } from '../../models';
|
||||
|
||||
@ObjectType()
|
||||
export class HumanReadableQuotaType {
|
||||
@Field(() => String)
|
||||
export class UserQuotaHumanReadableType {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
@Field()
|
||||
blobLimit!: string;
|
||||
|
||||
@Field(() => String)
|
||||
@Field()
|
||||
storageQuota!: string;
|
||||
|
||||
@Field(() => String)
|
||||
@Field()
|
||||
usedStorageQuota!: string;
|
||||
|
||||
@Field()
|
||||
historyPeriod!: string;
|
||||
|
||||
@Field(() => String)
|
||||
@Field()
|
||||
memberLimit!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
copilotActionLimit?: string;
|
||||
@Field()
|
||||
copilotActionLimit!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class QuotaQueryType {
|
||||
@Field(() => String)
|
||||
export class UserQuotaType implements UserQuota {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
usedStorageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
@Field()
|
||||
memberLimit!: number;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
copilotActionLimit?: number;
|
||||
|
||||
@Field(() => UserQuotaHumanReadableType)
|
||||
humanReadable!: UserQuotaHumanReadableType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UserQuotaUsageType {
|
||||
@Field(() => SafeIntResolver, {
|
||||
name: 'storageQuota',
|
||||
deprecationReason: "use `UserQuotaType['usedStorageQuota']` instead",
|
||||
})
|
||||
storageQuota!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceQuotaHumanReadableType {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
blobLimit!: string;
|
||||
|
||||
@Field()
|
||||
storageQuota!: string;
|
||||
|
||||
@Field()
|
||||
storageQuotaUsed!: string;
|
||||
|
||||
@Field()
|
||||
historyPeriod!: string;
|
||||
|
||||
@Field()
|
||||
memberLimit!: string;
|
||||
|
||||
@Field()
|
||||
memberCount!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceQuotaType implements Partial<WorkspaceQuota> {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
memberCount!: number;
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { nullable: true })
|
||||
copilotActionLimit?: number;
|
||||
|
||||
@Field(() => HumanReadableQuotaType)
|
||||
humanReadable!: HumanReadableQuotaType;
|
||||
@Field(() => SafeIntResolver)
|
||||
usedStorageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field()
|
||||
memberLimit!: number;
|
||||
|
||||
@Field()
|
||||
memberCount!: number;
|
||||
|
||||
@Field()
|
||||
humanReadable!: WorkspaceQuotaHumanReadableType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field(() => SafeIntResolver, {
|
||||
deprecationReason: 'use `usedStorageQuota` instead',
|
||||
})
|
||||
usedSize!: number;
|
||||
}
|
||||
|
||||
/// ======== utils ========
|
||||
|
||||
export function formatSize(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(OneKB));
|
||||
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + ByteUnit[i]
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(ms: number): string {
|
||||
return `${(ms / OneDay).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
export type QuotaBusinessType = QuotaQueryType & {
|
||||
businessBlobLimit: number;
|
||||
unlimited: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { OneDay, OneKB } from '../../base';
|
||||
|
||||
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
export function formatSize(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(OneKB));
|
||||
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + ByteUnit[i]
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(ms: number): string {
|
||||
return `${(ms / OneDay).toFixed(0)} days`;
|
||||
}
|
||||
@@ -112,8 +112,17 @@ export class WorkspaceBlobStorage {
|
||||
}
|
||||
|
||||
async totalSize(workspaceId: string) {
|
||||
const blobs = await this.list(workspaceId);
|
||||
return blobs.reduce((acc, item) => acc + item.size, 0);
|
||||
const sum = await this.db.blob.aggregate({
|
||||
where: {
|
||||
workspaceId,
|
||||
deletedAt: null,
|
||||
},
|
||||
_sum: {
|
||||
size: true,
|
||||
},
|
||||
});
|
||||
|
||||
return sum._sum.size ?? 0;
|
||||
}
|
||||
|
||||
private trySyncBlobsMeta(workspaceId: string, blobs: ListObjectsMetadata[]) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserModule } from '../user';
|
||||
import { WorkspacesController } from './controller';
|
||||
import { WorkspaceManagementResolver } from './management';
|
||||
import {
|
||||
DocHistoryResolver,
|
||||
PagePermissionResolver,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
providers: [
|
||||
WorkspaceResolver,
|
||||
TeamWorkspaceResolver,
|
||||
WorkspaceManagementResolver,
|
||||
PagePermissionResolver,
|
||||
DocHistoryResolver,
|
||||
WorkspaceBlobResolver,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden } from '../../base';
|
||||
import { CurrentUser } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { FeatureManagementService, FeatureType } from '../features';
|
||||
import { PermissionService } from '../permission';
|
||||
import { WorkspaceFeatureType, WorkspaceType } from './types';
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceManagementResolver {
|
||||
constructor(
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly permission: PermissionService
|
||||
) {}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async addWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<number> {
|
||||
return this.feature.addWorkspaceFeatures(workspaceId, feature);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async removeWorkspaceFeature(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<boolean> {
|
||||
return this.feature.removeWorkspaceFeature(workspaceId, feature);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Query(() => [WorkspaceFeatureType])
|
||||
async listWorkspaceFeatures(
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<WorkspaceFeatureType[]> {
|
||||
return this.feature.listFeatureWorkspaces(feature);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async setWorkspaceExperimentalFeature(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
|
||||
@Args('enable') enable: boolean
|
||||
): Promise<boolean> {
|
||||
if (!(await this.feature.canEarlyAccess(user.email))) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
||||
const availableFeatures = await this.availableFeatures(user);
|
||||
if (owner.id !== user.id || !availableFeatures.includes(feature)) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
return await this.feature
|
||||
.addWorkspaceFeatures(
|
||||
workspaceId,
|
||||
feature,
|
||||
'add by experimental feature api'
|
||||
)
|
||||
.then(id => id > 0);
|
||||
} else {
|
||||
return await this.feature.removeWorkspaceFeature(workspaceId, feature);
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
description: 'Available features of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async availableFeatures(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<FeatureType[]> {
|
||||
return await this.feature.getActivatedUserFeatures(user.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
description: 'Enabled features of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async features(@Parent() workspace: WorkspaceType): Promise<FeatureType[]> {
|
||||
return this.feature.getWorkspaceFeatures(workspace.id);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type { FileUpload } from '../../../base';
|
||||
import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { PermissionService, WorkspaceRole } from '../../permission';
|
||||
import { QuotaManagementService } from '../../quota';
|
||||
import { QuotaService } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { WorkspaceBlobSizes, WorkspaceType } from '../types';
|
||||
|
||||
@@ -41,7 +41,7 @@ export class WorkspaceBlobResolver {
|
||||
logger = new Logger(WorkspaceBlobResolver.name);
|
||||
constructor(
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly storage: WorkspaceBlobStorage
|
||||
) {}
|
||||
|
||||
@@ -106,7 +106,7 @@ export class WorkspaceBlobResolver {
|
||||
);
|
||||
|
||||
const checkExceeded =
|
||||
await this.quota.getQuotaCalculatorByWorkspace(workspaceId);
|
||||
await this.quota.getWorkspaceQuotaCalculator(workspaceId);
|
||||
|
||||
// TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge`
|
||||
if (checkExceeded(0)) {
|
||||
|
||||
@@ -148,6 +148,9 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
// ================ Team ================
|
||||
async isTeamWorkspace(workspaceId: string) {
|
||||
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
|
||||
}
|
||||
|
||||
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
ActionForbiddenOnNonTeamWorkspace,
|
||||
Cache,
|
||||
EventBus,
|
||||
MemberNotFoundInSpace,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
import { Models } from '../../../models';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { PermissionService, WorkspaceRole } from '../../permission';
|
||||
import { QuotaManagementService } from '../../quota';
|
||||
import { QuotaService } from '../../quota';
|
||||
import {
|
||||
InviteLink,
|
||||
InviteResult,
|
||||
@@ -47,7 +48,7 @@ export class TeamWorkspaceResolver {
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly models: Models,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly workspaceService: WorkspaceService
|
||||
) {}
|
||||
@@ -58,7 +59,7 @@ export class TeamWorkspaceResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
team(@Parent() workspace: WorkspaceType) {
|
||||
return this.quota.isTeamWorkspace(workspace.id);
|
||||
return this.workspaceService.isTeamWorkspace(workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => [InviteResult])
|
||||
@@ -85,7 +86,7 @@ export class TeamWorkspaceResolver {
|
||||
return new TooManyRequest();
|
||||
}
|
||||
|
||||
const quota = await this.quota.getWorkspaceUsage(workspaceId);
|
||||
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
|
||||
|
||||
const results = [];
|
||||
for (const [idx, email] of emails.entries()) {
|
||||
@@ -285,10 +286,20 @@ export class TeamWorkspaceResolver {
|
||||
@Args('userId') userId: string,
|
||||
@Args('permission', { type: () => WorkspaceRole }) permission: WorkspaceRole
|
||||
) {
|
||||
// non-team workspace can only transfer ownership, but no detailed permission control
|
||||
if (permission !== WorkspaceRole.Owner) {
|
||||
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
|
||||
if (!isTeam) {
|
||||
throw new ActionForbiddenOnNonTeamWorkspace();
|
||||
}
|
||||
}
|
||||
|
||||
await this.permissions.checkWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
WorkspaceRole.Owner
|
||||
permission >= WorkspaceRole.Admin
|
||||
? WorkspaceRole.Owner
|
||||
: WorkspaceRole.Admin
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
mapWorkspaceRoleToWorkspaceActions,
|
||||
WorkspacePermissionsList,
|
||||
} from '../../permission/types';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
import { QuotaService, WorkspaceQuotaType } from '../../quota';
|
||||
import { UserType } from '../../user';
|
||||
import {
|
||||
InvitationType,
|
||||
@@ -122,7 +122,7 @@ export class WorkspaceResolver {
|
||||
private readonly cache: Cache,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus,
|
||||
private readonly mutex: RequestMutex,
|
||||
@@ -260,13 +260,19 @@ export class WorkspaceResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => QuotaQueryType, {
|
||||
@ResolveField(() => WorkspaceQuotaType, {
|
||||
name: 'quota',
|
||||
description: 'quota of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
workspaceQuota(@Parent() workspace: WorkspaceType) {
|
||||
return this.quota.getWorkspaceUsage(workspace.id);
|
||||
async workspaceQuota(
|
||||
@Parent() workspace: WorkspaceType
|
||||
): Promise<WorkspaceQuotaType> {
|
||||
const quota = await this.quota.getWorkspaceQuotaWithUsage(workspace.id);
|
||||
return {
|
||||
...quota,
|
||||
humanReadable: this.quota.formatWorkspaceQuota(quota),
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => Boolean, {
|
||||
@@ -421,12 +427,7 @@ export class WorkspaceResolver {
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
const isTeam = await this.quota.isTeamWorkspace(id);
|
||||
await this.permissions.checkWorkspace(
|
||||
id,
|
||||
user.id,
|
||||
isTeam ? WorkspaceRole.Owner : WorkspaceRole.Admin
|
||||
);
|
||||
await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Admin);
|
||||
|
||||
return this.prisma.workspace.update({
|
||||
where: {
|
||||
@@ -483,7 +484,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
// member limit check
|
||||
await this.quota.checkWorkspaceSeat(workspaceId);
|
||||
await this.quota.checkSeat(workspaceId);
|
||||
|
||||
let target = await this.models.user.getUserByEmail(email);
|
||||
if (target) {
|
||||
@@ -569,14 +570,14 @@ export class WorkspaceResolver {
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('userId') userId: string
|
||||
) {
|
||||
const isTeam = await this.quota.isTeamWorkspace(workspaceId);
|
||||
const isAdmin = await this.permissions.tryCheckWorkspaceIs(
|
||||
workspaceId,
|
||||
userId,
|
||||
WorkspaceRole.Admin
|
||||
);
|
||||
if (isTeam && isAdmin) {
|
||||
// only owner can revoke team workspace admin
|
||||
|
||||
if (isAdmin) {
|
||||
// only owner can revoke workspace admin
|
||||
await this.permissions.checkWorkspaceIs(
|
||||
workspaceId,
|
||||
user.id,
|
||||
@@ -607,7 +608,6 @@ export class WorkspaceResolver {
|
||||
throw new TooManyRequest();
|
||||
}
|
||||
|
||||
const isTeam = await this.quota.isTeamWorkspace(workspaceId);
|
||||
if (user) {
|
||||
const status = await this.permissions.getWorkspaceMemberStatus(
|
||||
workspaceId,
|
||||
@@ -622,8 +622,9 @@ export class WorkspaceResolver {
|
||||
`workspace:inviteLink:${workspaceId}`
|
||||
);
|
||||
if (invite?.inviteId === inviteId) {
|
||||
const quota = await this.quota.getWorkspaceUsage(workspaceId);
|
||||
if (quota.memberCount >= quota.memberLimit) {
|
||||
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
|
||||
const seatAvailable = await this.quota.tryCheckSeat(workspaceId);
|
||||
if (!seatAvailable) {
|
||||
// only team workspace allow over limit
|
||||
if (isTeam) {
|
||||
await this.permissions.grant(
|
||||
@@ -661,10 +662,6 @@ export class WorkspaceResolver {
|
||||
}
|
||||
}
|
||||
|
||||
// we added seats when sending invitation emails, but the payment may fail
|
||||
// so we need to check seat again here
|
||||
await this.quota.checkWorkspaceSeat(workspaceId, true);
|
||||
|
||||
if (sendAcceptMail) {
|
||||
const success = await this.workspaceService.sendAcceptedEmail(inviteId);
|
||||
if (!success) throw new UserNotFound();
|
||||
|
||||
Reference in New Issue
Block a user