mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 17:16:16 +08:00
feat: struct type feature config (#5142)
This commit is contained in:
@@ -1,80 +1,78 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { FeatureService } from './configure';
|
||||
import { FeatureType } from './types';
|
||||
import { Feature, FeatureSchema, FeatureType } from './types';
|
||||
|
||||
enum NewFeaturesKind {
|
||||
EarlyAccess,
|
||||
}
|
||||
class FeatureConfig {
|
||||
readonly config: Feature;
|
||||
|
||||
@Injectable()
|
||||
export class FeatureManagementService {
|
||||
protected logger = new Logger(FeatureManagementService.name);
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
isStaff(email: string) {
|
||||
return email.endsWith('@toeverything.info');
|
||||
}
|
||||
|
||||
async addEarlyAccess(userId: string) {
|
||||
return this.feature.addUserFeature(
|
||||
userId,
|
||||
FeatureType.EarlyAccess,
|
||||
1,
|
||||
'Early access user'
|
||||
);
|
||||
}
|
||||
|
||||
async removeEarlyAccess(userId: string) {
|
||||
return this.feature.removeUserFeature(userId, FeatureType.EarlyAccess);
|
||||
}
|
||||
|
||||
async listEarlyAccess() {
|
||||
return this.feature.listFeatureUsers(FeatureType.EarlyAccess);
|
||||
}
|
||||
|
||||
/// check early access by email
|
||||
async canEarlyAccess(email: string) {
|
||||
if (
|
||||
this.config.featureFlags.earlyAccessPreview &&
|
||||
!email.endsWith('@toeverything.info')
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const canEarlyAccess = await this.feature
|
||||
.hasFeature(user.id, FeatureType.EarlyAccess)
|
||||
.catch(() => false);
|
||||
if (canEarlyAccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Outdated, switch to feature gates
|
||||
const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.then(x => !!x)
|
||||
.catch(() => false);
|
||||
if (oldCanEarlyAccess) {
|
||||
this.logger.warn(
|
||||
`User ${email} has early access in old table but not in new table`
|
||||
);
|
||||
}
|
||||
return oldCanEarlyAccess;
|
||||
}
|
||||
return false;
|
||||
constructor(data: any) {
|
||||
const config = FeatureSchema.safeParse(data);
|
||||
if (config.success) {
|
||||
this.config = config.data;
|
||||
} else {
|
||||
return true;
|
||||
throw new Error(`Invalid quota config: ${config.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/// feature name of quota
|
||||
get name() {
|
||||
return this.config.feature;
|
||||
}
|
||||
}
|
||||
|
||||
export class EarlyAccessFeatureConfig extends FeatureConfig {
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.EarlyAccess) {
|
||||
throw new Error('Invalid feature config: type is not EarlyAccess');
|
||||
}
|
||||
}
|
||||
|
||||
checkWhiteList(email: string) {
|
||||
for (const domain in this.config.configs.whitelist) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const FeatureConfigMap = {
|
||||
[FeatureType.EarlyAccess]: EarlyAccessFeatureConfig,
|
||||
};
|
||||
|
||||
const FeatureCache = new Map<
|
||||
number,
|
||||
InstanceType<(typeof FeatureConfigMap)[FeatureType]>
|
||||
>();
|
||||
|
||||
export async function getFeature(prisma: PrismaService, featureId: number) {
|
||||
const cachedQuota = FeatureCache.get(featureId);
|
||||
|
||||
if (cachedQuota) {
|
||||
return cachedQuota;
|
||||
}
|
||||
|
||||
const feature = await prisma.features.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
});
|
||||
if (!feature) {
|
||||
// this should unreachable
|
||||
throw new Error(`Quota config ${featureId} not found`);
|
||||
}
|
||||
const ConfigClass = FeatureConfigMap[feature.feature as FeatureType];
|
||||
|
||||
if (!ConfigClass) {
|
||||
throw new Error(`Feature config ${featureId} not found`);
|
||||
}
|
||||
|
||||
const config = new ConfigClass(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,8 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { FeatureService } from './configure';
|
||||
import { FeatureManagementService } from './feature';
|
||||
import { FeatureManagementService } from './management';
|
||||
import { FeatureService } from './service';
|
||||
|
||||
/**
|
||||
* Feature module provider pre-user feature flag management.
|
||||
@@ -16,6 +16,6 @@ import { FeatureManagementService } from './feature';
|
||||
})
|
||||
export class FeatureModule {}
|
||||
|
||||
export type { CommonFeature, Feature } from './types';
|
||||
export { type CommonFeature, commonFeatureSchema } from './types';
|
||||
export { FeatureKind, Features, FeatureType } from './types';
|
||||
export { FeatureManagementService, FeatureService, PrismaService };
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { EarlyAccessFeatureConfig } from './feature';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
enum NewFeaturesKind {
|
||||
EarlyAccess,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeatureManagementService implements OnModuleInit {
|
||||
protected logger = new Logger(FeatureManagementService.name);
|
||||
private earlyAccessFeature?: EarlyAccessFeatureConfig;
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
async onModuleInit() {
|
||||
this.earlyAccessFeature = await this.feature.getFeature(
|
||||
FeatureType.EarlyAccess
|
||||
);
|
||||
}
|
||||
|
||||
// ======== Admin ========
|
||||
|
||||
// todo(@darkskygit): replace this with abac
|
||||
isStaff(email: string) {
|
||||
return this.earlyAccessFeature?.checkWhiteList(email) ?? false;
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
|
||||
async addEarlyAccess(userId: string) {
|
||||
return this.feature.addUserFeature(
|
||||
userId,
|
||||
FeatureType.EarlyAccess,
|
||||
1,
|
||||
'Early access user'
|
||||
);
|
||||
}
|
||||
|
||||
async removeEarlyAccess(userId: string) {
|
||||
return this.feature.removeUserFeature(userId, FeatureType.EarlyAccess);
|
||||
}
|
||||
|
||||
async listEarlyAccess() {
|
||||
return this.feature.listFeatureUsers(FeatureType.EarlyAccess);
|
||||
}
|
||||
|
||||
/// check early access by email
|
||||
async canEarlyAccess(email: string) {
|
||||
if (this.config.featureFlags.earlyAccessPreview && !this.isStaff(email)) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const canEarlyAccess = await this.feature
|
||||
.hasFeature(user.id, FeatureType.EarlyAccess)
|
||||
.catch(() => false);
|
||||
if (canEarlyAccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Outdated, switch to feature gates
|
||||
const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.then(x => !!x)
|
||||
.catch(() => false);
|
||||
if (oldCanEarlyAccess) {
|
||||
this.logger.warn(
|
||||
`User ${email} has early access in old table but not in new table`
|
||||
);
|
||||
}
|
||||
return oldCanEarlyAccess;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-13
@@ -1,7 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { Feature, FeatureKind, FeatureType } from './types';
|
||||
import { UserType } from '../users/types';
|
||||
import { getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class FeatureService {
|
||||
@@ -27,15 +29,20 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async getFeature(feature: FeatureType) {
|
||||
return this.prisma.features.findFirst({
|
||||
const data = await this.prisma.features.findFirst({
|
||||
where: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
return getFeature(this.prisma, data.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async addUserFeature(
|
||||
@@ -120,21 +127,21 @@ export class FeatureService {
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
feature: {
|
||||
select: {
|
||||
feature: true,
|
||||
configs: true,
|
||||
},
|
||||
},
|
||||
featureId: true,
|
||||
},
|
||||
});
|
||||
return features as typeof features &
|
||||
{
|
||||
feature: Pick<Feature, 'feature' | 'configs'>;
|
||||
}[];
|
||||
|
||||
const configs = await Promise.all(
|
||||
features.map(async feature => ({
|
||||
...feature,
|
||||
feature: await getFeature(this.prisma, feature.featureId),
|
||||
}))
|
||||
);
|
||||
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureUsers(feature: FeatureType) {
|
||||
async listFeatureUsers(feature: FeatureType): Promise<UserType[]> {
|
||||
return this.prisma.userFeatures
|
||||
.findMany({
|
||||
where: {
|
||||
@@ -1,26 +1,48 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/// ======== common schema ========
|
||||
|
||||
export enum FeatureKind {
|
||||
Feature,
|
||||
Quota,
|
||||
}
|
||||
|
||||
export type CommonFeature = {
|
||||
feature: string;
|
||||
type: FeatureKind;
|
||||
version: number;
|
||||
configs: Prisma.InputJsonValue;
|
||||
};
|
||||
export const commonFeatureSchema = z.object({
|
||||
feature: z.string(),
|
||||
type: z.nativeEnum(FeatureKind),
|
||||
version: z.number(),
|
||||
configs: z.unknown(),
|
||||
});
|
||||
|
||||
export type Feature = CommonFeature & {
|
||||
type: FeatureKind.Feature;
|
||||
feature: FeatureType;
|
||||
};
|
||||
export type CommonFeature = z.infer<typeof commonFeatureSchema>;
|
||||
|
||||
/// ======== feature define ========
|
||||
|
||||
export enum FeatureType {
|
||||
EarlyAccess = 'early_access',
|
||||
}
|
||||
|
||||
function checkHostname(host: string) {
|
||||
try {
|
||||
return new URL(`https://${host}`).hostname === host;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const featureEarlyAccess = z.object({
|
||||
feature: z.literal(FeatureType.EarlyAccess),
|
||||
configs: z.object({
|
||||
whitelist: z
|
||||
.string()
|
||||
.startsWith('@')
|
||||
.refine(domain => checkHostname(domain.slice(1)))
|
||||
.array(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const Features: Feature[] = [
|
||||
{
|
||||
feature: FeatureType.EarlyAccess,
|
||||
@@ -31,3 +53,13 @@ export const Features: Feature[] = [
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/// ======== schema infer ========
|
||||
|
||||
export const FeatureSchema = commonFeatureSchema
|
||||
.extend({
|
||||
type: z.literal(FeatureKind.Feature),
|
||||
})
|
||||
.and(z.discriminatedUnion('feature', [featureEarlyAccess]));
|
||||
|
||||
export type Feature = z.infer<typeof FeatureSchema>;
|
||||
|
||||
Reference in New Issue
Block a user