refactor(server): use feature model (#9932)

This commit is contained in:
forehalo
2025-02-05 10:27:26 +00:00
parent 0ff8d3af6f
commit 7826e2b7c8
121 changed files with 1723 additions and 3826 deletions

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View 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,
]
);
}
}

View File

@@ -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({}),
});

View File

@@ -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',
});

View File

@@ -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({}),
});

View File

@@ -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({}),
});

View File

@@ -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 };

View File

@@ -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({}),
});

View File

@@ -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({}),
});