refactor(server): use feature model (#9932)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
packages/backend/server/src/core/features/types.ts
Normal file
31
packages/backend/server/src/core/features/types.ts
Normal file
@@ -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({}),
|
||||
});
|
||||
Reference in New Issue
Block a user