diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index f19af17404..5fd5a72e34 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -40,6 +40,7 @@ model Workspace { pages WorkspacePage[] permissions WorkspaceUserPermission[] pagePermissions WorkspacePageUserPermission[] + features WorkspaceFeatures[] @@map("workspaces") } @@ -135,12 +136,39 @@ model UserFeatures { // - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it activated Boolean @default(false) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("user_features") } +// feature gates is a way to enable/disable features for a workspace +// for example: +// - copilet is a feature that allow some users in a workspace to access the copilet feature +model WorkspaceFeatures { + id Int @id @default(autoincrement()) + workspaceId String @map("workspace_id") @db.VarChar(36) + featureId Int @map("feature_id") @db.Integer + + // we will record the reason why the feature is enabled/disabled + // for example: + // - copilet_v1: "owner buy the copilet feature package" + reason String @db.VarChar + // record the feature enabled time + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + // record the quota expired time, pay plan is a subscription, so it will expired + expiredAt DateTime? @map("expired_at") @db.Timestamptz(6) + // whether the feature is activated + // for example: + // - if owner unsubscribe a feature package, we will set the feature to deactivated, but dont delete it + activated Boolean @default(false) + + feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id]) + + @@map("workspace_features") +} + model Features { id Int @id @default(autoincrement()) feature String @db.VarChar @@ -151,7 +179,8 @@ model Features { configs Json @db.Json createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - UserFeatureGates UserFeatures[] + UserFeatureGates UserFeatures[] + WorkspaceFeatures WorkspaceFeatures[] @@unique([feature, version]) @@map("features") diff --git a/packages/backend/server/src/modules/features/management.ts b/packages/backend/server/src/modules/features/management.ts index d7e274525c..41535515da 100644 --- a/packages/backend/server/src/modules/features/management.ts +++ b/packages/backend/server/src/modules/features/management.ts @@ -61,7 +61,7 @@ export class FeatureManagementService implements OnModuleInit { }); if (user) { const canEarlyAccess = await this.feature - .hasFeature(user.id, FeatureType.EarlyAccess) + .hasUserFeature(user.id, FeatureType.EarlyAccess) .catch(() => false); if (canEarlyAccess) { return true; @@ -86,4 +86,36 @@ export class FeatureManagementService implements OnModuleInit { return true; } } + + // ======== Workspace Feature ======== + async addWorkspaceFeatures( + workspaceId: string, + feature: FeatureType, + version?: number, + reason?: string + ) { + const latestVersions = await this.feature.getFeaturesVersion(); + // use latest version if not specified + const latestVersion = version || latestVersions[feature]; + if (!Number.isInteger(latestVersion)) { + throw new Error(`Version of feature ${feature} not found`); + } + return this.feature.addWorkspaceFeature( + workspaceId, + feature, + latestVersion, + reason || 'add feature by api' + ); + } + + async getWorkspaceFeatures(workspaceId: string) { + const features = await this.feature.getWorkspaceFeatures(workspaceId); + return features.map(feature => feature.feature.name); + } + + async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) { + return this.feature + .removeWorkspaceFeature(workspaceId, feature) + .then(c => c > 0); + } } diff --git a/packages/backend/server/src/modules/features/service.ts b/packages/backend/server/src/modules/features/service.ts index 1dae214a08..2b653f45aa 100644 --- a/packages/backend/server/src/modules/features/service.ts +++ b/packages/backend/server/src/modules/features/service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma'; import { UserType } from '../users/types'; +import { WorkspaceType } from '../workspaces/types'; import { FeatureConfigType, getFeature } from './feature'; import { FeatureKind, FeatureType } from './types'; @@ -21,7 +22,14 @@ export class FeatureService { }); return features.reduce( (acc, feature) => { - acc[feature.feature] = feature.version; + // only keep the latest version + if (acc[feature.feature]) { + if (acc[feature.feature] < feature.version) { + acc[feature.feature] = feature.version; + } + } else { + acc[feature.feature] = feature.version; + } return acc; }, {} as Record @@ -47,6 +55,8 @@ export class FeatureService { return undefined; } + // ======== User Features ======== + async addUserFeature( userId: string, feature: FeatureType, @@ -169,7 +179,7 @@ export class FeatureService { .then(users => users.map(user => user.user)); } - async hasFeature(userId: string, feature: FeatureType) { + async hasUserFeature(userId: string, feature: FeatureType) { return this.prisma.userFeatures .count({ where: { @@ -183,4 +193,142 @@ export class FeatureService { }) .then(count => count > 0); } + + // ======== Workspace Features ======== + + async addWorkspaceFeature( + workspaceId: string, + feature: FeatureType, + version: number, + reason: string, + expiredAt?: Date | string + ) { + return this.prisma.$transaction(async tx => { + const latestFlag = await tx.workspaceFeatures.findFirst({ + where: { + workspaceId, + feature: { + feature, + type: FeatureKind.Feature, + }, + activated: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + if (latestFlag) { + return latestFlag.id; + } else { + return tx.workspaceFeatures + .create({ + data: { + reason, + expiredAt, + activated: true, + workspace: { + connect: { + id: workspaceId, + }, + }, + feature: { + connect: { + feature_version: { + feature, + version, + }, + type: FeatureKind.Feature, + }, + }, + }, + }) + .then(r => r.id); + } + }); + } + + async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) { + return this.prisma.workspaceFeatures + .updateMany({ + where: { + workspaceId, + feature: { + feature, + type: FeatureKind.Feature, + }, + activated: true, + }, + data: { + activated: false, + }, + }) + .then(r => r.count); + } + + async getWorkspaceFeatures(workspaceId: string) { + const features = await this.prisma.workspaceFeatures.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), + })) + ); + + return configs.filter(feature => !!feature.feature); + } + + async listFeatureWorkspaces( + feature: FeatureType + ): Promise[]> { + return this.prisma.workspaceFeatures + .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.workspaceFeatures + .count({ + where: { + workspaceId, + activated: true, + feature: { + feature, + type: FeatureKind.Feature, + }, + }, + }) + .then(count => count > 0); + } }