feat(server): align pro plan for free in selfhost (#9973)

close AF-2099
This commit is contained in:
forehalo
2025-02-06 05:25:06 +00:00
parent f00fb327e2
commit d873a78534
6 changed files with 109 additions and 43 deletions
@@ -42,3 +42,16 @@ Generated by [AVA](https://avajs.dev).
name: 'Free', name: 'Free',
storageQuota: 10737418240, storageQuota: 10737418240,
} }
## should use pro plan as free for selfhost instance
> use pro plan as free plan for selfhosted instance
{
blobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 2592000000,
memberLimit: 10,
name: 'Pro',
storageQuota: 107374182400,
}
@@ -1,7 +1,8 @@
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { FeatureType, UserFeatureModel, UserModel } from '../../models'; import { ConfigModule } from '../../base/config';
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
import { createTestingModule, TestingModule } from '../utils'; import { createTestingModule, TestingModule } from '../utils';
interface Context { interface Context {
@@ -123,3 +124,25 @@ test('should not switch user quota if the new quota is the same as the current o
t.not(quota?.reason, 'test not switch'); t.not(quota?.reason, 'test not switch');
}); });
test('should use pro plan as free for selfhost instance', async t => {
await using module = await createTestingModule({
imports: [
ConfigModule.forRoot({
isSelfhosted: true,
}),
],
});
const models = module.get(Models);
const u1 = await models.user.create({
email: 'u1@affine.pro',
registered: true,
});
const quota = await models.userFeature.getQuota(u1.id);
t.snapshot(
quota?.configs,
'use pro plan as free plan for selfhosted instance'
);
});
@@ -90,6 +90,7 @@ test('should get feature if extra fields exist in feature config', async t => {
test('should create feature', async t => { test('should create feature', async t => {
const { feature } = t.context; const { feature } = t.context;
// @ts-expect-error internal
const newFeature = await feature.upsert( const newFeature = await feature.upsert(
'new_feature' as any, 'new_feature' as any,
{}, {},
@@ -104,6 +105,7 @@ test('should update feature', async t => {
const { feature } = t.context; const { feature } = t.context;
const freePlanFeature = await feature.get('free_plan_v1'); const freePlanFeature = await feature.get('free_plan_v1');
// @ts-expect-error internal
const newFreePlanFeature = await feature.upsert( const newFreePlanFeature = await feature.upsert(
'free_plan_v1', 'free_plan_v1',
{ {
@@ -123,6 +125,7 @@ test('should update feature', async t => {
test('should throw if feature config is invalid when updating', async t => { test('should throw if feature config is invalid when updating', async t => {
const { feature } = t.context; const { feature } = t.context;
await t.throwsAsync( await t.throwsAsync(
// @ts-expect-error internal
feature.upsert('free_plan_v1', {} as any, FeatureType.Quota, 1), feature.upsert('free_plan_v1', {} as any, FeatureType.Quota, 1),
{ {
message: 'Invalid feature config for free_plan_v1', message: 'Invalid feature config for free_plan_v1',
@@ -52,10 +52,12 @@ const initTestingDB = async (ref: ModuleRef) => {
export type TestingModule = BaseTestingModule & { export type TestingModule = BaseTestingModule & {
initTestingDB(): Promise<void>; initTestingDB(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
}; };
export type TestingApp = INestApplication & { export type TestingApp = INestApplication & {
initTestingDB(): Promise<void>; initTestingDB(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
}; };
function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) { function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) {
@@ -83,7 +85,7 @@ class MockResolver {
export async function createTestingModule( export async function createTestingModule(
moduleDef: TestingModuleMeatdata = {}, moduleDef: TestingModuleMeatdata = {},
autoInitialize = true autoInitialize = true
) { ): Promise<TestingModule> {
// setting up // setting up
let imports = moduleDef.imports ?? []; let imports = moduleDef.imports ?? [];
imports = imports =
@@ -129,6 +131,9 @@ export async function createTestingModule(
// by pass password min length validation // by pass password min length validation
await runtime.set('auth/password.min', 1); await runtime.set('auth/password.min', 1);
}; };
testingModule[Symbol.asyncDispose] = async () => {
await m.close();
};
if (autoInitialize) { if (autoInitialize) {
await testingModule.initTestingDB(); await testingModule.initTestingDB();
@@ -138,7 +143,9 @@ export async function createTestingModule(
return testingModule; return testingModule;
} }
export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { export async function createTestingApp(
moduleDef: TestingModuleMeatdata = {}
): Promise<{ module: TestingModule; app: TestingApp }> {
const m = await createTestingModule(moduleDef, false); const m = await createTestingModule(moduleDef, false);
const app = m.createNestApplication({ const app = m.createNestApplication({
@@ -169,7 +176,10 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
await app.init(); await app.init();
app.initTestingDB = m.initTestingDB.bind(m); app.initTestingDB = m.initTestingDB.bind(m);
app[Symbol.asyncDispose] = async () => {
await m[Symbol.asyncDispose]();
await app.close();
};
return { return {
module: m, module: m,
app: app, app: app,
+56 -39
View File
@@ -29,44 +29,6 @@ export class FeatureModel extends BaseModel {
}; };
} }
@Transactional()
async upsert<T extends FeatureName>(
name: T,
configs: FeatureConfig<T>,
deprecatedType: FeatureType,
deprecatedVersion: number
) {
const parsedConfigs = this.check(name, configs);
// TODO(@forehalo):
// could be a simple upsert operation, but we got useless `version` column in the database
// will be fixed when `version` column gets deprecated
const latest = await this.try_get_unchecked(name);
let feature: Feature;
if (!latest) {
feature = await this.db.feature.create({
data: {
name,
deprecatedType,
deprecatedVersion,
configs: parsedConfigs,
},
});
} else {
feature = await this.db.feature.update({
where: { id: latest.id },
data: {
configs: parsedConfigs,
},
});
}
this.logger.verbose(`Feature ${name} upserted`);
return feature as Feature & { configs: FeatureConfig<T> };
}
/** /**
* Get the latest feature from database. * Get the latest feature from database.
* *
@@ -121,11 +83,66 @@ export class FeatureModel extends BaseModel {
return FeatureConfigs[name].type; return FeatureConfigs[name].type;
} }
@Transactional()
private async upsert<T extends FeatureName>(
name: T,
configs: FeatureConfig<T>,
deprecatedType: FeatureType,
deprecatedVersion: number
) {
const parsedConfigs = this.check(name, configs);
// TODO(@forehalo):
// could be a simple upsert operation, but we got useless `version` column in the database
// will be fixed when `version` column gets deprecated
const latest = await this.db.feature.findFirst({
where: {
name,
},
orderBy: {
deprecatedVersion: 'desc',
},
});
let feature: Feature;
if (!latest) {
feature = await this.db.feature.create({
data: {
name,
deprecatedType,
deprecatedVersion,
configs: parsedConfigs,
},
});
} else {
feature = await this.db.feature.update({
where: { id: latest.id },
data: {
configs: parsedConfigs,
},
});
}
this.logger.verbose(`Feature ${name} upserted`);
return feature as Feature & { configs: FeatureConfig<T> };
}
async refreshFeatures() { async refreshFeatures() {
for (const key in FeatureConfigs) { for (const key in FeatureConfigs) {
const name = key as FeatureName; const name = key as FeatureName;
const def = FeatureConfigs[name]; const def = FeatureConfigs[name];
await this.upsert(name, def.configs, def.type, def.deprecatedVersion); // self-hosted instance will use pro plan as free plan
if (name === 'free_plan_v1' && this.config.isSelfhosted) {
await this.upsert(
name,
FeatureConfigs['pro_plan_v1'].configs,
def.type,
def.deprecatedVersion
);
} else {
await this.upsert(name, def.configs, def.type, def.deprecatedVersion);
}
} }
} }
} }