feat: user usage init (#5074)

This commit is contained in:
DarkSky
2023-12-13 09:21:14 +00:00
parent 098787bd0c
commit 77a5552dcd
14 changed files with 818 additions and 11 deletions
@@ -0,0 +1,177 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { Feature, FeatureKind, FeatureType } from './types';
@Injectable()
export class FeatureService {
constructor(private readonly prisma: PrismaService) {}
async getFeaturesVersion() {
const features = await this.prisma.features.findMany({
where: {
type: FeatureKind.Feature,
},
select: {
feature: true,
version: true,
},
});
return features.reduce(
(acc, feature) => {
acc[feature.feature] = feature.version;
return acc;
},
{} as Record<string, number>
);
}
async getFeature(feature: FeatureType) {
return this.prisma.features.findFirst({
where: {
feature,
type: FeatureKind.Feature,
},
orderBy: {
version: 'desc',
},
});
}
async addUserFeature(
userId: string,
feature: FeatureType,
version: number,
reason: string,
expiredAt?: Date | string
) {
return this.prisma.$transaction(async tx => {
const latestFlag = await tx.userFeatures.findFirst({
where: {
userId,
feature: {
feature,
type: FeatureKind.Feature,
},
activated: true,
},
orderBy: {
createdAt: 'desc',
},
});
if (latestFlag) {
return latestFlag.id;
} else {
return tx.userFeatures
.create({
data: {
reason,
expiredAt,
activated: true,
user: {
connect: {
id: userId,
},
},
feature: {
connect: {
feature_version: {
feature,
version,
},
type: FeatureKind.Feature,
},
},
},
})
.then(r => r.id);
}
});
}
async removeUserFeature(userId: string, feature: FeatureType) {
return this.prisma.userFeatures
.updateMany({
where: {
userId,
feature: {
feature,
type: FeatureKind.Feature,
},
activated: true,
},
data: {
activated: false,
},
})
.then(r => r.count);
}
async getUserFeatures(userId: string) {
const features = await this.prisma.userFeatures.findMany({
where: {
user: { id: userId },
feature: {
type: FeatureKind.Feature,
},
},
select: {
activated: true,
reason: true,
createdAt: true,
expiredAt: true,
feature: {
select: {
feature: true,
configs: true,
},
},
},
});
return features as typeof features &
{
feature: Pick<Feature, 'feature' | 'configs'>;
}[];
}
async listFeatureUsers(feature: FeatureType) {
return this.prisma.userFeatures
.findMany({
where: {
activated: true,
feature: {
feature: feature,
type: FeatureKind.Feature,
},
},
select: {
user: {
select: {
id: true,
name: true,
avatarUrl: true,
email: true,
emailVerified: true,
createdAt: true,
},
},
},
})
.then(users => users.map(user => user.user));
}
async hasFeature(userId: string, feature: FeatureType) {
return this.prisma.userFeatures
.count({
where: {
userId,
activated: true,
feature: {
feature,
type: FeatureKind.Feature,
},
},
})
.then(count => count > 0);
}
}
@@ -0,0 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { FeatureService } from './configure';
import { FeatureType } from './types';
export enum NewFeaturesKind {
EarlyAccess,
}
@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;
} else {
return true;
}
}
}
@@ -0,0 +1,54 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { FeatureService } from './configure';
import { FeatureManagementService } from './feature';
import type { CommonFeature } from './types';
// upgrade features from lower version to higher version
async function upsertFeature(
db: PrismaService,
feature: CommonFeature
): Promise<void> {
const hasEqualOrGreaterVersion =
(await db.features.count({
where: {
feature: feature.feature,
version: {
gte: feature.version,
},
},
})) > 0;
// will not update exists version
if (!hasEqualOrGreaterVersion) {
await db.features.create({
data: {
feature: feature.feature,
type: feature.type,
version: feature.version,
configs: feature.configs,
},
});
}
}
/**
* Feature module provider pre-user feature flag management.
* includes:
* - feature query/update/permit
* - feature statistics
*/
@Module({
providers: [FeatureService, FeatureManagementService],
exports: [FeatureService, FeatureManagementService],
})
export class FeatureModule {}
export type { CommonFeature, Feature } from './types';
export { FeatureKind, Features, FeatureType } from './types';
export {
FeatureManagementService,
FeatureService,
PrismaService,
upsertFeature,
};
@@ -0,0 +1,33 @@
import type { Prisma } from '@prisma/client';
export enum FeatureKind {
Feature,
Quota,
}
export type CommonFeature = {
feature: string;
type: FeatureKind;
version: number;
configs: Prisma.InputJsonValue;
};
export type Feature = CommonFeature & {
type: FeatureKind.Feature;
feature: FeatureType;
};
export enum FeatureType {
EarlyAccess = 'early_access',
}
export const Features: Feature[] = [
{
feature: FeatureType.EarlyAccess,
type: FeatureKind.Feature,
version: 1,
configs: {
whitelist: ['@toeverything.info'],
},
},
];