feat: workspace level feature schema (#5466)
This commit is contained in:
@@ -40,6 +40,7 @@ model Workspace {
|
|||||||
pages WorkspacePage[]
|
pages WorkspacePage[]
|
||||||
permissions WorkspaceUserPermission[]
|
permissions WorkspaceUserPermission[]
|
||||||
pagePermissions WorkspacePageUserPermission[]
|
pagePermissions WorkspacePageUserPermission[]
|
||||||
|
features WorkspaceFeatures[]
|
||||||
|
|
||||||
@@map("workspaces")
|
@@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
|
// - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it
|
||||||
activated Boolean @default(false)
|
activated Boolean @default(false)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
feature Features @relation(fields: [featureId], 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")
|
@@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 {
|
model Features {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
feature String @db.VarChar
|
feature String @db.VarChar
|
||||||
@@ -151,7 +179,8 @@ model Features {
|
|||||||
configs Json @db.Json
|
configs Json @db.Json
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
UserFeatureGates UserFeatures[]
|
UserFeatureGates UserFeatures[]
|
||||||
|
WorkspaceFeatures WorkspaceFeatures[]
|
||||||
|
|
||||||
@@unique([feature, version])
|
@@unique([feature, version])
|
||||||
@@map("features")
|
@@map("features")
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class FeatureManagementService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (user) {
|
if (user) {
|
||||||
const canEarlyAccess = await this.feature
|
const canEarlyAccess = await this.feature
|
||||||
.hasFeature(user.id, FeatureType.EarlyAccess)
|
.hasUserFeature(user.id, FeatureType.EarlyAccess)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
if (canEarlyAccess) {
|
if (canEarlyAccess) {
|
||||||
return true;
|
return true;
|
||||||
@@ -86,4 +86,36 @@ export class FeatureManagementService implements OnModuleInit {
|
|||||||
return true;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
import { UserType } from '../users/types';
|
import { UserType } from '../users/types';
|
||||||
|
import { WorkspaceType } from '../workspaces/types';
|
||||||
import { FeatureConfigType, getFeature } from './feature';
|
import { FeatureConfigType, getFeature } from './feature';
|
||||||
import { FeatureKind, FeatureType } from './types';
|
import { FeatureKind, FeatureType } from './types';
|
||||||
|
|
||||||
@@ -21,7 +22,14 @@ export class FeatureService {
|
|||||||
});
|
});
|
||||||
return features.reduce(
|
return features.reduce(
|
||||||
(acc, feature) => {
|
(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;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, number>
|
{} as Record<string, number>
|
||||||
@@ -47,6 +55,8 @@ export class FeatureService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======== User Features ========
|
||||||
|
|
||||||
async addUserFeature(
|
async addUserFeature(
|
||||||
userId: string,
|
userId: string,
|
||||||
feature: FeatureType,
|
feature: FeatureType,
|
||||||
@@ -169,7 +179,7 @@ export class FeatureService {
|
|||||||
.then(users => users.map(user => user.user));
|
.then(users => users.map(user => user.user));
|
||||||
}
|
}
|
||||||
|
|
||||||
async hasFeature(userId: string, feature: FeatureType) {
|
async hasUserFeature(userId: string, feature: FeatureType) {
|
||||||
return this.prisma.userFeatures
|
return this.prisma.userFeatures
|
||||||
.count({
|
.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -183,4 +193,142 @@ export class FeatureService {
|
|||||||
})
|
})
|
||||||
.then(count => count > 0);
|
.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<Omit<WorkspaceType, 'members'>[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user