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
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX "_data_migrations_name_key" ON "_data_migrations"("name");
@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "features" ADD COLUMN "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN "type" SET DEFAULT 0,
ALTER COLUMN "configs" SET DEFAULT '{}';
-- AlterTable
ALTER TABLE "user_features" ADD COLUMN "name" VARCHAR NOT NULL DEFAULT '',
ADD COLUMN "type" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "workspace_features" ADD COLUMN "name" VARCHAR NOT NULL DEFAULT '',
ADD COLUMN "type" INTEGER NOT NULL DEFAULT 0;
-- CreateIndex
CREATE INDEX "user_features_name_idx" ON "user_features"("name");
-- CreateIndex
CREATE INDEX "user_features_feature_id_idx" ON "user_features"("feature_id");
-- CreateIndex
CREATE INDEX "workspace_features_name_idx" ON "workspace_features"("name");
-- CreateIndex
CREATE INDEX "workspace_features_feature_id_idx" ON "workspace_features"("feature_id");
+62 -68
View File
@@ -1,7 +1,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"] binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]
previewFeatures = ["metrics", "tracing", "relationJoins", "nativeDistinct"] previewFeatures = ["metrics", "relationJoins", "nativeDistinct"]
} }
datasource db { datasource db {
@@ -102,10 +102,10 @@ model Workspace {
enableAi Boolean @default(true) @map("enable_ai") enableAi Boolean @default(true) @map("enable_ai")
enableUrlPreview Boolean @default(false) @map("enable_url_preview") enableUrlPreview Boolean @default(false) @map("enable_url_preview")
features WorkspaceFeature[]
pages WorkspacePage[] pages WorkspacePage[]
permissions WorkspaceUserPermission[] permissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[] pagePermissions WorkspacePageUserPermission[]
features WorkspaceFeature[]
blobs Blob[] blobs Blob[]
@@map("workspaces") @@map("workspaces")
@@ -178,82 +178,76 @@ model WorkspacePageUserPermission {
@@map("workspace_page_user_permissions") @@map("workspace_page_user_permissions")
} }
// feature gates is a way to enable/disable features for a user model Feature {
// for example: id Int @id @default(autoincrement())
// - early access is a feature that allow some users to access the insider version name String @map("feature") @db.VarChar
// - pro plan is a quota that allow some users access to more resources after they pay configs Json @default("{}") @db.Json
model UserFeature { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
id Int @id @default(autoincrement()) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
userId String @map("user_id") @db.VarChar /// TODO(@forehalo): remove in the coming version
featureId Int @map("feature_id") @db.Integer /// @deprecated
/// we don't need to record all the historical version of features
deprecatedVersion Int @default(0) @map("version") @db.Integer
/// @deprecated
/// we don't need to record type of features any more, there are always static,
/// but set it in `WorkspaceFeature` and `UserFeature` for fast query with just a little redundant.
deprecatedType Int @default(0) @map("type") @db.Integer
// we will record the reason why the feature is enabled/disabled userFeatures UserFeature[]
// for example: workspaceFeatures WorkspaceFeature[]
// - pro_plan_v1: "user buy the pro plan"
@@unique([name, deprecatedVersion])
@@map("features")
}
model UserFeature {
id Int @id @default(autoincrement())
userId String @map("user_id") @db.VarChar
featureId Int @map("feature_id") @db.Integer
// it should be typed as `optional` in the codebase, but we would keep all values exists during data migration.
// so it's safe to assert it a non-null value.
name String @default("") @map("name") @db.VarChar
// a little redundant, but fast the queries
type Int @default(0) @map("type") @db.Integer
reason String @db.VarChar reason String @db.VarChar
// record the quota enabled time
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
// record the quota expired time, pay plan is a subscription, so it will expired
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
// whether the feature is activated
// for example:
// - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it
activated Boolean @default(false) activated Boolean @default(false)
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade) feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
@@index([name])
@@index([featureId])
@@map("user_features") @@map("user_features")
} }
// feature gates is a way to enable/disable features for a workspace
// for example:
// - copilet is a feature that allow some users in a workspace to access the copilet feature
model WorkspaceFeature { model WorkspaceFeature {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
workspaceId String @map("workspace_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar
featureId Int @map("feature_id") @db.Integer featureId Int @map("feature_id") @db.Integer
// it should be typed as `optional` in the codebase, but we would keep all values exists during data migration.
// override quota's configs // so it's safe to assert it a non-null value.
configs Json @default("{}") @db.Json name String @default("") @map("name") @db.VarChar
// we will record the reason why the feature is enabled/disabled // a little redundant, but fast the queries
// for example: type Int @default(0) @map("type") @db.Integer
// - copilet_v1: "owner buy the copilet feature package" /// overrides for the default feature configs
reason String @db.VarChar configs Json @default("{}") @db.Json
// record the feature enabled time reason String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
// record the quota expired time, pay plan is a subscription, so it will expired activated Boolean @default(false)
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3) expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
// whether the feature is activated
// for example:
// - if owner unsubscribe a feature package, we will set the feature to deactivated, but dont delete it
activated Boolean @default(false)
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade) feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId]) @@index([workspaceId])
@@index([name])
@@index([featureId])
@@map("workspace_features") @@map("workspace_features")
} }
model Feature {
id Int @id @default(autoincrement())
feature String @db.VarChar
version Int @default(0) @db.Integer
// 0: feature, 1: quota
type Int @db.Integer
// configs, define by feature controller
configs Json @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
UserFeatureGates UserFeature[]
WorkspaceFeatures WorkspaceFeature[]
@@unique([feature, version])
@@map("features")
}
// the latest snapshot of each doc that we've seen // the latest snapshot of each doc that we've seen
// Snapshot + Updates are the latest state of the doc // Snapshot + Updates are the latest state of the doc
model Snapshot { model Snapshot {
@@ -417,7 +411,7 @@ model AiSession {
model DataMigration { model DataMigration {
id String @id @default(uuid()) @db.VarChar id String @id @default(uuid()) @db.VarChar
name String @db.VarChar name String @unique @db.VarChar
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3) startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
@@ -552,21 +546,21 @@ model Subscription {
} }
model Invoice { model Invoice {
stripeInvoiceId String @id @map("stripe_invoice_id") stripeInvoiceId String @id @map("stripe_invoice_id")
targetId String @map("target_id") @db.VarChar targetId String @map("target_id") @db.VarChar
currency String @db.VarChar(3) currency String @db.VarChar(3)
// CNY 12.50 stored as 1250 // CNY 12.50 stored as 1250
amount Int @db.Integer amount Int @db.Integer
status String @db.VarChar(20) status String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason // billing reason
reason String? @db.VarChar reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text lastPaymentError String? @map("last_payment_error") @db.Text
// stripe hosted invoice link // stripe hosted invoice link
link String? @db.Text link String? @db.Text
// whether the onetime subscription has been redeemed // whether the onetime subscription has been redeemed
onetimeSubscriptionRedeemed Boolean @map("onetime_subscription_redeemed") @default(false) onetimeSubscriptionRedeemed Boolean @default(false) @map("onetime_subscription_redeemed")
@@index([targetId]) @@index([targetId])
@@map("invoices") @@map("invoices")
@@ -1,7 +1,6 @@
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava'; import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
@@ -10,10 +9,10 @@ import request from 'supertest';
import { buildAppModule } from '../../app.module'; import { buildAppModule } from '../../app.module';
import { Config } from '../../base'; import { Config } from '../../base';
import { ServerService } from '../../core/config'; import { ServerService } from '../../core/config';
import { createTestingApp, initTestingDB } from '../utils'; import { createTestingApp, type TestingApp } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
app: INestApplication; app: TestingApp;
db: PrismaClient; db: PrismaClient;
}>; }>;
@@ -54,7 +53,7 @@ test.before('init selfhost server', async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.db); await t.context.app.initTestingDB();
const server = t.context.app.get(ServerService); const server = t.context.app.get(ServerService);
// @ts-expect-error disable cache // @ts-expect-error disable cache
server._initialized = false; server._initialized = false;
@@ -188,7 +187,8 @@ test('should redirect to admin if initialized', async t => {
t.is(res.header.location, '/admin'); t.is(res.header.location, '/admin');
}); });
test('should return mobile assets if visited by mobile', async t => { // TODO(@forehalo): return mobile when it's ready
test.skip('should return web assets if visited by mobile', async t => {
await t.context.db.user.create({ await t.context.db.user.create({
data: { data: {
name: 'test', name: 'test',
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
@@ -8,7 +7,7 @@ import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota'; import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user'; import { UserModule } from '../../core/user';
import { Models } from '../../models'; import { Models } from '../../models';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
@@ -31,7 +30,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.db); await t.context.m.initTestingDB();
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1'); t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
}); });
@@ -1,6 +1,5 @@
/// <reference types="../global.d.ts" /> /// <reference types="../global.d.ts" />
import { TestingModule } from '@nestjs/testing';
import type { ExecutionContext, TestFn } from 'ava'; import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
@@ -27,7 +26,7 @@ import {
CopilotCheckHtmlExecutor, CopilotCheckHtmlExecutor,
CopilotCheckJsonExecutor, CopilotCheckJsonExecutor,
} from '../plugins/copilot/workflow/executor'; } from '../plugins/copilot/workflow/executor';
import { createTestingModule } from './utils'; import { createTestingModule, TestingModule } from './utils';
import { TestAssets } from './utils/copilot'; import { TestAssets } from './utils/copilot';
type Tester = { type Tester = {
@@ -2,7 +2,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava'; import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -27,6 +26,7 @@ import {
createWorkspace, createWorkspace,
inviteUser, inviteUser,
signUp, signUp,
TestingApp,
} from './utils'; } from './utils';
import { import {
array2sse, array2sse,
@@ -47,7 +47,7 @@ import {
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
app: INestApplication; app: TestingApp;
prompt: PromptService; prompt: PromptService;
provider: CopilotProviderService; provider: CopilotProviderService;
storage: CopilotStorage; storage: CopilotStorage;
@@ -1,6 +1,3 @@
/// <reference types="../global.d.ts" />
import { TestingModule } from '@nestjs/testing';
import type { TestFn } from 'ava'; import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -41,7 +38,7 @@ import {
} from '../plugins/copilot/workflow/executor'; } from '../plugins/copilot/workflow/executor';
import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils'; import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils';
import { WorkflowGraphList } from '../plugins/copilot/workflow/graph'; import { WorkflowGraphList } from '../plugins/copilot/workflow/graph';
import { createTestingModule } from './utils'; import { createTestingModule, TestingModule } from './utils';
import { MockCopilotTestProvider, WorkflowTestCases } from './utils/copilot'; import { MockCopilotTestProvider, WorkflowTestCases } from './utils/copilot';
const test = ava as TestFn<{ const test = ava as TestFn<{
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import type { Snapshot } from '@prisma/client'; import type { Snapshot } from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import test from 'ava'; import test from 'ava';
@@ -7,7 +6,7 @@ import * as Sinon from 'sinon';
import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc'; import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { DocStorageOptions } from '../../core/doc/options'; import { DocStorageOptions } from '../../core/doc/options';
import { DocRecord } from '../../core/doc/storage'; import { DocRecord } from '../../core/doc/storage';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
let m: TestingModule; let m: TestingModule;
let adapter: PgWorkspaceDocStorageAdapter; let adapter: PgWorkspaceDocStorageAdapter;
@@ -24,7 +23,7 @@ test.before(async () => {
}); });
test.beforeEach(async () => { test.beforeEach(async () => {
await initTestingDB(db); await m.initTestingDB();
const options = m.get(DocStorageOptions); const options = m.get(DocStorageOptions);
Sinon.stub(options, 'historyMaxAge').resolves(1000); Sinon.stub(options, 'historyMaxAge').resolves(1000);
}); });
@@ -75,7 +75,8 @@ test('should render correct html', async t => {
); );
}); });
test('should render correct mobile html', async t => { // TODO(@forehalo): enable it when mobile version is ready
test.skip('should render correct mobile html', async t => {
const res = await request(t.context.app.getHttpServer()) const res = await request(t.context.app.getHttpServer())
.get('/workspace/xxxx/xxx') .get('/workspace/xxxx/xxx')
.set('user-agent', mobileUAString) .set('user-agent', mobileUAString)
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import test from 'ava'; import test from 'ava';
import * as Sinon from 'sinon'; import * as Sinon from 'sinon';
@@ -9,7 +8,7 @@ import {
DocStorageModule, DocStorageModule,
PgWorkspaceDocStorageAdapter as Adapter, PgWorkspaceDocStorageAdapter as Adapter,
} from '../../core/doc'; } from '../../core/doc';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
let m: TestingModule; let m: TestingModule;
let db: PrismaClient; let db: PrismaClient;
@@ -35,7 +34,7 @@ test.before('init testing module', async () => {
}); });
test.beforeEach(async () => { test.beforeEach(async () => {
await initTestingDB(db); await m.initTestingDB();
}); });
test.after.always(async () => { test.after.always(async () => {
@@ -1,176 +0,0 @@
/// <reference types="../global.d.ts" />
import { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import { Runtime } from '../base';
import { AuthService } from '../core/auth/service';
import {
FeatureManagementService,
FeatureModule,
FeatureService,
FeatureType,
} from '../core/features';
import { WorkspaceResolver } from '../core/workspaces/resolvers';
import { createTestingApp } from './utils';
import { WorkspaceResolverMock } from './utils/feature';
const test = ava as TestFn<{
auth: AuthService;
feature: FeatureService;
workspace: WorkspaceResolver;
management: FeatureManagementService;
app: INestApplication;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [FeatureModule],
providers: [WorkspaceResolver],
tapModule: module => {
module
.overrideProvider(WorkspaceResolver)
.useClass(WorkspaceResolverMock);
},
});
const runtime = app.get(Runtime);
await runtime.set('flags/earlyAccessControl', true);
t.context.app = app;
t.context.auth = app.get(AuthService);
t.context.feature = app.get(FeatureService);
t.context.workspace = app.get(WorkspaceResolver);
t.context.management = app.get(FeatureManagementService);
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to set user feature', async t => {
const { auth, feature } = t.context;
const u1 = await auth.signUp('test@test.com', '123456');
const f1 = await feature.getUserFeatures(u1.id);
t.is(f1.length, 0, 'should be empty');
await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 'test');
const f2 = await feature.getUserFeatures(u1.id);
t.is(f2.length, 1, 'should have 1 feature');
t.is(f2[0].feature.name, FeatureType.EarlyAccess, 'should be early access');
});
test('should be able to check early access', async t => {
const { auth, feature, management } = t.context;
const u1 = await auth.signUp('test@test.com', '123456');
const f1 = await management.canEarlyAccess(u1.email);
t.false(f1, 'should not have early access');
await management.addEarlyAccess(u1.id);
const f2 = await management.canEarlyAccess(u1.email);
t.true(f2, 'should have early access');
const f3 = await feature.listUsersByFeature(FeatureType.EarlyAccess);
t.is(f3.length, 1, 'should have 1 user');
t.is(f3[0].id, u1.id, 'should be the same user');
});
test('should be able revert user feature', async t => {
const { auth, feature, management } = t.context;
const u1 = await auth.signUp('test@test.com', '123456');
const f1 = await management.canEarlyAccess(u1.email);
t.false(f1, 'should not have early access');
await management.addEarlyAccess(u1.id);
const f2 = await management.canEarlyAccess(u1.email);
t.true(f2, 'should have early access');
const q1 = await management.listEarlyAccess();
t.is(q1.length, 1, 'should have 1 user');
t.is(q1[0].id, u1.id, 'should be the same user');
await management.removeEarlyAccess(u1.id);
const f3 = await management.canEarlyAccess(u1.email);
t.false(f3, 'should not have early access');
const q2 = await management.listEarlyAccess();
t.is(q2.length, 0, 'should have no user');
const q3 = await feature.getUserFeatures(u1.id);
t.is(q3.length, 1, 'should have 1 feature');
t.is(q3[0].feature.name, FeatureType.EarlyAccess, 'should be early access');
t.is(q3[0].activated, false, 'should be deactivated');
});
test('should be same instance after reset the user feature', async t => {
const { auth, feature, management } = t.context;
const u1 = await auth.signUp('test@test.com', '123456');
await management.addEarlyAccess(u1.id);
const f1 = (await feature.getUserFeatures(u1.id))[0];
await management.removeEarlyAccess(u1.id);
await management.addEarlyAccess(u1.id);
const f2 = (await feature.getUserFeatures(u1.id))[1];
t.is(f1.feature, f2.feature, 'should be same instance');
});
test('should be able to set workspace feature', async t => {
const { auth, feature, workspace } = t.context;
const u1 = await auth.signUp('test@test.com', '123456');
const w1 = await workspace.createWorkspace(u1, null);
const f1 = await feature.getWorkspaceFeatures(w1.id);
t.is(f1.length, 0, 'should be empty');
await feature.addWorkspaceFeature(w1.id, FeatureType.Copilot, 'test');
const f2 = await feature.getWorkspaceFeatures(w1.id);
t.is(f2.length, 1, 'should have 1 feature');
t.is(f2[0].feature.name, FeatureType.Copilot, 'should be copilot');
});
test('should be able to check workspace feature', async t => {
const { auth, feature, workspace, management } = t.context;
const u1 = await auth.signUp('test@test.com', '123456');
const w1 = await workspace.createWorkspace(u1, null);
const f1 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
t.false(f1, 'should not have copilot');
await management.addWorkspaceFeatures(w1.id, FeatureType.Copilot, 'test');
const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
t.true(f2, 'should have copilot');
const f3 = await feature.listWorkspacesByFeature(FeatureType.Copilot);
t.is(f3.length, 1, 'should have 1 workspace');
t.is(f3[0].id, w1.id, 'should be the same workspace');
});
test('should be able revert workspace feature', async t => {
const { auth, feature, workspace, management } = t.context;
const u1 = await auth.signUp('test@test.com', '123456');
const w1 = await workspace.createWorkspace(u1, null);
const f1 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
t.false(f1, 'should not have feature');
await management.addWorkspaceFeatures(w1.id, FeatureType.Copilot, 'test');
const f2 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
t.true(f2, 'should have feature');
await management.removeWorkspaceFeature(w1.id, FeatureType.Copilot);
const f3 = await management.hasWorkspaceFeature(w1.id, FeatureType.Copilot);
t.false(f3, 'should not have feature');
const q3 = await feature.getWorkspaceFeatures(w1.id);
t.is(q3.length, 1, 'should have 1 feature');
t.is(q3[0].feature.name, FeatureType.Copilot, 'should be copilot');
t.is(q3[0].activated, false, 'should be deactivated');
});
@@ -5,7 +5,6 @@ import Sinon from 'sinon';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { MailService } from '../base/mailer'; import { MailService } from '../base/mailer';
import { FeatureManagementService } from '../core/features';
import { createTestingApp, createWorkspace, inviteUser, signUp } from './utils'; import { createTestingApp, createWorkspace, inviteUser, signUp } from './utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
app: INestApplication; app: INestApplication;
@@ -16,13 +15,6 @@ import * as renderers from '../mails';
test.beforeEach(async t => { test.beforeEach(async t => {
const { module, app } = await createTestingApp({ const { module, app } = await createTestingApp({
imports: [AppModule], imports: [AppModule],
tapModule: module => {
module.overrideProvider(FeatureManagementService).useValue({
hasWorkspaceFeature() {
return false;
},
});
},
}); });
const mail = module.get(MailService); const mail = module.get(MailService);
@@ -0,0 +1,44 @@
# Snapshot report for `src/__tests__/models/feature-user.spec.ts`
The actual snapshot is saved in `feature-user.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should get user quota
> free plan
{
blobLimit: 10485760,
businessBlobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 604800000,
memberLimit: 3,
name: 'Free',
storageQuota: 10737418240,
}
## should switch user quota
> switch to pro plan
{
blobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 2592000000,
memberLimit: 10,
name: 'Pro',
storageQuota: 107374182400,
}
> switch to free plan
{
blobLimit: 10485760,
businessBlobLimit: 104857600,
copilotActionLimit: 10,
historyPeriod: 604800000,
memberLimit: 3,
name: 'Free',
storageQuota: 10737418240,
}
@@ -0,0 +1,18 @@
# Snapshot report for `src/__tests__/models/feature-workspace.spec.ts`
The actual snapshot is saved in `feature-workspace.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should get workspace quota
> team plan
{
blobLimit: 524288000,
historyPeriod: 2592000000,
memberLimit: 100,
name: 'Team Workspace',
seatQuota: 21474836480,
storageQuota: 2254857830400,
}
@@ -10,6 +10,7 @@ Generated by [AVA](https://avajs.dev).
{ {
blobLimit: 10485760, blobLimit: 10485760,
businessBlobLimit: 104857600,
copilotActionLimit: 10, copilotActionLimit: 10,
historyPeriod: 604800000, historyPeriod: 604800000,
memberLimit: 3, memberLimit: 3,
@@ -23,6 +24,7 @@ Generated by [AVA](https://avajs.dev).
{ {
blobLimit: 10485760, blobLimit: 10485760,
businessBlobLimit: 104857600,
copilotActionLimit: 10, copilotActionLimit: 10,
historyPeriod: 604800000, historyPeriod: 604800000,
memberLimit: 3, memberLimit: 3,
@@ -1,9 +1,8 @@
import { TestingModule } from '@nestjs/testing'; import { User } from '@prisma/client';
import { PrismaClient, User } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { UserFeatureModel, UserModel } from '../../models'; import { FeatureType, UserFeatureModel, UserModel } from '../../models';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, TestingModule } from '../utils';
interface Context { interface Context {
module: TestingModule; module: TestingModule;
@@ -21,7 +20,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.module.get(PrismaClient)); await t.context.module.initTestingDB();
t.context.u1 = await t.context.module.get(UserModel).create({ t.context.u1 = await t.context.module.get(UserModel).create({
email: 'u1@affine.pro', email: 'u1@affine.pro',
registered: true, registered: true,
@@ -41,7 +40,13 @@ test('should get null if user feature not found', async t => {
test('should get user feature', async t => { test('should get user feature', async t => {
const { model, u1 } = t.context; const { model, u1 } = t.context;
const userFeature = await model.get(u1.id, 'free_plan_v1'); const userFeature = await model.get(u1.id, 'free_plan_v1');
t.is(userFeature?.feature, 'free_plan_v1'); t.is(userFeature?.name, 'free_plan_v1');
});
test('should get user quota', async t => {
const { model, u1 } = t.context;
const userQuota = await model.getQuota(u1.id);
t.snapshot(userQuota?.configs, 'free plan');
}); });
test('should list user features', async t => { test('should list user features', async t => {
@@ -50,6 +55,16 @@ test('should list user features', async t => {
t.like(await model.list(u1.id), ['free_plan_v1']); t.like(await model.list(u1.id), ['free_plan_v1']);
}); });
test('should list user features by type', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'test');
await model.add(u1.id, 'unlimited_copilot', 'test');
t.like(await model.list(u1.id, FeatureType.Quota), ['free_plan_v1']);
t.like(await model.list(u1.id, FeatureType.Feature), ['unlimited_copilot']);
});
test('should directly test user feature existence', async t => { test('should directly test user feature existence', async t => {
const { model, u1 } = t.context; const { model, u1 } = t.context;
@@ -82,14 +97,29 @@ test('should remove user feature', async t => {
t.false((await model.list(u1.id)).includes('free_plan_v1')); t.false((await model.list(u1.id)).includes('free_plan_v1'));
}); });
test('should switch user feature', async t => { test('should switch user quota', async t => {
const { model, u1 } = t.context; const { model, u1 } = t.context;
await model.switch(u1.id, 'free_plan_v1', 'pro_plan_v1', 'test'); await model.switchQuota(u1.id, 'pro_plan_v1', 'test');
const quota = await model.getQuota(u1.id);
t.snapshot(quota?.configs, 'switch to pro plan');
t.false(await model.has(u1.id, 'free_plan_v1')); await model.switchQuota(u1.id, 'free_plan_v1', 'test');
t.true(await model.has(u1.id, 'pro_plan_v1')); const quota2 = await model.getQuota(u1.id);
t.snapshot(quota2?.configs, 'switch to free plan');
t.false((await model.list(u1.id)).includes('free_plan_v1')); });
t.true((await model.list(u1.id)).includes('pro_plan_v1'));
test('should not switch user quota if the new quota is the same as the current one', async t => {
const { model, u1 } = t.context;
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
// @ts-expect-error private
const quota = await model.db.userFeature.findFirst({
where: {
userId: u1.id,
},
});
t.not(quota?.reason, 'test not switch');
}); });
@@ -1,9 +1,13 @@
import { TestingModule } from '@nestjs/testing'; import { Workspace } from '@prisma/client';
import { PrismaClient, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { UserModel, WorkspaceFeatureModel, WorkspaceModel } from '../../models'; import {
import { createTestingModule, initTestingDB } from '../utils'; FeatureType,
UserModel,
WorkspaceFeatureModel,
WorkspaceModel,
} from '../../models';
import { createTestingModule, type TestingModule } from '../utils';
interface Context { interface Context {
module: TestingModule; module: TestingModule;
@@ -21,7 +25,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.module.get(PrismaClient)); await t.context.module.initTestingDB();
const u1 = await t.context.module.get(UserModel).create({ const u1 = await t.context.module.get(UserModel).create({
email: 'u1@affine.pro', email: 'u1@affine.pro',
registered: true, registered: true,
@@ -46,18 +50,52 @@ test('should directly test workspace feature existence', async t => {
t.false(await model.has(ws.id, 'unlimited_workspace')); t.false(await model.has(ws.id, 'unlimited_workspace'));
}); });
test('should get workspace quota', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 100,
});
const quota = await model.getQuota(ws.id);
t.snapshot(quota?.configs, 'team plan');
});
test('should return null if quota removed', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 100,
});
await model.remove(ws.id, 'team_plan_v1');
const quota = await model.getQuota(ws.id);
t.is(quota, null);
});
test('should list empty workspace features', async t => { test('should list empty workspace features', async t => {
const { model, ws } = t.context; const { model, ws } = t.context;
t.deepEqual(await model.list(ws.id), []); t.deepEqual(await model.list(ws.id), []);
}); });
test('should list workspace features by type', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'unlimited_workspace', 'test');
await model.add(ws.id, 'team_plan_v1', 'test');
t.like(await model.list(ws.id, FeatureType.Quota), ['team_plan_v1']);
t.like(await model.list(ws.id, FeatureType.Feature), ['unlimited_workspace']);
});
test('should add workspace feature', async t => { test('should add workspace feature', async t => {
const { model, ws } = t.context; const { model, ws } = t.context;
await model.add(ws.id, 'unlimited_workspace', 'test'); await model.add(ws.id, 'unlimited_workspace', 'test');
t.is( t.is(
(await model.get(ws.id, 'unlimited_workspace'))?.feature, (await model.get(ws.id, 'unlimited_workspace'))?.name,
'unlimited_workspace' 'unlimited_workspace'
); );
t.true(await model.has(ws.id, 'unlimited_workspace')); t.true(await model.has(ws.id, 'unlimited_workspace'));
@@ -103,25 +141,3 @@ test('should remove workspace feature', async t => {
t.false(await model.has(ws.id, 'team_plan_v1')); t.false(await model.has(ws.id, 'team_plan_v1'));
t.false((await model.list(ws.id)).includes('team_plan_v1')); t.false((await model.list(ws.id)).includes('team_plan_v1'));
}); });
test('should switch workspace feature', async t => {
const { model, ws } = t.context;
await model.switch(ws.id, 'team_plan_v1', 'unlimited_workspace', 'test');
t.false(await model.has(ws.id, 'team_plan_v1'));
t.true(await model.has(ws.id, 'unlimited_workspace'));
t.false((await model.list(ws.id)).includes('team_plan_v1'));
t.true((await model.list(ws.id)).includes('unlimited_workspace'));
});
test('should switch workspace feature with overrides', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'unlimited_workspace', 'test');
await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 100 });
const f2 = await model.get(ws.id, 'team_plan_v1');
t.is(f2!.configs.memberLimit, 100);
});
@@ -1,9 +1,8 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { FeatureType } from '../../models';
import { FeatureModel } from '../../models/feature'; import { FeatureModel } from '../../models/feature';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
interface Context { interface Context {
module: TestingModule; module: TestingModule;
@@ -20,7 +19,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.module.get(PrismaClient)); await t.context.module.initTestingDB();
}); });
test.after(async t => { test.after(async t => {
@@ -91,7 +90,12 @@ 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;
const newFeature = await feature.upsert('new_feature' as any, {}); const newFeature = await feature.upsert(
'new_feature' as any,
{},
FeatureType.Feature,
1
);
t.deepEqual(newFeature.configs, {}); t.deepEqual(newFeature.configs, {});
}); });
@@ -100,10 +104,15 @@ 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');
const newFreePlanFeature = await feature.upsert('free_plan_v1', { const newFreePlanFeature = await feature.upsert(
...freePlanFeature.configs, 'free_plan_v1',
memberLimit: 10, {
}); ...freePlanFeature.configs,
memberLimit: 10,
},
FeatureType.Quota,
1
);
t.deepEqual(newFreePlanFeature.configs, { t.deepEqual(newFreePlanFeature.configs, {
...freePlanFeature.configs, ...freePlanFeature.configs,
@@ -113,7 +122,10 @@ 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(feature.upsert('free_plan_v1', {} as any), { await t.throwsAsync(
message: 'Invalid feature config for free_plan_v1', feature.upsert('free_plan_v1', {} as any, FeatureType.Quota, 1),
}); {
message: 'Invalid feature config for free_plan_v1',
}
);
}); });
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
@@ -8,7 +7,7 @@ import { PublicPageMode } from '../../models/common';
import { PageModel } from '../../models/page'; import { PageModel } from '../../models/page';
import { type User, UserModel } from '../../models/user'; import { type User, UserModel } from '../../models/user';
import { type Workspace, WorkspaceModel } from '../../models/workspace'; import { type Workspace, WorkspaceModel } from '../../models/workspace';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
interface Context { interface Context {
config: Config; config: Config;
@@ -36,7 +35,7 @@ let user: User;
let workspace: Workspace; let workspace: Workspace;
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.db); await t.context.module.initTestingDB();
user = await t.context.user.create({ user = await t.context.user.create({
email: 'test@affine.pro', email: 'test@affine.pro',
}); });
@@ -1,11 +1,10 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import { Config } from '../../base/config'; import { Config } from '../../base/config';
import { SessionModel } from '../../models/session'; import { SessionModel } from '../../models/session';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
interface Context { interface Context {
config: Config; config: Config;
@@ -28,7 +27,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.db); await t.context.module.initTestingDB();
}); });
test.after(async t => { test.after(async t => {
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -7,7 +6,7 @@ import { EmailAlreadyUsed, EventBus } from '../../base';
import { WorkspaceRole } from '../../core/permission'; import { WorkspaceRole } from '../../core/permission';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { WorkspaceMemberStatus } from '../../models/workspace'; import { WorkspaceMemberStatus } from '../../models/workspace';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
interface Context { interface Context {
module: TestingModule; module: TestingModule;
@@ -24,7 +23,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.module.get(PrismaClient)); await t.context.module.initTestingDB();
}); });
test.after(async t => { test.after(async t => {
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
@@ -6,7 +5,7 @@ import {
TokenType, TokenType,
VerificationTokenModel, VerificationTokenModel,
} from '../../models/verification-token'; } from '../../models/verification-token';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
interface Context { interface Context {
module: TestingModule; module: TestingModule;
@@ -25,7 +24,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.db); await t.context.module.initTestingDB();
}); });
test.after(async t => { test.after(async t => {
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -7,7 +6,7 @@ import { Config, EventBus } from '../../base';
import { WorkspaceRole } from '../../core/permission'; import { WorkspaceRole } from '../../core/permission';
import { UserModel } from '../../models/user'; import { UserModel } from '../../models/user';
import { WorkspaceModel } from '../../models/workspace'; import { WorkspaceModel } from '../../models/workspace';
import { createTestingModule, initTestingDB } from '../utils'; import { createTestingModule, type TestingModule } from '../utils';
interface Context { interface Context {
config: Config; config: Config;
@@ -29,7 +28,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.db); await t.context.module.initTestingDB();
}); });
test.after(async t => { test.after(async t => {
@@ -1,13 +1,6 @@
import '../../plugins/config'; import '../../plugins/config';
import { import { Controller, Get, HttpStatus, UseGuards } from '@nestjs/common';
Controller,
Get,
HttpStatus,
INestApplication,
UseGuards,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
import request, { type Response } from 'supertest'; import request, { type Response } from 'supertest';
@@ -21,12 +14,12 @@ import {
ThrottlerStorage, ThrottlerStorage,
} from '../../base/throttler'; } from '../../base/throttler';
import { AuthService, Public } from '../../core/auth'; import { AuthService, Public } from '../../core/auth';
import { createTestingApp, initTestingDB, internalSignIn } from '../utils'; import { createTestingApp, internalSignIn, TestingApp } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
storage: ThrottlerStorage; storage: ThrottlerStorage;
cookie: string; cookie: string;
app: INestApplication; app: TestingApp;
}>; }>;
@UseGuards(CloudThrottlerGuard) @UseGuards(CloudThrottlerGuard)
@@ -115,7 +108,7 @@ test.before(async t => {
}); });
test.beforeEach(async t => { test.beforeEach(async t => {
await initTestingDB(t.context.app.get(PrismaClient)); await t.context.app.initTestingDB();
const { app } = t.context; const { app } = t.context;
const auth = app.get(AuthService); const auth = app.get(AuthService);
const u1 = await auth.signUp('u1@affine.pro', 'test'); const u1 = await auth.signUp('u1@affine.pro', 'test');
@@ -1,6 +1,6 @@
import '../../plugins/config'; import '../../plugins/config';
import { HttpStatus, INestApplication } from '@nestjs/common'; import { HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -15,7 +15,7 @@ import { Models } from '../../models';
import { OAuthProviderName } from '../../plugins/oauth/config'; import { OAuthProviderName } from '../../plugins/oauth/config';
import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google'; import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google';
import { OAuthService } from '../../plugins/oauth/service'; import { OAuthService } from '../../plugins/oauth/service';
import { createTestingApp, getSession, initTestingDB } from '../utils'; import { createTestingApp, getSession, TestingApp } from '../utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
auth: AuthService; auth: AuthService;
@@ -23,7 +23,7 @@ const test = ava as TestFn<{
models: Models; models: Models;
u1: CurrentUser; u1: CurrentUser;
db: PrismaClient; db: PrismaClient;
app: INestApplication; app: TestingApp;
}>; }>;
test.before(async t => { test.before(async t => {
@@ -54,7 +54,7 @@ test.before(async t => {
test.beforeEach(async t => { test.beforeEach(async t => {
Sinon.restore(); Sinon.restore();
await initTestingDB(t.context.db); await t.context.app.initTestingDB();
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1'); t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
}); });
@@ -247,7 +247,7 @@ test('should throw if provider is invalid in callback uri', async t => {
t.pass(); t.pass();
}); });
function mockOAuthProvider(app: INestApplication, email: string) { function mockOAuthProvider(app: TestingApp, email: string) {
const provider = app.get(GoogleOAuthProvider); const provider = app.get(GoogleOAuthProvider);
const oauth = app.get(OAuthService); const oauth = app.get(OAuthService);
@@ -1,6 +1,5 @@
import '../../plugins/payment'; import '../../plugins/payment';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava'; import ava, { TestFn } from 'ava';
import Sinon from 'sinon'; import Sinon from 'sinon';
@@ -11,7 +10,7 @@ import { EventBus, Runtime } from '../../base';
import { ConfigModule } from '../../base/config'; import { ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service'; import { AuthService } from '../../core/auth/service';
import { EarlyAccessType, FeatureManagementService } from '../../core/features'; import { EarlyAccessType, FeatureService } from '../../core/features';
import { SubscriptionService } from '../../plugins/payment/service'; import { SubscriptionService } from '../../plugins/payment/service';
import { import {
CouponType, CouponType,
@@ -21,7 +20,7 @@ import {
SubscriptionStatus, SubscriptionStatus,
SubscriptionVariant, SubscriptionVariant,
} from '../../plugins/payment/types'; } from '../../plugins/payment/types';
import { createTestingApp, initTestingDB } from '../utils'; import { createTestingApp, type TestingApp } from '../utils';
const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`; const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`;
const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`; const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`;
@@ -156,10 +155,10 @@ const sub: Stripe.Subscription = {
const test = ava as TestFn<{ const test = ava as TestFn<{
u1: CurrentUser; u1: CurrentUser;
db: PrismaClient; db: PrismaClient;
app: INestApplication; app: TestingApp;
service: SubscriptionService; service: SubscriptionService;
event: Sinon.SinonStubbedInstance<EventBus>; event: Sinon.SinonStubbedInstance<EventBus>;
feature: Sinon.SinonStubbedInstance<FeatureManagementService>; feature: Sinon.SinonStubbedInstance<FeatureService>;
runtime: Sinon.SinonStubbedInstance<Runtime>; runtime: Sinon.SinonStubbedInstance<Runtime>;
stripe: { stripe: {
customers: Sinon.SinonStubbedInstance<Stripe.CustomersResource>; customers: Sinon.SinonStubbedInstance<Stripe.CustomersResource>;
@@ -200,8 +199,8 @@ test.before(async t => {
AppModule, AppModule,
], ],
tapModule: m => { tapModule: m => {
m.overrideProvider(FeatureManagementService).useValue( m.overrideProvider(FeatureService).useValue(
Sinon.createStubInstance(FeatureManagementService) Sinon.createStubInstance(FeatureService)
); );
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus)); m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime)); m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
@@ -210,7 +209,7 @@ test.before(async t => {
t.context.event = app.get(EventBus); t.context.event = app.get(EventBus);
t.context.service = app.get(SubscriptionService); t.context.service = app.get(SubscriptionService);
t.context.feature = app.get(FeatureManagementService); t.context.feature = app.get(FeatureService);
t.context.runtime = app.get(Runtime); t.context.runtime = app.get(Runtime);
t.context.db = app.get(PrismaClient); t.context.db = app.get(PrismaClient);
t.context.app = app; t.context.app = app;
@@ -232,7 +231,7 @@ test.before(async t => {
test.beforeEach(async t => { test.beforeEach(async t => {
const { db, app, stripe } = t.context; const { db, app, stripe } = t.context;
await initTestingDB(db); await t.context.app.initTestingDB();
t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1'); t.context.u1 = await app.get(AuthService).signUp('u1@affine.pro', '1');
await db.workspace.create({ await db.workspace.create({
@@ -1,212 +0,0 @@
/// <reference types="../global.d.ts" />
import { TestingModule } from '@nestjs/testing';
import type { TestFn } from 'ava';
import ava from 'ava';
import { AuthService } from '../core/auth';
import {
QuotaManagementService,
QuotaModule,
QuotaService,
QuotaType,
} from '../core/quota';
import { OneGB, OneMB } from '../core/quota/constant';
import { FreePlan, ProPlan } from '../core/quota/schema';
import { StorageModule, WorkspaceBlobStorage } from '../core/storage';
import { WorkspaceResolver } from '../core/workspaces/resolvers';
import { createTestingModule } from './utils';
import { WorkspaceResolverMock } from './utils/feature';
const test = ava as TestFn<{
auth: AuthService;
quota: QuotaService;
quotaManager: QuotaManagementService;
workspace: WorkspaceResolver;
workspaceBlob: WorkspaceBlobStorage;
module: TestingModule;
}>;
test.beforeEach(async t => {
const module = await createTestingModule({
imports: [StorageModule, QuotaModule],
providers: [WorkspaceResolver],
tapModule: module => {
module
.overrideProvider(WorkspaceResolver)
.useClass(WorkspaceResolverMock);
},
});
t.context.module = module;
t.context.auth = module.get(AuthService);
t.context.quota = module.get(QuotaService);
t.context.quotaManager = module.get(QuotaManagementService);
t.context.workspace = module.get(WorkspaceResolver);
t.context.workspaceBlob = module.get(WorkspaceBlobStorage);
});
test.afterEach.always(async t => {
await t.context.module.close();
});
test('should be able to set quota', async t => {
const { auth, quota } = t.context;
const u1 = await auth.signUp('test@affine.pro', '123456');
const q1 = await quota.getUserQuota(u1.id);
t.truthy(q1, 'should have quota');
t.is(q1?.feature.name, QuotaType.FreePlanV1, 'should be free plan');
t.is(q1?.feature.version, 4, 'should be version 4');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
const q2 = await quota.getUserQuota(u1.id);
t.is(q2?.feature.name, QuotaType.ProPlanV1, 'should be pro plan');
const fail = quota.switchUserQuota(u1.id, 'not_exists_plan_v1' as QuotaType);
await t.throwsAsync(fail, { instanceOf: Error }, 'should throw error');
});
test('should be able to check storage quota', async t => {
const { auth, quota, quotaManager } = t.context;
const u1 = await auth.signUp('test@affine.pro', '123456');
const freePlan = FreePlan.configs;
const proPlan = ProPlan.configs;
const q1 = await quotaManager.getUserQuota(u1.id);
t.is(q1?.blobLimit, freePlan.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, freePlan.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
const q2 = await quotaManager.getUserQuota(u1.id);
t.is(q2?.blobLimit, proPlan.blobLimit, 'should be pro plan');
t.is(q2?.storageQuota, proPlan.storageQuota, 'should be pro plan');
});
test('should be able revert quota', async t => {
const { auth, quota, quotaManager } = t.context;
const u1 = await auth.signUp('test@affine.pro', '123456');
const freePlan = FreePlan.configs;
const proPlan = ProPlan.configs;
const q1 = await quotaManager.getUserQuota(u1.id);
t.is(q1?.blobLimit, freePlan.blobLimit, 'should be free plan');
t.is(q1?.storageQuota, freePlan.storageQuota, 'should be free plan');
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
const q2 = await quotaManager.getUserQuota(u1.id);
t.is(q2?.blobLimit, proPlan.blobLimit, 'should be pro plan');
t.is(q2?.storageQuota, proPlan.storageQuota, 'should be pro plan');
t.is(
q2?.copilotActionLimit,
proPlan.copilotActionLimit!,
'should be pro plan'
);
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
const q3 = await quotaManager.getUserQuota(u1.id);
t.is(q3?.blobLimit, freePlan.blobLimit, 'should be free plan');
const quotas = await quota.getUserQuotas(u1.id);
t.is(quotas.length, 3, 'should have 3 quotas');
t.is(quotas[0].feature.name, QuotaType.FreePlanV1, 'should be free plan');
t.is(quotas[1].feature.name, QuotaType.ProPlanV1, 'should be pro plan');
t.is(quotas[2].feature.name, QuotaType.FreePlanV1, 'should be free plan');
t.is(quotas[0].activated, false, 'should be activated');
t.is(quotas[1].activated, false, 'should be activated');
t.is(quotas[2].activated, true, 'should be activated');
});
test('should be able to check quota', async t => {
const { auth, quotaManager } = t.context;
const u1 = await auth.signUp('test@affine.pro', '123456');
const freePlan = FreePlan.configs;
const q1 = await quotaManager.getUserQuota(u1.id);
t.assert(q1, 'should have quota');
t.is(q1.blobLimit, freePlan.blobLimit, 'should be free plan');
t.is(q1.storageQuota, freePlan.storageQuota, 'should be free plan');
t.is(q1.historyPeriod, freePlan.historyPeriod, 'should be free plan');
t.is(q1.memberLimit, freePlan.memberLimit, 'should be free plan');
t.is(
q1.copilotActionLimit!,
freePlan.copilotActionLimit!,
'should be free plan'
);
});
test('should be able to override quota', async t => {
const { auth, quotaManager, workspace } = t.context;
const u1 = await auth.signUp('test@affine.pro', '123456');
const w1 = await workspace.createWorkspace(u1, null);
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
t.is(wq1.blobLimit, 10 * OneMB, 'should be 10MB');
t.is(wq1.businessBlobLimit, 100 * OneMB, 'should be 100MB');
t.is(wq1.memberLimit, 3, 'should be 3');
await quotaManager.addTeamWorkspace(w1.id, 'test');
const wq2 = await quotaManager.getWorkspaceUsage(w1.id);
t.is(wq2.storageQuota, 120 * OneGB, 'should be override to 100GB');
t.is(wq2.businessBlobLimit, 500 * OneMB, 'should be override to 500MB');
t.is(wq2.memberLimit, 1, 'should be override to 1');
await quotaManager.updateWorkspaceConfig(w1.id, QuotaType.TeamPlanV1, {
memberLimit: 2,
});
const wq3 = await quotaManager.getWorkspaceUsage(w1.id);
t.is(wq3.storageQuota, 140 * OneGB, 'should be override to 120GB');
t.is(wq3.memberLimit, 2, 'should be override to 1');
});
test('should be able to check with workspace quota', async t => {
const { auth, quotaManager, workspace, workspaceBlob } = t.context;
const u1 = await auth.signUp('test@affine.pro', '123456');
const w1 = await workspace.createWorkspace(u1, null);
const w2 = await workspace.createWorkspace(u1, null);
const w3 = await workspace.createWorkspace(u1, null);
await quotaManager.addTeamWorkspace(w3.id, 'test');
{
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
t.is(wq1.usedSize, 0, 'should be 0');
const wq2 = await quotaManager.getWorkspaceUsage(w2.id);
t.is(wq2.usedSize, 0, 'should be 0');
const wq3 = await quotaManager.getWorkspaceUsage(w3.id);
t.is(wq3.usedSize, 0, 'should be 0');
}
{
await workspaceBlob.put(w1.id, 'test', Buffer.from([0, 0]));
await workspaceBlob.put(w2.id, 'test', Buffer.from([0, 0]));
// normal workspace
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
t.is(wq1.usedSize, 4, 'should share usage with w2');
const wq2 = await quotaManager.getWorkspaceUsage(w2.id);
t.is(wq2.usedSize, 4, 'should share usage with w1');
// workspace with quota
const wq3 = await quotaManager.getWorkspaceUsage(w3.id);
t.is(wq3.usedSize, 0, 'should not share usage with w1 and w2');
}
{
await workspaceBlob.put(w3.id, 'test', Buffer.from([0, 0, 0]));
// normal workspace
const wq1 = await quotaManager.getWorkspaceUsage(w1.id);
t.is(wq1.usedSize, 4, 'should not share usage with w3');
const wq2 = await quotaManager.getWorkspaceUsage(w2.id);
t.is(wq2.usedSize, 4, 'should not share usage with w3');
// workspace with quota
const wq3 = await quotaManager.getWorkspaceUsage(w3.id);
t.is(wq3.usedSize, 3, 'should not share usage with w1 and w2');
}
});
@@ -3,7 +3,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud'; import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud';
import { INestApplication } from '@nestjs/common';
import { WorkspaceMemberStatus } from '@prisma/client'; import { WorkspaceMemberStatus } from '@prisma/client';
import type { TestFn } from 'ava'; import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
@@ -14,8 +13,8 @@ import { EventBus } from '../base';
import { AuthService } from '../core/auth'; import { AuthService } from '../core/auth';
import { DocContentService } from '../core/doc-renderer'; import { DocContentService } from '../core/doc-renderer';
import { PermissionService, WorkspaceRole } from '../core/permission'; import { PermissionService, WorkspaceRole } from '../core/permission';
import { QuotaManagementService, QuotaService, QuotaType } from '../core/quota';
import { WorkspaceType } from '../core/workspaces'; import { WorkspaceType } from '../core/workspaces';
import { Models } from '../models';
import { import {
acceptInviteById, acceptInviteById,
approveMember, approveMember,
@@ -34,19 +33,19 @@ import {
revokeUser, revokeUser,
signUp, signUp,
sleep, sleep,
TestingApp,
UserAuthedType, UserAuthedType,
} from './utils'; } from './utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
app: INestApplication; app: TestingApp;
auth: AuthService; auth: AuthService;
event: Sinon.SinonStubbedInstance<EventBus>; event: Sinon.SinonStubbedInstance<EventBus>;
quota: QuotaService; models: Models;
quotaManager: QuotaManagementService;
permissions: PermissionService; permissions: PermissionService;
}>; }>;
test.beforeEach(async t => { test.before(async t => {
const { app } = await createTestingApp({ const { app } = await createTestingApp({
imports: [AppModule], imports: [AppModule],
tapModule: module => { tapModule: module => {
@@ -67,17 +66,20 @@ test.beforeEach(async t => {
t.context.app = app; t.context.app = app;
t.context.auth = app.get(AuthService); t.context.auth = app.get(AuthService);
t.context.event = app.get(EventBus); t.context.event = app.get(EventBus);
t.context.quota = app.get(QuotaService); t.context.models = app.get(Models);
t.context.quotaManager = app.get(QuotaManagementService);
t.context.permissions = app.get(PermissionService); t.context.permissions = app.get(PermissionService);
}); });
test.afterEach.always(async t => { test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
await t.context.app.close(); await t.context.app.close();
}); });
const init = async ( const init = async (
app: INestApplication, app: TestingApp,
memberLimit = 10, memberLimit = 10,
prefix = randomUUID() prefix = randomUUID()
) => { ) => {
@@ -87,17 +89,15 @@ const init = async (
`${prefix}owner@affine.pro`, `${prefix}owner@affine.pro`,
'123456' '123456'
); );
const models = app.get(Models);
{ {
const quota = app.get(QuotaService); await models.userFeature.add(owner.id, 'pro_plan_v1', 'test');
await quota.switchUserQuota(owner.id, QuotaType.ProPlanV1);
} }
const workspace = await createWorkspace(app, owner.token.token); const workspace = await createWorkspace(app, owner.token.token);
const teamWorkspace = await createWorkspace(app, owner.token.token); const teamWorkspace = await createWorkspace(app, owner.token.token);
{ {
const quota = app.get(QuotaManagementService); models.workspaceFeature.add(teamWorkspace.id, 'team_plan_v1', 'test', {
await quota.addTeamWorkspace(teamWorkspace.id, 'test');
await quota.updateWorkspaceConfig(teamWorkspace.id, QuotaType.TeamPlanV1, {
memberLimit, memberLimit,
}); });
} }
@@ -264,7 +264,7 @@ test('should be able to invite multiple users', async t => {
}); });
test('should be able to check seat limit', async t => { test('should be able to check seat limit', async t => {
const { app, permissions, quotaManager } = t.context; const { app, permissions, models } = t.context;
const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 4); const { invite, inviteBatch, teamWorkspace: ws } = await init(app, 4);
{ {
@@ -274,7 +274,7 @@ test('should be able to check seat limit', async t => {
{ message: 'You have exceeded your workspace member quota.' }, { message: 'You have exceeded your workspace member quota.' },
'should throw error if exceed member limit' 'should throw error if exceed member limit'
); );
await quotaManager.updateWorkspaceConfig(ws.id, QuotaType.TeamPlanV1, { models.workspaceFeature.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 5, memberLimit: 5,
}); });
await t.notThrowsAsync( await t.notThrowsAsync(
@@ -323,7 +323,7 @@ test('should be able to check seat limit', async t => {
test('should be able to grant team member permission', async t => { test('should be able to grant team member permission', async t => {
const { app, permissions } = t.context; const { app, permissions } = t.context;
const { owner, teamWorkspace: ws, admin, write, read } = await init(app); const { owner, teamWorkspace: ws, write, read } = await init(app);
await t.throwsAsync( await t.throwsAsync(
grantMember( grantMember(
@@ -350,13 +350,13 @@ test('should be able to grant team member permission', async t => {
await t.throwsAsync( await t.throwsAsync(
grantMember( grantMember(
app, app,
admin.token.token, write.token.token,
ws.id, ws.id,
read.id, read.id,
WorkspaceRole.Collaborator WorkspaceRole.Collaborator
), ),
{ instanceOf: Error }, { instanceOf: Error },
'should throw error if not owner' 'should throw error if not admin'
); );
{ {
@@ -571,7 +571,7 @@ test('should be able to approve team member', async t => {
}); });
test('should be able to invite by link', async t => { test('should be able to invite by link', async t => {
const { app, permissions, quotaManager } = t.context; const { app, permissions, models } = t.context;
const { const {
createInviteLink, createInviteLink,
owner, owner,
@@ -631,7 +631,7 @@ test('should be able to invite by link', async t => {
'should not change status' 'should not change status'
); );
await quotaManager.updateWorkspaceConfig(tws.id, QuotaType.TeamPlanV1, { models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 5, memberLimit: 5,
}); });
await permissions.refreshSeatStatus(tws.id, 5); await permissions.refreshSeatStatus(tws.id, 5);
@@ -646,7 +646,7 @@ test('should be able to invite by link', async t => {
'should not change status' 'should not change status'
); );
await quotaManager.updateWorkspaceConfig(tws.id, QuotaType.TeamPlanV1, { models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', {
memberLimit: 6, memberLimit: 6,
}); });
await permissions.refreshSeatStatus(tws.id, 6); await permissions.refreshSeatStatus(tws.id, 6);
@@ -1,20 +1,23 @@
import type { INestApplication } from '@nestjs/common';
import test from 'ava'; import test from 'ava';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { createTestingApp, currentUser, signUp } from './utils'; import { createTestingApp, currentUser, signUp, TestingApp } from './utils';
let app: INestApplication; let app: TestingApp;
test.beforeEach(async () => { test.before(async () => {
const { app: testApp } = await createTestingApp({ const { app: testApp } = await createTestingApp({
imports: [AppModule], imports: [AppModule],
}); });
app = testApp; app = testApp;
}); });
test.afterEach.always(async () => { test.beforeEach(async () => {
await app.initTestingDB();
});
test.after.always(async () => {
await app.close(); await app.close();
}); });
@@ -3,9 +3,13 @@ import {
INestApplication, INestApplication,
ModuleMetadata, ModuleMetadata,
} from '@nestjs/common'; } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD, ModuleRef } from '@nestjs/core';
import { Query, Resolver } from '@nestjs/graphql'; import { Query, Resolver } from '@nestjs/graphql';
import { Test, TestingModuleBuilder } from '@nestjs/testing'; import {
Test,
TestingModule as BaseTestingModule,
TestingModuleBuilder,
} from '@nestjs/testing';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
@@ -16,7 +20,7 @@ import { AppModule, FunctionalityModules } from '../../app.module';
import { GlobalExceptionFilter, Runtime } from '../../base'; import { GlobalExceptionFilter, Runtime } from '../../base';
import { GqlModule } from '../../base/graphql'; import { GqlModule } from '../../base/graphql';
import { AuthGuard, AuthModule } from '../../core/auth'; import { AuthGuard, AuthModule } from '../../core/auth';
import { UserFeaturesInit1698652531198 } from '../../data/migrations/1698652531198-user-features-init'; import { RefreshFeatures0001 } from '../../data/migrations/0001-refresh-features';
import { ModelsModule } from '../../models'; import { ModelsModule } from '../../models';
async function flushDB(client: PrismaClient) { async function flushDB(client: PrismaClient) {
@@ -35,20 +39,25 @@ async function flushDB(client: PrismaClient) {
); );
} }
async function initFeatureConfigs(db: PrismaClient) {
await UserFeaturesInit1698652531198.up(db);
}
export async function initTestingDB(db: PrismaClient) {
await flushDB(db);
await initFeatureConfigs(db);
}
interface TestingModuleMeatdata extends ModuleMetadata { interface TestingModuleMeatdata extends ModuleMetadata {
tapModule?(m: TestingModuleBuilder): void; tapModule?(m: TestingModuleBuilder): void;
tapApp?(app: INestApplication): void; tapApp?(app: INestApplication): void;
} }
const initTestingDB = async (ref: ModuleRef) => {
const db = ref.get(PrismaClient, { strict: false });
await flushDB(db);
await RefreshFeatures0001.up(db, ref);
};
export type TestingModule = BaseTestingModule & {
initTestingDB(): Promise<void>;
};
export type TestingApp = INestApplication & {
initTestingDB(): Promise<void>;
};
function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) { function dedupeModules(modules: NonNullable<ModuleMetadata['imports']>) {
const map = new Map(); const map = new Map();
@@ -73,7 +82,7 @@ class MockResolver {
export async function createTestingModule( export async function createTestingModule(
moduleDef: TestingModuleMeatdata = {}, moduleDef: TestingModuleMeatdata = {},
init = true autoInitialize = true
) { ) {
// setting up // setting up
let imports = moduleDef.imports ?? []; let imports = moduleDef.imports ?? [];
@@ -107,13 +116,9 @@ export async function createTestingModule(
const m = await builder.compile(); const m = await builder.compile();
const prisma = m.get(PrismaClient); const testingModule = m as TestingModule;
if (prisma instanceof PrismaClient) { testingModule.initTestingDB = async () => {
await initTestingDB(prisma); await initTestingDB(m.get(ModuleRef));
}
if (init) {
await m.init();
// we got a lot smoking tests try to break nestjs // we got a lot smoking tests try to break nestjs
// can't tolerate the noisy logs // can't tolerate the noisy logs
// @ts-expect-error private // @ts-expect-error private
@@ -123,9 +128,14 @@ export async function createTestingModule(
const runtime = m.get(Runtime); const runtime = m.get(Runtime);
// 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);
};
if (autoInitialize) {
await testingModule.initTestingDB();
await testingModule.init();
} }
return m; return testingModule;
} }
export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
@@ -135,7 +145,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
cors: true, cors: true,
bodyParser: true, bodyParser: true,
rawBody: true, rawBody: true,
}); }) as TestingApp;
const logger = new ConsoleLogger(); const logger = new ConsoleLogger();
logger.setLogLevels(['fatal']); logger.setLogLevels(['fatal']);
@@ -155,15 +165,14 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
moduleDef.tapApp(app); moduleDef.tapApp(app);
} }
await m.initTestingDB();
await app.init(); await app.init();
const runtime = app.get(Runtime); app.initTestingDB = m.initTestingDB.bind(m);
// by pass password min length validation
await runtime.set('auth/password.min', 1);
return { return {
module: m, module: m,
app, app: app,
}; };
} }
@@ -2,7 +2,6 @@ import {
getCurrentMailMessageCount, getCurrentMailMessageCount,
getLatestMailMessage, getLatestMailMessage,
} from '@affine-test/kit/utils/cloud'; } from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava'; import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
@@ -20,17 +19,18 @@ import {
leaveWorkspace, leaveWorkspace,
revokeUser, revokeUser,
signUp, signUp,
TestingApp,
} from './utils'; } from './utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
app: INestApplication; app: TestingApp;
client: PrismaClient; client: PrismaClient;
auth: AuthService; auth: AuthService;
mail: MailService; mail: MailService;
models: Models; models: Models;
}>; }>;
test.beforeEach(async t => { test.before(async t => {
const { app } = await createTestingApp({ const { app } = await createTestingApp({
imports: [AppModule], imports: [AppModule],
}); });
@@ -41,7 +41,11 @@ test.beforeEach(async t => {
t.context.models = app.get(Models); t.context.models = app.get(Models);
}); });
test.afterEach.always(async t => { test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
await t.context.app.close(); await t.context.app.close();
}); });
@@ -227,15 +231,12 @@ test('should support pagination for member', async t => {
test('should limit member count correctly', async t => { test('should limit member count correctly', async t => {
const { app } = t.context; const { app } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1'); const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
for (let i = 0; i < 10; i++) { const workspace = await createWorkspace(app, u1.token.token);
const workspace = await createWorkspace(app, u1.token.token); await Promise.allSettled(
await Promise.allSettled( Array.from({ length: 10 }).map(async (_, i) =>
Array.from({ length: 10 }).map(async (_, i) => inviteUser(app, u1.token.token, workspace.id, `u${i}@affine.pro`)
inviteUser(app, u1.token.token, workspace.id, `u${i}@affine.pro`) )
) );
); const ws = await getWorkspace(app, u1.token.token, workspace.id);
t.assert(ws.members.length <= 3, 'failed to check member list');
const ws = await getWorkspace(app, u1.token.token, workspace.id);
t.assert(ws.members.length <= 3, 'failed to check member list');
}
}); });
@@ -1,30 +1,28 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava'; import type { TestFn } from 'ava';
import ava from 'ava'; import ava from 'ava';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { WorkspaceRole } from '../core/permission/types';
import { import {
acceptInviteById, acceptInviteById,
createTestingApp, createTestingApp,
createWorkspace, createWorkspace,
getWorkspacePublicPages, getWorkspacePublicPages,
grantMember,
inviteUser, inviteUser,
publishPage, publishPage,
revokePublicPage, revokePublicPage,
signUp, signUp,
TestingApp,
updateWorkspace, updateWorkspace,
} from './utils'; } from './utils';
const test = ava as TestFn<{ const test = ava as TestFn<{
app: INestApplication; app: TestingApp;
client: PrismaClient; client: PrismaClient;
}>; }>;
test.beforeEach(async t => { test.before(async t => {
const { app } = await createTestingApp({ const { app } = await createTestingApp({
imports: [AppModule], imports: [AppModule],
}); });
@@ -33,7 +31,11 @@ test.beforeEach(async t => {
t.context.app = app; t.context.app = app;
}); });
test.afterEach.always(async t => { test.beforeEach(async t => {
await t.context.app.initTestingDB();
});
test.after.always(async t => {
await t.context.app.close(); await t.context.app.close();
}); });
@@ -132,30 +134,6 @@ test('should share a page', async t => {
'You do not have permission to access doc page2 under Space not_exists_ws.', 'You do not have permission to access doc page2 under Space not_exists_ws.',
'unauthorized user can share page' 'unauthorized user can share page'
); );
await acceptInviteById(
app,
workspace.id,
await inviteUser(app, u1.token.token, workspace.id, u2.email)
);
const msg3 = await publishPage(app, u2.token.token, workspace.id, 'page2');
t.is(
msg3,
`You do not have permission to access doc page2 under Space ${workspace.id}.`,
'WorkspaceRole and PageRole is lower than required'
);
await grantMember(
app,
u1.token.token,
workspace.id,
u2.id,
WorkspaceRole.Admin
);
const invited = await publishPage(app, u2.token.token, workspace.id, 'page2');
t.is(invited.id, 'page2', 'failed to share page');
const revoke = await revokePublicPage( const revoke = await revokePublicPage(
app, app,
u1.token.token, u1.token.token,
@@ -168,9 +146,7 @@ test('should share a page', async t => {
u1.token.token, u1.token.token,
workspace.id workspace.id
); );
t.is(pages2.length, 1, 'failed to get shared pages'); t.is(pages2.length, 0, 'failed to get shared pages');
t.is(pages2[0].id, 'page2', 'failed to get shared page: page2');
const msg4 = await revokePublicPage( const msg4 = await revokePublicPage(
app, app,
u1.token.token, u1.token.token,
@@ -179,19 +155,12 @@ test('should share a page', async t => {
); );
t.is(msg4, 'Page is not public'); t.is(msg4, 'Page is not public');
const revoked = await revokePublicPage( const pages3 = await getWorkspacePublicPages(
app,
u1.token.token,
workspace.id,
'page2'
);
t.false(revoked.public, 'failed to revoke page');
const page3 = await getWorkspacePublicPages(
app, app,
u1.token.token, u1.token.token,
workspace.id workspace.id
); );
t.is(page3.length, 0, 'failed to get shared pages'); t.is(pages3.length, 0, 'failed to get shared pages');
}); });
test('should be able to get workspace doc', async t => { test('should be able to get workspace doc', async t => {
@@ -1,10 +1,8 @@
import type { INestApplication } from '@nestjs/common';
import test from 'ava'; import test from 'ava';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../../app.module'; import { AppModule } from '../../app.module';
import { FeatureManagementService, FeatureType } from '../../core/features'; import { WorkspaceFeatureModel } from '../../models';
import { QuotaService, QuotaType } from '../../core/quota';
import { import {
collectAllBlobSizes, collectAllBlobSizes,
createTestingApp, createTestingApp,
@@ -13,25 +11,35 @@ import {
listBlobs, listBlobs,
setBlob, setBlob,
signUp, signUp,
TestingApp,
} from '../utils'; } from '../utils';
const OneMB = 1024 * 1024; const OneMB = 1024 * 1024;
const RESTRICTED_QUOTA = {
seatQuota: 0,
blobLimit: OneMB,
storageQuota: 2 * OneMB - 1,
historyPeriod: 1,
memberLimit: 1,
};
let app: INestApplication; let app: TestingApp;
let quota: QuotaService; let model: WorkspaceFeatureModel;
let feature: FeatureManagementService;
test.beforeEach(async () => { test.before(async () => {
const { app: testApp } = await createTestingApp({ const { app: testApp } = await createTestingApp({
imports: [AppModule], imports: [AppModule],
}); });
app = testApp; app = testApp;
quota = app.get(QuotaService); model = app.get(WorkspaceFeatureModel);
feature = app.get(FeatureManagementService);
}); });
test.afterEach.always(async () => { test.beforeEach(async () => {
await app.initTestingDB();
});
test.after.always(async () => {
await app.close(); await app.close();
}); });
@@ -119,32 +127,23 @@ test('should reject blob exceeded limit', async t => {
const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1'); const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1');
const workspace1 = await createWorkspace(app, u1.token.token); const workspace1 = await createWorkspace(app, u1.token.token);
await quota.switchUserQuota(u1.id, QuotaType.RestrictedPlanV1); await model.add(workspace1.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const buffer1 = Buffer.from(Array.from({ length: OneMB + 1 }, () => 0)); const buffer1 = Buffer.from(
Array.from({ length: RESTRICTED_QUOTA.blobLimit + 1 }, () => 0)
);
await t.throwsAsync(setBlob(app, u1.token.token, workspace1.id, buffer1)); await t.throwsAsync(setBlob(app, u1.token.token, workspace1.id, buffer1));
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
const buffer2 = Buffer.from(Array.from({ length: OneMB + 1 }, () => 0));
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace1.id, buffer2));
const buffer3 = Buffer.from(Array.from({ length: 100 * OneMB + 1 }, () => 0));
await t.throwsAsync(setBlob(app, u1.token.token, workspace1.id, buffer3));
}); });
test('should reject blob exceeded quota', async t => { test('should reject blob exceeded quota', async t => {
const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1'); const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token); const workspace = await createWorkspace(app, u1.token.token);
await quota.switchUserQuota(u1.id, QuotaType.RestrictedPlanV1); await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0)); const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
for (let i = 0; i < 10; i++) { await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
}
await t.throwsAsync(setBlob(app, u1.token.token, workspace.id, buffer)); await t.throwsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
}); });
@@ -152,14 +151,10 @@ test('should accept blob even storage out of quota if workspace has unlimited fe
const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1'); const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token); const workspace = await createWorkspace(app, u1.token.token);
await quota.switchUserQuota(u1.id, QuotaType.RestrictedPlanV1); await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
feature.addWorkspaceFeatures(workspace.id, FeatureType.UnlimitedWorkspace); await model.add(workspace.id, 'unlimited_workspace', 'test');
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0)); const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
for (let i = 0; i < 10; i++) {
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
}
await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer)); await t.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer));
}); });
@@ -478,6 +478,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'internal_server_error', type: 'internal_server_error',
message: 'Failed to store doc snapshot.', message: 'Failed to store doc snapshot.',
}, },
action_forbidden_on_non_team_workspace: {
type: 'action_forbidden',
message: 'A Team workspace is required to perform this action.',
},
// Subscription Errors // Subscription Errors
unsupported_subscription_plan: { unsupported_subscription_plan: {
@@ -399,6 +399,12 @@ export class FailedToUpsertSnapshot extends UserFriendlyError {
super('internal_server_error', 'failed_to_upsert_snapshot', message); super('internal_server_error', 'failed_to_upsert_snapshot', message);
} }
} }
export class ActionForbiddenOnNonTeamWorkspace extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'action_forbidden_on_non_team_workspace', message);
}
}
@ObjectType() @ObjectType()
class UnsupportedSubscriptionPlanDataType { class UnsupportedSubscriptionPlanDataType {
@Field() plan!: string @Field() plan!: string
@@ -754,6 +760,7 @@ export enum ErrorNames {
PAGE_IS_NOT_PUBLIC, PAGE_IS_NOT_PUBLIC,
FAILED_TO_SAVE_UPDATES, FAILED_TO_SAVE_UPDATES,
FAILED_TO_UPSERT_SNAPSHOT, FAILED_TO_UPSERT_SNAPSHOT,
ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE,
UNSUPPORTED_SUBSCRIPTION_PLAN, UNSUPPORTED_SUBSCRIPTION_PLAN,
FAILED_TO_CHECKOUT, FAILED_TO_CHECKOUT,
INVALID_CHECKOUT_PARAMETERS, INVALID_CHECKOUT_PARAMETERS,
+1 -7
View File
@@ -35,10 +35,4 @@ export { Runtime } from './runtime';
export * from './storage'; export * from './storage';
export { type StorageProvider, StorageProviderFactory } from './storage'; export { type StorageProvider, StorageProviderFactory } from './storage';
export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler'; export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler';
export { export * from './utils';
getRequestFromHost,
getRequestResponseFromContext,
getRequestResponseFromHost,
parseCookies,
} from './utils/request';
export * from './utils/types';
@@ -126,14 +126,19 @@ export class Runtime implements OnModuleInit {
V = FlattenedAppRuntimeConfig[K], V = FlattenedAppRuntimeConfig[K],
>(key: K, value: V) { >(key: K, value: V) {
validateConfigType(key, value); validateConfigType(key, value);
const config = await this.db.runtimeConfig.update({ const config = await this.db.runtimeConfig.upsert({
where: { where: {
id: key, id: key,
deletedAt: null, deletedAt: null,
}, },
data: { create: {
...defaultRuntimeConfig[key],
value: value as any, value: value as any,
}, },
update: {
value: value as any,
deletedAt: null,
},
}); });
await this.setCache(key, config.value as FlattenedAppRuntimeConfig[K]); await this.setCache(key, config.value as FlattenedAppRuntimeConfig[K]);
@@ -0,0 +1,4 @@
export * from './promise';
export * from './request';
export * from './types';
export * from './unit';
@@ -2,4 +2,3 @@ export const OneKB = 1024;
export const OneMB = OneKB * OneKB; export const OneMB = OneKB * OneKB;
export const OneGB = OneKB * OneMB; export const OneGB = OneKB * OneMB;
export const OneDay = 1000 * 60 * 60 * 24; export const OneDay = 1000 * 60 * 60 * 24;
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
@@ -4,9 +4,7 @@ import { assign, pick } from 'lodash-es';
import { Config, MailService, SignUpForbidden } from '../../base'; import { Config, MailService, SignUpForbidden } from '../../base';
import { Models, type User, type UserSession } from '../../models'; import { Models, type User, type UserSession } from '../../models';
import { FeatureManagementService } from '../features/management'; import { FeatureService } from '../features';
import { QuotaService } from '../quota/service';
import { QuotaType } from '../quota/types';
import type { CurrentUser } from './session'; import type { CurrentUser } from './session';
export function sessionUser( export function sessionUser(
@@ -45,8 +43,7 @@ export class AuthService implements OnApplicationBootstrap {
private readonly config: Config, private readonly config: Config,
private readonly models: Models, private readonly models: Models,
private readonly mailer: MailService, private readonly mailer: MailService,
private readonly feature: FeatureManagementService, private readonly feature: FeatureService
private readonly quota: QuotaService
) {} ) {}
async onApplicationBootstrap() { async onApplicationBootstrap() {
@@ -61,17 +58,24 @@ export class AuthService implements OnApplicationBootstrap {
password, password,
}); });
} }
await this.quota.switchUserQuota(devUser.id, QuotaType.ProPlanV1); await this.models.userFeature.add(
await this.feature.addAdmin(devUser.id); devUser.id,
await this.feature.addCopilot(devUser.id); 'administrator',
'dev user'
);
await this.models.userFeature.add(
devUser.id,
'unlimited_copilot',
'dev user'
);
} catch { } catch {
// ignore // ignore
} }
} }
} }
canSignIn(email: string) { async canSignIn(email: string) {
return this.feature.canEarlyAccess(email); return await this.feature.canEarlyAccess(email);
} }
/** /**
@@ -7,16 +7,16 @@ import { Injectable, UseGuards } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { ActionForbidden, getRequestResponseFromContext } from '../../base'; import { ActionForbidden, getRequestResponseFromContext } from '../../base';
import { FeatureManagementService } from '../features/management'; import { FeatureService } from '../features/service';
@Injectable() @Injectable()
export class AdminGuard implements CanActivate, OnModuleInit { export class AdminGuard implements CanActivate, OnModuleInit {
private feature!: FeatureManagementService; private feature!: FeatureService;
constructor(private readonly ref: ModuleRef) {} constructor(private readonly ref: ModuleRef) {}
onModuleInit() { onModuleInit() {
this.feature = this.ref.get(FeatureManagementService, { strict: false }); this.feature = this.ref.get(FeatureService, { strict: false });
} }
async canActivate(context: ExecutionContext) { async canActivate(context: ExecutionContext) {
@@ -13,10 +13,10 @@ import { RuntimeConfig, RuntimeConfigType } from '@prisma/client';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'; import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, Runtime, URLHelper } from '../../base'; import { Config, Runtime, URLHelper } from '../../base';
import { Feature } from '../../models';
import { Public } from '../auth'; import { Public } from '../auth';
import { Admin } from '../common'; import { Admin } from '../common';
import { FeatureType } from '../features'; import { AvailableUserFeatureConfig } from '../features';
import { AvailableUserFeatureConfig } from '../features/resolver';
import { ServerFlags } from './config'; import { ServerFlags } from './config';
import { ENABLED_FEATURES } from './server-feature'; import { ENABLED_FEATURES } from './server-feature';
import { ServerService } from './service'; import { ServerService } from './service';
@@ -139,11 +139,7 @@ export class ServerConfigResolver {
@Resolver(() => ServerConfigType) @Resolver(() => ServerConfigType)
export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig { export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
constructor(config: Config) { @ResolveField(() => [Feature], {
super(config);
}
@ResolveField(() => [FeatureType], {
description: 'Features for user that can be configured', description: 'Features for user that can be configured',
}) })
override availableUserFeatures() { override availableUserFeatures() {
@@ -89,7 +89,7 @@ export class DocStorageOptions implements IDocStorageOptions {
historyMaxAge = async (spaceId: string) => { historyMaxAge = async (spaceId: string) => {
const owner = await this.permission.getWorkspaceOwner(spaceId); const owner = await this.permission.getWorkspaceOwner(spaceId);
const quota = await this.quota.getUserQuota(owner.id); const quota = await this.quota.getUserQuota(owner.id);
return quota.feature.historyPeriod; return quota.historyPeriod;
}; };
historyMinInterval = (_spaceId: string) => { historyMinInterval = (_spaceId: string) => {
@@ -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 { Module } from '@nestjs/common';
import { UserModule } from '../user';
import { EarlyAccessType, FeatureManagementService } from './management';
import { import {
AdminFeatureManagementResolver, AdminFeatureManagementResolver,
FeatureManagementResolver, UserFeatureResolver,
} from './resolver'; } 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({ @Module({
imports: [UserModule],
providers: [ providers: [
FeatureService, UserFeatureResolver,
FeatureManagementService,
FeatureManagementResolver,
AdminFeatureManagementResolver, AdminFeatureManagementResolver,
FeatureService,
], ],
exports: [FeatureService, FeatureManagementService], exports: [FeatureService],
}) })
export class FeatureModule {} export class FeatureModule {}
export type { FeatureConfigType } from './feature'; export { EarlyAccessType, FeatureService };
export { export { AvailableUserFeatureConfig } from './types';
type CommonFeature,
commonFeatureSchema,
type FeatureConfig,
FeatureKind,
Features,
FeatureType,
} from './types';
export { EarlyAccessType, FeatureManagementService, FeatureService };
@@ -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'; } from '@nestjs/graphql';
import { difference } from 'lodash-es'; import { difference } from 'lodash-es';
import { Config } from '../../base'; import {
Feature,
Models,
type UserFeatureName,
type WorkspaceFeatureName,
} from '../../models';
import { Admin } from '../common'; import { Admin } from '../common';
import { UserType } from '../user/types'; import { UserType } from '../user/types';
import { EarlyAccessType, FeatureManagementService } from './management'; import { AvailableUserFeatureConfig } from './types';
import { FeatureService } from './service';
import { FeatureType } from './types';
registerEnumType(EarlyAccessType, { registerEnumType(Feature, {
name: 'EarlyAccessType', name: 'FeatureType',
}); });
@Resolver(() => UserType) @Resolver(() => UserType)
export class FeatureManagementResolver { export class UserFeatureResolver extends AvailableUserFeatureConfig {
constructor(private readonly feature: FeatureManagementService) {} constructor(private readonly models: Models) {
super();
}
@ResolveField(() => [FeatureType], { @ResolveField(() => [Feature], {
name: 'features', name: 'features',
description: 'Enabled features of a user', description: 'Enabled features of a user',
}) })
async userFeatures(@Parent() user: UserType) { async userFeatures(@Parent() user: UserType) {
return this.feature.getActivatedUserFeatures(user.id); const features = await this.models.userFeature.list(user.id);
} const availableUserFeatures = this.availableUserFeatures();
} return features.filter(feature => availableUserFeatures.has(feature));
export class AvailableUserFeatureConfig {
constructor(private readonly config: Config) {}
async availableUserFeatures() {
return this.config.isSelfhosted
? [FeatureType.Admin, FeatureType.UnlimitedCopilot]
: [FeatureType.EarlyAccess, FeatureType.AIEarlyAccess, FeatureType.Admin];
} }
} }
@Admin() @Admin()
@Resolver(() => Boolean) @Resolver(() => Boolean)
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig { export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
constructor( constructor(private readonly models: Models) {
config: Config, super();
private readonly feature: FeatureService
) {
super(config);
} }
@Mutation(() => [FeatureType], { @Mutation(() => [Feature], {
description: 'update user enabled feature', description: 'update user enabled feature',
}) })
async updateUserFeatures( async updateUserFeatures(
@Args('id') id: string, @Args('id') id: string,
@Args({ name: 'features', type: () => [FeatureType] }) @Args({ name: 'features', type: () => [Feature] })
features: FeatureType[] 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( await Promise.all(
features.map(feature => features.map(async feature => {
this.feature.addUserFeature(id, feature, 'admin panel') if (configurableUserFeatures.has(feature)) {
) return this.models.userFeature.add(id, feature, 'admin panel');
} else {
return;
}
})
); );
await Promise.all( await Promise.all(
removed.map(feature => this.feature.removeUserFeature(id, feature)) removed.map(feature => this.models.userFeature.remove(id, feature))
); );
return features; 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 { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { CannotDeleteAllAdminAccount } from '../../base'; import { Runtime } from '../../base';
import { WorkspaceFeatureType } from '../workspaces/types'; import { Models } from '../../models';
import { FeatureConfigType, getFeature } from './feature';
import { FeatureKind, FeatureType } from './types'; const STAFF = ['@toeverything.info', '@affine.pro'];
export enum EarlyAccessType {
App = 'app',
AI = 'ai',
}
@Injectable() @Injectable()
export class FeatureService { export class FeatureService {
constructor(private readonly prisma: PrismaClient) {} protected logger = new Logger(FeatureService.name);
async getFeature<F extends FeatureType>(feature: F) { constructor(
const data = await this.prisma.feature.findFirst({ private readonly models: Models,
where: { feature, type: FeatureKind.Feature }, private readonly runtime: Runtime
select: { id: true }, ) {}
orderBy: { version: 'desc' },
});
if (data) { // ======== Admin ========
return getFeature(this.prisma, data.id) as Promise<FeatureConfigType<F>>; 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, userId: string,
feature: FeatureType, type: EarlyAccessType = EarlyAccessType.App
reason: string,
expiredAt?: Date | string
) { ) {
return this.prisma.$transaction(async tx => { return this.models.userFeature.add(
const latestFlag = await tx.userFeature.findFirst({ userId,
where: { type === EarlyAccessType.App ? 'early_access' : 'ai_early_access',
userId, 'Early access user'
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 configs.filter(feature => !!feature.feature);
} }
async getUserActivatedFeatures(userId: string) { async removeEarlyAccess(
const features = await this.prisma.userFeature.findMany({ userId: string,
where: { type: EarlyAccessType = EarlyAccessType.App
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
) { ) {
return this.prisma.$transaction(async tx => { return this.models.userFeature.remove(
const latestFlag = await tx.workspaceFeature.findFirst({ userId,
where: { type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
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);
}
});
} }
async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) { async isEarlyAccessUser(
return this.prisma.workspaceFeature userId: string,
.updateMany({ type: EarlyAccessType = EarlyAccessType.App
where: { ) {
workspaceId, return await this.models.userFeature.has(
feature: { userId,
feature, type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
type: FeatureKind.Feature, );
},
activated: true,
},
data: {
activated: false,
},
})
.then(r => r.count);
} }
/** async canEarlyAccess(
* get workspace's features, will included inactivated features email: string,
* @param workspaceId workspace id type: EarlyAccessType = EarlyAccessType.App
* @returns list of features ) {
*/ const earlyAccessControlEnabled = await this.runtime.fetch(
async getWorkspaceFeatures(workspaceId: string) { 'flags/earlyAccessControl'
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),
}))
); );
return configs.filter(feature => !!feature.feature); if (earlyAccessControlEnabled && !this.isStaff(email)) {
} const user = await this.models.user.getUserByEmail(email);
if (!user) {
async listWorkspacesByFeature( return false;
feature: FeatureType }
): Promise<WorkspaceFeatureType[]> { return this.isEarlyAccessUser(user.id, type);
return this.prisma.workspaceFeature } else {
.findMany({ return true;
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);
} }
} }
@@ -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({}),
});
@@ -1,11 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FeatureModule } from '../features';
import { PermissionModule } from '../permission'; import { PermissionModule } from '../permission';
import { StorageModule } from '../storage'; import { StorageModule } from '../storage';
import { QuotaManagementResolver } from './resolver'; import { QuotaResolver } from './resolver';
import { QuotaService } from './service'; import { QuotaService } from './service';
import { QuotaManagementService } from './storage';
/** /**
* Quota module provider pre-user quota management. * Quota module provider pre-user quota management.
@@ -14,18 +12,11 @@ import { QuotaManagementService } from './storage';
* - quota statistics * - quota statistics
*/ */
@Module({ @Module({
imports: [FeatureModule, StorageModule, PermissionModule], imports: [StorageModule, PermissionModule],
providers: [QuotaService, QuotaManagementResolver, QuotaManagementService], providers: [QuotaService, QuotaResolver],
exports: [QuotaService, QuotaManagementService], exports: [QuotaService],
}) })
export class QuotaModule {} export class QuotaModule {}
export { QuotaManagementService, QuotaService }; export { QuotaService };
export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema'; export { WorkspaceQuotaHumanReadableType, WorkspaceQuotaType } from './types';
export {
formatDate,
formatSize,
type QuotaBusinessType,
QuotaQueryType,
QuotaType,
} from './types';
@@ -1,146 +0,0 @@
import { pick } from 'lodash-es';
import { PrismaTransaction } from '../../base';
import { formatDate, formatSize, Quota, QuotaSchema } from './types';
const QuotaCache = new Map<number, QuotaConfig>();
export class QuotaConfig {
readonly config: Quota;
readonly override?: Partial<Quota['configs']>;
static async get(tx: PrismaTransaction, featureId: number) {
const cachedQuota = QuotaCache.get(featureId);
if (cachedQuota) {
return cachedQuota;
}
const quota = await tx.feature.findFirst({
where: {
id: featureId,
},
});
if (!quota) {
throw new Error(`Quota config ${featureId} not found`);
}
const config = new QuotaConfig(quota);
// we always edit quota config as a new quota config
// so we can cache it by featureId
QuotaCache.set(featureId, config);
return config;
}
private constructor(data: any, override?: any) {
const config = QuotaSchema.safeParse(data);
if (config.success) {
this.config = config.data;
} else {
throw new Error(
`Invalid quota config: ${config.error.message}, ${JSON.stringify(
data
)})}`
);
}
if (override) {
const overrideConfig = QuotaSchema.safeParse({
...config.data,
configs: Object.assign({}, config.data.configs, override),
});
if (overrideConfig.success) {
this.override = pick(
overrideConfig.data.configs,
Object.keys(override)
);
} else {
throw new Error(
`Invalid quota override config: ${override.error.message}, ${JSON.stringify(
data
)})}`
);
}
}
}
withOverride(override: any) {
if (override) {
return new QuotaConfig(
this.config,
Object.assign({}, this.override, override)
);
}
return this;
}
checkOverride(override: any) {
return QuotaSchema.safeParse({
...this.config,
configs: Object.assign({}, this.config.configs, override),
});
}
get version() {
return this.config.version;
}
/// feature name of quota
get name() {
return this.config.feature;
}
get blobLimit() {
return this.override?.blobLimit || this.config.configs.blobLimit;
}
get businessBlobLimit() {
return (
this.override?.businessBlobLimit ||
this.config.configs.businessBlobLimit ||
this.override?.blobLimit ||
this.config.configs.blobLimit
);
}
private get additionalQuota() {
const seatQuota =
this.override?.seatQuota || this.config.configs.seatQuota || 0;
return this.memberLimit * seatQuota;
}
get storageQuota() {
const baseQuota =
this.override?.storageQuota || this.config.configs.storageQuota;
return baseQuota + this.additionalQuota;
}
get historyPeriod() {
return this.override?.historyPeriod || this.config.configs.historyPeriod;
}
get memberLimit() {
return this.override?.memberLimit || this.config.configs.memberLimit;
}
get copilotActionLimit() {
if ('copilotActionLimit' in this.config.configs) {
return this.config.configs.copilotActionLimit || undefined;
}
return undefined;
}
get humanReadable() {
return {
name: this.config.configs.name,
blobLimit: formatSize(this.blobLimit),
storageQuota: formatSize(this.storageQuota),
historyPeriod: formatDate(this.historyPeriod),
memberLimit: this.memberLimit.toString(),
copilotActionLimit: this.copilotActionLimit
? `${this.copilotActionLimit} times`
: 'Unlimited',
};
}
}
@@ -1,86 +1,29 @@
import { import { ResolveField, Resolver } from '@nestjs/graphql';
Field,
ObjectType,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { CurrentUser } from '../auth/session'; import { CurrentUser } from '../auth/session';
import { EarlyAccessType } from '../features';
import { UserType } from '../user'; import { UserType } from '../user';
import { QuotaService } from './service'; import { QuotaService } from './service';
import { QuotaManagementService } from './storage'; import { UserQuotaType, UserQuotaUsageType } from './types';
registerEnumType(EarlyAccessType, {
name: 'EarlyAccessType',
});
@ObjectType('UserQuotaHumanReadable')
class UserQuotaHumanReadableType {
@Field({ name: 'name' })
name!: string;
@Field({ name: 'blobLimit' })
blobLimit!: string;
@Field({ name: 'storageQuota' })
storageQuota!: string;
@Field({ name: 'historyPeriod' })
historyPeriod!: string;
@Field({ name: 'memberLimit' })
memberLimit!: string;
}
@ObjectType('UserQuota')
class UserQuotaType {
@Field({ name: 'name' })
name!: string;
@Field(() => SafeIntResolver, { name: 'blobLimit' })
blobLimit!: number;
@Field(() => SafeIntResolver, { name: 'storageQuota' })
storageQuota!: number;
@Field(() => SafeIntResolver, { name: 'historyPeriod' })
historyPeriod!: number;
@Field({ name: 'memberLimit' })
memberLimit!: number;
@Field({ name: 'humanReadable' })
humanReadable!: UserQuotaHumanReadableType;
}
@ObjectType('UserQuotaUsage')
class UserQuotaUsageType {
@Field(() => SafeIntResolver, { name: 'storageQuota' })
storageQuota!: number;
}
@Resolver(() => UserType) @Resolver(() => UserType)
export class QuotaManagementResolver { export class QuotaResolver {
constructor( constructor(private readonly quota: QuotaService) {}
private readonly quota: QuotaService,
private readonly management: QuotaManagementService
) {}
@ResolveField(() => UserQuotaType, { name: 'quota', nullable: true }) @ResolveField(() => UserQuotaType, { name: 'quota' })
async getQuota(@CurrentUser() me: UserType) { async getQuota(@CurrentUser() me: UserType): Promise<UserQuotaType> {
const quota = await this.quota.getUserQuota(me.id); const quota = await this.quota.getUserQuotaWithUsage(me.id);
return quota.feature; return {
...quota,
humanReadable: this.quota.formatUserQuota(quota),
};
} }
@ResolveField(() => UserQuotaUsageType, { name: 'quotaUsage' }) @ResolveField(() => UserQuotaUsageType, { name: 'quotaUsage' })
async getQuotaUsage( async getQuotaUsage(
@CurrentUser() me: UserType @CurrentUser() me: UserType
): Promise<UserQuotaUsageType> { ): Promise<UserQuotaUsageType> {
const usage = await this.management.getUserStorageUsage(me.id); const usage = await this.quota.getUserStorageUsage(me.id);
return { return {
storageQuota: usage, storageQuota: usage,
@@ -1,216 +0,0 @@
import { FeatureKind } from '../features/types';
import { OneDay, OneGB, OneMB } from './constant';
import { Quota, QuotaType } from './types';
export const Quotas: Quota[] = [
{
feature: QuotaType.FreePlanV1,
type: FeatureKind.Quota,
version: 1,
configs: {
// quota name
name: 'Free',
// single blob limit 10MB
blobLimit: 10 * OneMB,
// total blob limit 10GB
storageQuota: 10 * OneGB,
// history period of validity 7 days
historyPeriod: 7 * OneDay,
// member limit 3
memberLimit: 3,
},
},
{
feature: QuotaType.ProPlanV1,
type: FeatureKind.Quota,
version: 1,
configs: {
// quota name
name: 'Pro',
// single blob limit 100MB
blobLimit: 100 * OneMB,
// total blob limit 100GB
storageQuota: 100 * OneGB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
},
},
{
feature: QuotaType.RestrictedPlanV1,
type: FeatureKind.Quota,
version: 1,
configs: {
// quota name
name: 'Restricted',
// single blob limit 10MB
blobLimit: OneMB,
// total blob limit 1GB
storageQuota: 10 * OneMB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
},
},
{
feature: QuotaType.FreePlanV1,
type: FeatureKind.Quota,
version: 2,
configs: {
// quota name
name: 'Free',
// single blob limit 10MB
blobLimit: 100 * OneMB,
// total blob limit 10GB
storageQuota: 10 * OneGB,
// history period of validity 7 days
historyPeriod: 7 * OneDay,
// member limit 3
memberLimit: 3,
},
},
{
feature: QuotaType.FreePlanV1,
type: FeatureKind.Quota,
version: 3,
configs: {
// quota name
name: 'Free',
// single blob limit 10MB
blobLimit: 10 * OneMB,
// server limit will larger then client to handle a edge case:
// when a user downgrades from pro to free, he can still continue
// to upload previously added files that exceed the free limit
// NOTE: this is a product decision, may change in future
businessBlobLimit: 100 * OneMB,
// total blob limit 10GB
storageQuota: 10 * OneGB,
// history period of validity 7 days
historyPeriod: 7 * OneDay,
// member limit 3
memberLimit: 3,
},
},
{
feature: QuotaType.FreePlanV1,
type: FeatureKind.Quota,
version: 4,
configs: {
// quota name
name: 'Free',
// single blob limit 10MB
blobLimit: 10 * OneMB,
// server limit will larger then client to handle a edge case:
// when a user downgrades from pro to free, he can still continue
// to upload previously added files that exceed the free limit
// NOTE: this is a product decision, may change in future
businessBlobLimit: 100 * OneMB,
// total blob limit 10GB
storageQuota: 10 * OneGB,
// history period of validity 7 days
historyPeriod: 7 * OneDay,
// member limit 3
memberLimit: 3,
// copilot action limit 10
copilotActionLimit: 10,
},
},
{
feature: QuotaType.ProPlanV1,
type: FeatureKind.Quota,
version: 2,
configs: {
// quota name
name: 'Pro',
// single blob limit 100MB
blobLimit: 100 * OneMB,
// total blob limit 100GB
storageQuota: 100 * OneGB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
// copilot action limit 10
copilotActionLimit: 10,
},
},
{
feature: QuotaType.RestrictedPlanV1,
type: FeatureKind.Quota,
version: 2,
configs: {
// quota name
name: 'Restricted',
// single blob limit 1MB
blobLimit: OneMB,
// total blob limit 10MB
storageQuota: 10 * OneMB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
// copilot action limit 10
copilotActionLimit: 10,
},
},
{
feature: QuotaType.LifetimeProPlanV1,
type: FeatureKind.Quota,
version: 1,
configs: {
// quota name
name: 'Lifetime Pro',
// single blob limit 100MB
blobLimit: 100 * OneMB,
// total blob limit 1TB
storageQuota: 1024 * OneGB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
// copilot action limit 10
copilotActionLimit: 10,
},
},
{
feature: QuotaType.TeamPlanV1,
type: FeatureKind.Quota,
version: 1,
configs: {
// quota name
name: 'Team Workspace',
// single blob limit 100MB
blobLimit: 500 * OneMB,
// total blob limit 100GB
storageQuota: 100 * OneGB,
// seat quota 20GB per seat
seatQuota: 20 * OneGB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 1, override by workspace config
memberLimit: 1,
},
},
];
export function getLatestQuota<Q extends QuotaType>(type: Q): Quota<Q> {
const quota = Quotas.filter(f => f.feature === type);
quota.sort((a, b) => b.version - a.version);
return quota[0] as Quota<Q>;
}
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
export const LifetimeProPlan = getLatestQuota(QuotaType.LifetimeProPlanV1);
export const Quota_FreePlanV1_1 = {
feature: Quotas[5].feature,
version: Quotas[5].version,
};
export const Quota_ProPlanV1 = {
feature: Quotas[6].feature,
version: Quotas[6].version,
};
+251 -311
View File
@@ -1,329 +1,269 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaTransaction } from '../../base'; import { InternalServerError, MemberQuotaExceeded, OnEvent } from '../../base';
import { FeatureKind } from '../features/types'; import {
import { QuotaConfig } from './quota'; Models,
import { QuotaType } from './types'; type UserQuota,
WorkspaceQuota as BaseWorkspaceQuota,
} from '../../models';
import { PermissionService } from '../permission';
import { WorkspaceBlobStorage } from '../storage';
import {
UserQuotaHumanReadableType,
UserQuotaType,
WorkspaceQuotaHumanReadableType,
WorkspaceQuotaType,
} from './types';
import { formatDate, formatSize } from './utils';
type UserQuotaWithUsage = Omit<UserQuotaType, 'humanReadable'>;
type WorkspaceQuota = Omit<BaseWorkspaceQuota, 'seatQuota'> & {
ownerQuota?: string;
};
type WorkspaceQuotaWithUsage = Omit<WorkspaceQuotaType, 'humanReadable'>;
@Injectable() @Injectable()
export class QuotaService { export class QuotaService {
constructor(private readonly prisma: PrismaClient) {} protected logger = new Logger(QuotaService.name);
async getQuota<Q extends QuotaType>( constructor(
quota: Q, private readonly models: Models,
tx?: PrismaTransaction private readonly permissions: PermissionService,
): Promise<QuotaConfig | undefined> { private readonly storage: WorkspaceBlobStorage
const executor = tx ?? this.prisma; ) {}
const data = await executor.feature.findFirst({ @OnEvent('user.postCreated')
where: { feature: quota, type: FeatureKind.Quota }, async onUserCreated({ id }: Events['user.postCreated']) {
select: { id: true }, await this.setupUserBaseQuota(id);
orderBy: { version: 'desc' },
});
if (data) {
return QuotaConfig.get(this.prisma, data.id);
}
return undefined;
} }
// ======== User Quota ======== async getUserQuota(userId: string): Promise<UserQuota> {
let quota = await this.models.userFeature.getQuota(userId);
// get activated user quota
async getUserQuota(userId: string) {
const quota = await this.prisma.userFeature.findFirst({
where: {
userId,
feature: { type: FeatureKind.Quota },
activated: true,
},
select: {
reason: true,
createdAt: true,
expiredAt: true,
featureId: true,
},
});
// not possible, but just in case, we do a little fix for user to avoid system dump
if (!quota) { if (!quota) {
// this should unreachable await this.setupUserBaseQuota(userId);
throw new Error(`User ${userId} has no quota`); quota = await this.models.userFeature.getQuota(userId);
} }
const feature = await QuotaConfig.get(this.prisma, quota.featureId); const unlimitedCopilot = await this.models.userFeature.has(
return { ...quota, feature }; userId,
} 'unlimited_copilot'
// get user all quota records
async getUserQuotas(userId: string) {
const quotas = await this.prisma.userFeature.findMany({
where: {
userId,
feature: { type: FeatureKind.Quota },
},
select: {
activated: true,
reason: true,
createdAt: true,
expiredAt: true,
featureId: true,
},
orderBy: { id: 'asc' },
});
const configs = await Promise.all(
quotas.map(async quota => {
try {
return {
...quota,
feature: await QuotaConfig.get(this.prisma, quota.featureId),
};
} catch {}
return null as unknown as typeof quota & {
feature: QuotaConfig;
};
})
); );
return configs.filter(quota => !!quota); if (!quota) {
} throw new InternalServerError(
'User quota not found and can not be created.'
// switch user to a new quota
// currently each user can only have one quota
async switchUserQuota(
userId: string,
quota: QuotaType,
reason?: string,
expiredAt?: Date
) {
await this.prisma.$transaction(async tx => {
const hasSameActivatedQuota = await this.hasUserQuota(userId, quota, tx);
if (hasSameActivatedQuota) return; // don't need to switch
const featureId = await tx.feature
.findFirst({
where: { feature: quota, type: FeatureKind.Quota },
select: { id: true },
orderBy: { version: 'desc' },
})
.then(f => f?.id);
if (!featureId) {
throw new Error(`Quota ${quota} not found`);
}
// we will deactivate all exists quota for this user
await tx.userFeature.updateMany({
where: {
id: undefined,
userId,
feature: {
type: FeatureKind.Quota,
},
},
data: {
activated: false,
},
});
await tx.userFeature.create({
data: {
userId,
featureId,
reason: reason ?? 'switch quota',
activated: true,
expiredAt,
},
});
});
}
async hasUserQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
const executor = tx ?? this.prisma;
return executor.userFeature
.count({
where: {
userId,
feature: {
feature: quota,
type: FeatureKind.Quota,
},
activated: true,
},
})
.then(count => count > 0);
}
// ======== Workspace Quota ========
// get activated workspace quota
async getWorkspaceQuota(workspaceId: string) {
const quota = await this.prisma.workspaceFeature.findFirst({
where: {
workspaceId,
feature: { type: FeatureKind.Quota },
activated: true,
},
select: {
configs: true,
reason: true,
createdAt: true,
expiredAt: true,
featureId: true,
},
});
if (quota) {
const feature = await QuotaConfig.get(this.prisma, quota.featureId);
const { configs, ...rest } = quota;
return { ...rest, feature: feature.withOverride(configs) };
}
return null;
}
// switch user to a new quota
// currently each user can only have one quota
async switchWorkspaceQuota(
workspaceId: string,
quota: QuotaType,
reason?: string,
expiredAt?: Date
) {
await this.prisma.$transaction(async tx => {
const hasSameActivatedQuota = await this.hasWorkspaceQuota(
workspaceId,
quota,
tx
);
if (hasSameActivatedQuota) return; // don't need to switch
const featureId = await tx.feature
.findFirst({
where: { feature: quota, type: FeatureKind.Quota },
select: { id: true },
orderBy: { version: 'desc' },
})
.then(f => f?.id);
if (!featureId) {
throw new Error(`Quota ${quota} not found`);
}
// we will deactivate all exists quota for this workspace
await this.deactivateWorkspaceQuota(workspaceId, undefined, tx);
await tx.workspaceFeature.create({
data: {
workspaceId,
featureId,
reason: reason ?? 'switch quota',
activated: true,
expiredAt,
},
});
});
}
async deactivateWorkspaceQuota(
workspaceId: string,
quota?: QuotaType,
tx?: PrismaTransaction
) {
const executor = tx ?? this.prisma;
await executor.workspaceFeature.updateMany({
where: {
id: undefined,
workspaceId,
feature: quota
? { feature: quota, type: FeatureKind.Quota }
: { type: FeatureKind.Quota },
},
data: { activated: false },
});
}
async hasWorkspaceQuota(
workspaceId: string,
quota: QuotaType,
tx?: PrismaTransaction
) {
const executor = tx ?? this.prisma;
return executor.workspaceFeature
.count({
where: {
workspaceId,
feature: {
feature: quota,
type: FeatureKind.Quota,
},
activated: true,
},
})
.then(count => count > 0);
}
/// check if workspaces have quota
/// return workspaces's id that have quota
async hasWorkspacesQuota(
workspaces: string[],
quota?: QuotaType
): Promise<string[]> {
const workspaceIds = await this.prisma.workspaceFeature.findMany({
where: {
workspaceId: { in: workspaces },
feature: { feature: quota, type: FeatureKind.Quota },
activated: true,
},
select: { workspaceId: true },
});
return Array.from(new Set(workspaceIds.map(w => w.workspaceId)));
}
async getWorkspaceConfig<Q extends QuotaType>(
workspaceId: string,
type: Q
): Promise<QuotaConfig | undefined> {
const quota = await this.getQuota(type);
if (quota) {
const configs = await this.prisma.workspaceFeature
.findFirst({
where: {
workspaceId,
feature: { feature: type, type: FeatureKind.Quota },
activated: true,
},
select: { configs: true },
})
.then(q => q?.configs);
return quota.withOverride(configs);
}
return undefined;
}
async updateWorkspaceConfig(
workspaceId: string,
quota: QuotaType,
configs: any
) {
const current = await this.getWorkspaceConfig(workspaceId, quota);
const ret = current?.checkOverride(configs);
if (!ret || !ret.success) {
throw new Error(
`Invalid quota config: ${ret?.error.message || 'quota not defined'}`
); );
} }
const r = await this.prisma.workspaceFeature.updateMany({
where: { return {
workspaceId, ...quota.configs,
feature: { feature: quota, type: FeatureKind.Quota }, copilotActionLimit: unlimitedCopilot
activated: true, ? undefined
}, : quota.configs.copilotActionLimit,
data: { configs }, } as UserQuotaWithUsage;
}); }
return r.count;
async getUserQuotaWithUsage(userId: string): Promise<UserQuotaWithUsage> {
const quota = await this.getUserQuota(userId);
const usedStorageQuota = await this.getUserStorageUsage(userId);
return { ...quota, usedStorageQuota };
}
async getUserStorageUsage(userId: string) {
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
const workspacesWithQuota =
await this.models.workspaceFeature.batchHasQuota(workspaces);
const sizes = await Promise.allSettled(
workspaces
.filter(w => !workspacesWithQuota.includes(w))
.map(workspace => this.storage.totalSize(workspace))
);
return sizes.reduce((total, size) => {
if (size.status === 'fulfilled') {
// ensure that size is within the safe range of gql
const totalSize = total + size.value;
if (Number.isSafeInteger(totalSize)) {
return totalSize;
} else {
this.logger.error(`Workspace size is invalid: ${size.value}`);
}
} else {
this.logger.error(`Failed to get workspace size: ${size.reason}`);
}
return total;
}, 0);
}
async getWorkspaceStorageUsage(workspaceId: string) {
const totalSize = await this.storage.totalSize(workspaceId);
// ensure that size is within the safe range of gql
if (Number.isSafeInteger(totalSize)) {
return totalSize;
} else {
this.logger.error(`Workspace size is invalid: ${totalSize}`);
}
return 0;
}
async getWorkspaceQuota(workspaceId: string): Promise<WorkspaceQuota> {
const quota = await this.models.workspaceFeature.getQuota(workspaceId);
if (!quota) {
// get and convert to workspace quota from owner's quota
// TODO(@forehalo): replace it with `WorkspaceRoleModel` when it's ready
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const ownerQuota = await this.getUserQuota(owner.id);
return {
...ownerQuota,
ownerQuota: owner.id,
};
}
return quota.configs;
}
async getWorkspaceQuotaWithUsage(
workspaceId: string
): Promise<WorkspaceQuotaWithUsage> {
const quota = await this.getWorkspaceQuota(workspaceId);
const usedStorageQuota = quota.ownerQuota
? await this.getUserStorageUsage(quota.ownerQuota)
: await this.getWorkspaceStorageUsage(workspaceId);
const memberCount =
await this.permissions.getWorkspaceMemberCount(workspaceId);
return {
...quota,
usedStorageQuota,
memberCount,
usedSize: usedStorageQuota,
};
}
formatUserQuota(
quota: Omit<UserQuotaType, 'humanReadable'>
): UserQuotaHumanReadableType {
return {
name: quota.name,
blobLimit: formatSize(quota.blobLimit),
storageQuota: formatSize(quota.storageQuota),
usedStorageQuota: formatSize(quota.usedStorageQuota),
historyPeriod: formatDate(quota.historyPeriod),
memberLimit: quota.memberLimit.toString(),
copilotActionLimit: quota.copilotActionLimit
? `${quota.copilotActionLimit} times`
: 'Unlimited',
};
}
async getWorkspaceSeatQuota(workspaceId: string) {
const quota = await this.getWorkspaceQuota(workspaceId);
const memberCount =
await this.permissions.getWorkspaceMemberCount(workspaceId);
return {
memberCount,
memberLimit: quota.memberLimit,
};
}
async tryCheckSeat(workspaceId: string, excludeSelf = false) {
const quota = await this.getWorkspaceSeatQuota(workspaceId);
return quota.memberCount - (excludeSelf ? 1 : 0) < quota.memberLimit;
}
async checkSeat(workspaceId: string, excludeSelf = false) {
const available = await this.tryCheckSeat(workspaceId, excludeSelf);
if (!available) {
throw new MemberQuotaExceeded();
}
}
formatWorkspaceQuota(
quota: Omit<WorkspaceQuotaType, 'humanReadable'>
): WorkspaceQuotaHumanReadableType {
return {
name: quota.name,
blobLimit: formatSize(quota.blobLimit),
storageQuota: formatSize(quota.storageQuota),
storageQuotaUsed: formatSize(quota.usedStorageQuota),
historyPeriod: formatDate(quota.historyPeriod),
memberLimit: quota.memberLimit.toString(),
memberCount: quota.memberCount.toString(),
};
}
async getUserQuotaCalculator(userId: string) {
const quota = await this.getUserQuota(userId);
const usedSize = await this.getUserStorageUsage(userId);
return this.generateQuotaCalculator(
quota.storageQuota,
quota.blobLimit,
usedSize
);
}
async getWorkspaceQuotaCalculator(workspaceId: string) {
const quota = await this.getWorkspaceQuota(workspaceId);
const unlimited = await this.models.workspaceFeature.has(
workspaceId,
'unlimited_workspace'
);
// quota check will be disabled for unlimited workspace
// we save a complicated db read for used size
if (unlimited) {
return this.generateQuotaCalculator(0, quota.blobLimit, 0, true);
}
const usedSize = quota.ownerQuota
? await this.getUserStorageUsage(quota.ownerQuota)
: await this.getWorkspaceStorageUsage(workspaceId);
return this.generateQuotaCalculator(
quota.storageQuota,
quota.blobLimit,
usedSize
);
}
private async setupUserBaseQuota(userId: string) {
await this.models.userFeature.add(userId, 'free_plan_v1', 'sign up');
}
private generateQuotaCalculator(
storageQuota: number,
blobLimit: number,
usedQuota: number,
unlimited = false
) {
const checkExceeded = (recvSize: number) => {
const currentSize = usedQuota + recvSize;
// only skip total storage check if workspace has unlimited feature
if (currentSize > storageQuota && !unlimited) {
this.logger.warn(
`storage size limit exceeded: ${currentSize} > ${storageQuota}`
);
return true;
} else if (recvSize > blobLimit) {
this.logger.warn(
`blob size limit exceeded: ${recvSize} > ${blobLimit}`
);
return true;
} else {
return false;
}
};
return checkExceeded;
} }
} }
@@ -1,264 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { MemberQuotaExceeded } from '../../base';
import { FeatureService, FeatureType } from '../features';
import { PermissionService } from '../permission';
import { WorkspaceBlobStorage } from '../storage';
import { OneGB } from './constant';
import { QuotaConfig } from './quota';
import { QuotaService } from './service';
import { formatSize, Quota, type QuotaBusinessType, QuotaType } from './types';
@Injectable()
export class QuotaManagementService {
protected logger = new Logger(QuotaManagementService.name);
constructor(
private readonly feature: FeatureService,
private readonly quota: QuotaService,
private readonly permissions: PermissionService,
private readonly storage: WorkspaceBlobStorage
) {}
async getUserQuota(userId: string) {
const quota = await this.quota.getUserQuota(userId);
return {
name: quota.feature.name,
reason: quota.reason,
createAt: quota.createdAt,
expiredAt: quota.expiredAt,
blobLimit: quota.feature.blobLimit,
businessBlobLimit: quota.feature.businessBlobLimit,
storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit,
copilotActionLimit: quota.feature.copilotActionLimit,
};
}
async getWorkspaceConfig<Q extends QuotaType>(
workspaceId: string,
quota: Q
): Promise<QuotaConfig | undefined> {
return this.quota.getWorkspaceConfig(workspaceId, quota);
}
async updateWorkspaceConfig<Q extends QuotaType>(
workspaceId: string,
quota: Q,
configs: Partial<Quota<Q>['configs']>
) {
const orig = await this.getWorkspaceConfig(workspaceId, quota);
return await this.quota.updateWorkspaceConfig(
workspaceId,
quota,
Object.assign({}, orig?.override, configs)
);
}
// ======== Team Workspace ========
async addTeamWorkspace(workspaceId: string, reason: string) {
return this.quota.switchWorkspaceQuota(
workspaceId,
QuotaType.TeamPlanV1,
reason
);
}
async removeTeamWorkspace(workspaceId: string) {
return this.quota.deactivateWorkspaceQuota(
workspaceId,
QuotaType.TeamPlanV1
);
}
async isTeamWorkspace(workspaceId: string) {
return this.quota.hasWorkspaceQuota(workspaceId, QuotaType.TeamPlanV1);
}
async getUserStorageUsage(userId: string) {
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
const workspacesWithQuota = await this.quota.hasWorkspacesQuota(workspaces);
const sizes = await Promise.allSettled(
workspaces
.filter(w => !workspacesWithQuota.includes(w))
.map(workspace => this.storage.totalSize(workspace))
);
return sizes.reduce((total, size) => {
if (size.status === 'fulfilled') {
// ensure that size is within the safe range of gql
const totalSize = total + size.value;
if (Number.isSafeInteger(totalSize)) {
return totalSize;
} else {
this.logger.error(`Workspace size is invalid: ${size.value}`);
}
} else {
this.logger.error(`Failed to get workspace size: ${size.reason}`);
}
return total;
}, 0);
}
async getWorkspaceStorageUsage(workspaceId: string) {
const totalSize = await this.storage.totalSize(workspaceId);
// ensure that size is within the safe range of gql
if (Number.isSafeInteger(totalSize)) {
return totalSize;
} else {
this.logger.error(`Workspace size is invalid: ${totalSize}`);
}
return 0;
}
private generateQuotaCalculator(
quota: number,
blobLimit: number,
usedSize: number,
unlimited = false
) {
const checkExceeded = (recvSize: number) => {
const total = usedSize + recvSize;
// only skip total storage check if workspace has unlimited feature
if (total > quota && !unlimited) {
this.logger.warn(`storage size limit exceeded: ${total} > ${quota}`);
return true;
} else if (recvSize > blobLimit) {
this.logger.warn(
`blob size limit exceeded: ${recvSize} > ${blobLimit}`
);
return true;
} else {
return false;
}
};
return checkExceeded;
}
async getQuotaCalculator(userId: string) {
const quota = await this.getUserQuota(userId);
const { storageQuota, businessBlobLimit } = quota;
const usedSize = await this.getUserStorageUsage(userId);
return this.generateQuotaCalculator(
storageQuota,
businessBlobLimit,
usedSize
);
}
async getQuotaCalculatorByWorkspace(workspaceId: string) {
const { storageQuota, usedSize, businessBlobLimit, unlimited } =
await this.getWorkspaceUsage(workspaceId);
return this.generateQuotaCalculator(
storageQuota,
businessBlobLimit,
usedSize,
unlimited
);
}
private async getWorkspaceQuota(
userId: string,
workspaceId: string
): Promise<{ quota: QuotaConfig; fromUser: boolean }> {
const { feature: workspaceQuota } =
(await this.quota.getWorkspaceQuota(workspaceId)) || {};
const { feature: userQuota } = await this.quota.getUserQuota(userId);
if (workspaceQuota) {
return {
quota: workspaceQuota.withOverride({
// override user quota with workspace quota
copilotActionLimit: userQuota.copilotActionLimit,
}),
fromUser: false,
};
}
return { quota: userQuota, fromUser: true };
}
async checkWorkspaceSeat(workspaceId: string, excludeSelf = false) {
const quota = await this.getWorkspaceUsage(workspaceId);
if (quota.memberCount - (excludeSelf ? 1 : 0) >= quota.memberLimit) {
throw new MemberQuotaExceeded();
}
}
// get workspace's owner quota and total size of used
// quota was apply to owner's account
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
const memberCount =
await this.permissions.getWorkspaceMemberCount(workspaceId);
const {
quota: {
name,
blobLimit,
businessBlobLimit,
historyPeriod,
memberLimit,
storageQuota,
copilotActionLimit,
humanReadable,
},
fromUser,
} = await this.getWorkspaceQuota(owner.id, workspaceId);
const usedSize = fromUser
? // get all workspaces size of owner used
await this.getUserStorageUsage(owner.id)
: // get workspace size
await this.getWorkspaceStorageUsage(workspaceId);
// relax restrictions if workspace has unlimited feature
// todo(@darkskygit): need a mechanism to allow feature as a middleware to edit quota
const unlimited = await this.feature.hasWorkspaceFeature(
workspaceId,
FeatureType.UnlimitedWorkspace
);
const quota: QuotaBusinessType = {
name,
blobLimit,
businessBlobLimit,
historyPeriod,
memberLimit,
storageQuota,
copilotActionLimit,
humanReadable,
usedSize,
unlimited,
memberCount,
};
if (quota.unlimited) {
return this.mergeUnlimitedQuota(quota);
}
return quota;
}
private mergeUnlimitedQuota(orig: QuotaBusinessType): QuotaBusinessType {
return {
...orig,
storageQuota: 1000 * OneGB,
memberLimit: 1000,
humanReadable: {
...orig.humanReadable,
name: 'Unlimited',
storageQuota: formatSize(1000 * OneGB),
memberLimit: '1000',
},
};
}
async checkBlobQuota(workspaceId: string, size: number) {
const { storageQuota, usedSize } =
await this.getWorkspaceUsage(workspaceId);
return storageQuota - (size + usedSize);
}
}
+86 -105
View File
@@ -1,142 +1,123 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars'; import { SafeIntResolver } from 'graphql-scalars';
import { z } from 'zod';
import { commonFeatureSchema, FeatureKind } from '../features/types'; import { UserQuota, WorkspaceQuota } from '../../models';
import { ByteUnit, OneDay, OneKB } from './constant';
/// ======== quota define ========
/**
* naming rule:
* we append Vx to the end of the feature name to indicate the version of the feature
* x is a number, start from 1, this number will be change only at the time we change the schema of config
* for example, we change the value of `blobLimit` from 10MB to 100MB, then we will only change `version` field from 1 to 2
* but if we remove the `blobLimit` field or rename it, then we will change the Vx to Vx+1
*/
export enum QuotaType {
FreePlanV1 = 'free_plan_v1',
ProPlanV1 = 'pro_plan_v1',
TeamPlanV1 = 'team_plan_v1',
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
// only for test, smaller quota
RestrictedPlanV1 = 'restricted_plan_v1',
}
const basicQuota = z.object({
name: z.string(),
blobLimit: z.number().positive().int(),
storageQuota: z.number().positive().int(),
seatQuota: z.number().positive().int().nullish(),
historyPeriod: z.number().positive().int(),
memberLimit: z.number().positive().int(),
businessBlobLimit: z.number().positive().int().nullish(),
});
const userQuota = basicQuota.extend({
copilotActionLimit: z.number().positive().int().nullish(),
});
const userQuotaPlan = z.object({
feature: z.enum([
QuotaType.FreePlanV1,
QuotaType.ProPlanV1,
QuotaType.LifetimeProPlanV1,
QuotaType.RestrictedPlanV1,
]),
configs: userQuota,
});
const workspaceQuotaPlan = z.object({
feature: z.enum([QuotaType.TeamPlanV1]),
configs: basicQuota,
});
/// ======== schema infer ========
export const QuotaSchema = commonFeatureSchema
.extend({
type: z.literal(FeatureKind.Quota),
})
.and(z.discriminatedUnion('feature', [userQuotaPlan, workspaceQuotaPlan]));
export type Quota<Q extends QuotaType = QuotaType> = z.infer<
typeof QuotaSchema
> & { feature: Q };
export type QuotaConfigType = Quota['configs'];
/// ======== query types ========
@ObjectType() @ObjectType()
export class HumanReadableQuotaType { export class UserQuotaHumanReadableType {
@Field(() => String) @Field()
name!: string; name!: string;
@Field(() => String) @Field()
blobLimit!: string; blobLimit!: string;
@Field(() => String) @Field()
storageQuota!: string; storageQuota!: string;
@Field(() => String) @Field()
usedStorageQuota!: string;
@Field()
historyPeriod!: string; historyPeriod!: string;
@Field(() => String) @Field()
memberLimit!: string; memberLimit!: string;
@Field(() => String, { nullable: true }) @Field()
copilotActionLimit?: string; copilotActionLimit!: string;
} }
@ObjectType() @ObjectType()
export class QuotaQueryType { export class UserQuotaType implements UserQuota {
@Field(() => String) @Field()
name!: string; name!: string;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
blobLimit!: number; blobLimit!: number;
@Field(() => SafeIntResolver)
storageQuota!: number;
@Field(() => SafeIntResolver)
usedStorageQuota!: number;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
historyPeriod!: number; historyPeriod!: number;
@Field(() => SafeIntResolver) @Field()
memberLimit!: number; memberLimit!: number;
@Field(() => Number, { nullable: true })
copilotActionLimit?: number;
@Field(() => UserQuotaHumanReadableType)
humanReadable!: UserQuotaHumanReadableType;
}
@ObjectType()
export class UserQuotaUsageType {
@Field(() => SafeIntResolver, {
name: 'storageQuota',
deprecationReason: "use `UserQuotaType['usedStorageQuota']` instead",
})
storageQuota!: number;
}
@ObjectType()
export class WorkspaceQuotaHumanReadableType {
@Field()
name!: string;
@Field()
blobLimit!: string;
@Field()
storageQuota!: string;
@Field()
storageQuotaUsed!: string;
@Field()
historyPeriod!: string;
@Field()
memberLimit!: string;
@Field()
memberCount!: string;
}
@ObjectType()
export class WorkspaceQuotaType implements Partial<WorkspaceQuota> {
@Field()
name!: string;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
memberCount!: number; blobLimit!: number;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
storageQuota!: number; storageQuota!: number;
@Field(() => SafeIntResolver, { nullable: true }) @Field(() => SafeIntResolver)
copilotActionLimit?: number; usedStorageQuota!: number;
@Field(() => HumanReadableQuotaType)
humanReadable!: HumanReadableQuotaType;
@Field(() => SafeIntResolver) @Field(() => SafeIntResolver)
historyPeriod!: number;
@Field()
memberLimit!: number;
@Field()
memberCount!: number;
@Field()
humanReadable!: WorkspaceQuotaHumanReadableType;
/**
* @deprecated
*/
@Field(() => SafeIntResolver, {
deprecationReason: 'use `usedStorageQuota` instead',
})
usedSize!: number; usedSize!: number;
} }
/// ======== utils ========
export function formatSize(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 B';
const dm = decimals < 0 ? 0 : decimals;
const i = Math.floor(Math.log(bytes) / Math.log(OneKB));
return (
parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + ByteUnit[i]
);
}
export function formatDate(ms: number): string {
return `${(ms / OneDay).toFixed(0)} days`;
}
export type QuotaBusinessType = QuotaQueryType & {
businessBlobLimit: number;
unlimited: boolean;
};
@@ -0,0 +1,19 @@
import { OneDay, OneKB } from '../../base';
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
export function formatSize(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 B';
const dm = decimals < 0 ? 0 : decimals;
const i = Math.floor(Math.log(bytes) / Math.log(OneKB));
return (
parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + ByteUnit[i]
);
}
export function formatDate(ms: number): string {
return `${(ms / OneDay).toFixed(0)} days`;
}
@@ -112,8 +112,17 @@ export class WorkspaceBlobStorage {
} }
async totalSize(workspaceId: string) { async totalSize(workspaceId: string) {
const blobs = await this.list(workspaceId); const sum = await this.db.blob.aggregate({
return blobs.reduce((acc, item) => acc + item.size, 0); where: {
workspaceId,
deletedAt: null,
},
_sum: {
size: true,
},
});
return sum._sum.size ?? 0;
} }
private trySyncBlobsMeta(workspaceId: string, blobs: ListObjectsMetadata[]) { private trySyncBlobsMeta(workspaceId: string, blobs: ListObjectsMetadata[]) {
@@ -8,7 +8,6 @@ import { QuotaModule } from '../quota';
import { StorageModule } from '../storage'; import { StorageModule } from '../storage';
import { UserModule } from '../user'; import { UserModule } from '../user';
import { WorkspacesController } from './controller'; import { WorkspacesController } from './controller';
import { WorkspaceManagementResolver } from './management';
import { import {
DocHistoryResolver, DocHistoryResolver,
PagePermissionResolver, PagePermissionResolver,
@@ -32,7 +31,6 @@ import {
providers: [ providers: [
WorkspaceResolver, WorkspaceResolver,
TeamWorkspaceResolver, TeamWorkspaceResolver,
WorkspaceManagementResolver,
PagePermissionResolver, PagePermissionResolver,
DocHistoryResolver, DocHistoryResolver,
WorkspaceBlobResolver, WorkspaceBlobResolver,
@@ -1,98 +0,0 @@
import {
Args,
Int,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { ActionForbidden } from '../../base';
import { CurrentUser } from '../auth';
import { Admin } from '../common';
import { FeatureManagementService, FeatureType } from '../features';
import { PermissionService } from '../permission';
import { WorkspaceFeatureType, WorkspaceType } from './types';
@Resolver(() => WorkspaceType)
export class WorkspaceManagementResolver {
constructor(
private readonly feature: FeatureManagementService,
private readonly permission: PermissionService
) {}
@Admin()
@Mutation(() => Int)
async addWorkspaceFeature(
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<number> {
return this.feature.addWorkspaceFeatures(workspaceId, feature);
}
@Admin()
@Mutation(() => Int)
async removeWorkspaceFeature(
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<boolean> {
return this.feature.removeWorkspaceFeature(workspaceId, feature);
}
@Admin()
@Query(() => [WorkspaceFeatureType])
async listWorkspaceFeatures(
@Args('feature', { type: () => FeatureType }) feature: FeatureType
): Promise<WorkspaceFeatureType[]> {
return this.feature.listFeatureWorkspaces(feature);
}
@Mutation(() => Boolean)
async setWorkspaceExperimentalFeature(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
@Args('enable') enable: boolean
): Promise<boolean> {
if (!(await this.feature.canEarlyAccess(user.email))) {
throw new ActionForbidden();
}
const owner = await this.permission.getWorkspaceOwner(workspaceId);
const availableFeatures = await this.availableFeatures(user);
if (owner.id !== user.id || !availableFeatures.includes(feature)) {
throw new ActionForbidden();
}
if (enable) {
return await this.feature
.addWorkspaceFeatures(
workspaceId,
feature,
'add by experimental feature api'
)
.then(id => id > 0);
} else {
return await this.feature.removeWorkspaceFeature(workspaceId, feature);
}
}
@ResolveField(() => [FeatureType], {
description: 'Available features of workspace',
complexity: 2,
})
async availableFeatures(
@CurrentUser() user: CurrentUser
): Promise<FeatureType[]> {
return await this.feature.getActivatedUserFeatures(user.id);
}
@ResolveField(() => [FeatureType], {
description: 'Enabled features of workspace',
complexity: 2,
})
async features(@Parent() workspace: WorkspaceType): Promise<FeatureType[]> {
return this.feature.getWorkspaceFeatures(workspace.id);
}
}
@@ -16,7 +16,7 @@ import type { FileUpload } from '../../../base';
import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base'; import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base';
import { CurrentUser } from '../../auth'; import { CurrentUser } from '../../auth';
import { PermissionService, WorkspaceRole } from '../../permission'; import { PermissionService, WorkspaceRole } from '../../permission';
import { QuotaManagementService } from '../../quota'; import { QuotaService } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobStorage } from '../../storage';
import { WorkspaceBlobSizes, WorkspaceType } from '../types'; import { WorkspaceBlobSizes, WorkspaceType } from '../types';
@@ -41,7 +41,7 @@ export class WorkspaceBlobResolver {
logger = new Logger(WorkspaceBlobResolver.name); logger = new Logger(WorkspaceBlobResolver.name);
constructor( constructor(
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService, private readonly quota: QuotaService,
private readonly storage: WorkspaceBlobStorage private readonly storage: WorkspaceBlobStorage
) {} ) {}
@@ -106,7 +106,7 @@ export class WorkspaceBlobResolver {
); );
const checkExceeded = const checkExceeded =
await this.quota.getQuotaCalculatorByWorkspace(workspaceId); await this.quota.getWorkspaceQuotaCalculator(workspaceId);
// TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge` // TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge`
if (checkExceeded(0)) { if (checkExceeded(0)) {
@@ -148,6 +148,9 @@ export class WorkspaceService {
} }
// ================ Team ================ // ================ Team ================
async isTeamWorkspace(workspaceId: string) {
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
}
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) { async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
const workspace = await this.getWorkspaceInfo(workspaceId); const workspace = await this.getWorkspaceInfo(workspaceId);
@@ -10,6 +10,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { import {
ActionForbiddenOnNonTeamWorkspace,
Cache, Cache,
EventBus, EventBus,
MemberNotFoundInSpace, MemberNotFoundInSpace,
@@ -22,7 +23,7 @@ import {
import { Models } from '../../../models'; import { Models } from '../../../models';
import { CurrentUser } from '../../auth'; import { CurrentUser } from '../../auth';
import { PermissionService, WorkspaceRole } from '../../permission'; import { PermissionService, WorkspaceRole } from '../../permission';
import { QuotaManagementService } from '../../quota'; import { QuotaService } from '../../quota';
import { import {
InviteLink, InviteLink,
InviteResult, InviteResult,
@@ -47,7 +48,7 @@ export class TeamWorkspaceResolver {
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly models: Models, private readonly models: Models,
private readonly quota: QuotaManagementService, private readonly quota: QuotaService,
private readonly mutex: RequestMutex, private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService private readonly workspaceService: WorkspaceService
) {} ) {}
@@ -58,7 +59,7 @@ export class TeamWorkspaceResolver {
complexity: 2, complexity: 2,
}) })
team(@Parent() workspace: WorkspaceType) { team(@Parent() workspace: WorkspaceType) {
return this.quota.isTeamWorkspace(workspace.id); return this.workspaceService.isTeamWorkspace(workspace.id);
} }
@Mutation(() => [InviteResult]) @Mutation(() => [InviteResult])
@@ -85,7 +86,7 @@ export class TeamWorkspaceResolver {
return new TooManyRequest(); return new TooManyRequest();
} }
const quota = await this.quota.getWorkspaceUsage(workspaceId); const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
const results = []; const results = [];
for (const [idx, email] of emails.entries()) { for (const [idx, email] of emails.entries()) {
@@ -285,10 +286,20 @@ export class TeamWorkspaceResolver {
@Args('userId') userId: string, @Args('userId') userId: string,
@Args('permission', { type: () => WorkspaceRole }) permission: WorkspaceRole @Args('permission', { type: () => WorkspaceRole }) permission: WorkspaceRole
) { ) {
// non-team workspace can only transfer ownership, but no detailed permission control
if (permission !== WorkspaceRole.Owner) {
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
if (!isTeam) {
throw new ActionForbiddenOnNonTeamWorkspace();
}
}
await this.permissions.checkWorkspace( await this.permissions.checkWorkspace(
workspaceId, workspaceId,
user.id, user.id,
WorkspaceRole.Owner permission >= WorkspaceRole.Admin
? WorkspaceRole.Owner
: WorkspaceRole.Admin
); );
try { try {
@@ -38,7 +38,7 @@ import {
mapWorkspaceRoleToWorkspaceActions, mapWorkspaceRoleToWorkspaceActions,
WorkspacePermissionsList, WorkspacePermissionsList,
} from '../../permission/types'; } from '../../permission/types';
import { QuotaManagementService, QuotaQueryType } from '../../quota'; import { QuotaService, WorkspaceQuotaType } from '../../quota';
import { UserType } from '../../user'; import { UserType } from '../../user';
import { import {
InvitationType, InvitationType,
@@ -122,7 +122,7 @@ export class WorkspaceResolver {
private readonly cache: Cache, private readonly cache: Cache,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly quota: QuotaManagementService, private readonly quota: QuotaService,
private readonly models: Models, private readonly models: Models,
private readonly event: EventBus, private readonly event: EventBus,
private readonly mutex: RequestMutex, private readonly mutex: RequestMutex,
@@ -260,13 +260,19 @@ export class WorkspaceResolver {
}; };
} }
@ResolveField(() => QuotaQueryType, { @ResolveField(() => WorkspaceQuotaType, {
name: 'quota', name: 'quota',
description: 'quota of workspace', description: 'quota of workspace',
complexity: 2, complexity: 2,
}) })
workspaceQuota(@Parent() workspace: WorkspaceType) { async workspaceQuota(
return this.quota.getWorkspaceUsage(workspace.id); @Parent() workspace: WorkspaceType
): Promise<WorkspaceQuotaType> {
const quota = await this.quota.getWorkspaceQuotaWithUsage(workspace.id);
return {
...quota,
humanReadable: this.quota.formatWorkspaceQuota(quota),
};
} }
@Query(() => Boolean, { @Query(() => Boolean, {
@@ -421,12 +427,7 @@ export class WorkspaceResolver {
@Args({ name: 'input', type: () => UpdateWorkspaceInput }) @Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput { id, ...updates }: UpdateWorkspaceInput
) { ) {
const isTeam = await this.quota.isTeamWorkspace(id); await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Admin);
await this.permissions.checkWorkspace(
id,
user.id,
isTeam ? WorkspaceRole.Owner : WorkspaceRole.Admin
);
return this.prisma.workspace.update({ return this.prisma.workspace.update({
where: { where: {
@@ -483,7 +484,7 @@ export class WorkspaceResolver {
} }
// member limit check // member limit check
await this.quota.checkWorkspaceSeat(workspaceId); await this.quota.checkSeat(workspaceId);
let target = await this.models.user.getUserByEmail(email); let target = await this.models.user.getUserByEmail(email);
if (target) { if (target) {
@@ -569,14 +570,14 @@ export class WorkspaceResolver {
@Args('workspaceId') workspaceId: string, @Args('workspaceId') workspaceId: string,
@Args('userId') userId: string @Args('userId') userId: string
) { ) {
const isTeam = await this.quota.isTeamWorkspace(workspaceId);
const isAdmin = await this.permissions.tryCheckWorkspaceIs( const isAdmin = await this.permissions.tryCheckWorkspaceIs(
workspaceId, workspaceId,
userId, userId,
WorkspaceRole.Admin WorkspaceRole.Admin
); );
if (isTeam && isAdmin) {
// only owner can revoke team workspace admin if (isAdmin) {
// only owner can revoke workspace admin
await this.permissions.checkWorkspaceIs( await this.permissions.checkWorkspaceIs(
workspaceId, workspaceId,
user.id, user.id,
@@ -607,7 +608,6 @@ export class WorkspaceResolver {
throw new TooManyRequest(); throw new TooManyRequest();
} }
const isTeam = await this.quota.isTeamWorkspace(workspaceId);
if (user) { if (user) {
const status = await this.permissions.getWorkspaceMemberStatus( const status = await this.permissions.getWorkspaceMemberStatus(
workspaceId, workspaceId,
@@ -622,8 +622,9 @@ export class WorkspaceResolver {
`workspace:inviteLink:${workspaceId}` `workspace:inviteLink:${workspaceId}`
); );
if (invite?.inviteId === inviteId) { if (invite?.inviteId === inviteId) {
const quota = await this.quota.getWorkspaceUsage(workspaceId); const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
if (quota.memberCount >= quota.memberLimit) { const seatAvailable = await this.quota.tryCheckSeat(workspaceId);
if (!seatAvailable) {
// only team workspace allow over limit // only team workspace allow over limit
if (isTeam) { if (isTeam) {
await this.permissions.grant( await this.permissions.grant(
@@ -661,10 +662,6 @@ export class WorkspaceResolver {
} }
} }
// we added seats when sending invitation emails, but the payment may fail
// so we need to check seat again here
await this.quota.checkWorkspaceSeat(workspaceId, true);
if (sendAcceptMail) { if (sendAcceptMail) {
const success = await this.workspaceService.sendAcceptedEmail(inviteId); const success = await this.workspaceService.sendAcceptedEmail(inviteId);
if (!success) throw new UserNotFound(); if (!success) throw new UserNotFound();
@@ -5,16 +5,18 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { once } from 'lodash-es';
import { Command, CommandRunner } from 'nest-commander'; import { Command, CommandRunner } from 'nest-commander';
interface Migration { interface Migration {
file: string; file: string;
name: string; name: string;
always?: boolean;
up: (db: PrismaClient, injector: ModuleRef) => Promise<void>; up: (db: PrismaClient, injector: ModuleRef) => Promise<void>;
down: (db: PrismaClient, injector: ModuleRef) => Promise<void>; down: (db: PrismaClient, injector: ModuleRef) => Promise<void>;
} }
export async function collectMigrations(): Promise<Migration[]> { export const collectMigrations = once(async () => {
const folder = join(fileURLToPath(import.meta.url), '../../migrations'); const folder = join(fileURLToPath(import.meta.url), '../../migrations');
const migrationFiles = readdirSync(folder) const migrationFiles = readdirSync(folder)
@@ -33,6 +35,7 @@ export async function collectMigrations(): Promise<Migration[]> {
return { return {
file, file,
name: migration.name, name: migration.name,
always: migration.always,
up: migration.up, up: migration.up,
down: migration.down, down: migration.down,
}; };
@@ -41,7 +44,8 @@ export async function collectMigrations(): Promise<Migration[]> {
); );
return migrations; return migrations;
} });
@Command({ @Command({
name: 'run', name: 'run',
description: 'Run all pending data migrations', description: 'Run all pending data migrations',
@@ -65,7 +69,7 @@ export class RunCommand extends CommandRunner {
}, },
}); });
if (exists) { if (exists && !migration.always) {
continue; continue;
} }
@@ -100,8 +104,14 @@ export class RunCommand extends CommandRunner {
private async runMigration(migration: Migration) { private async runMigration(migration: Migration) {
this.logger.log(`Running ${migration.name}...`); this.logger.log(`Running ${migration.name}...`);
const record = await this.db.dataMigration.create({ const record = await this.db.dataMigration.upsert({
data: { where: {
name: migration.name,
},
update: {
startedAt: new Date(),
},
create: {
name: migration.name, name: migration.name,
startedAt: new Date(), startedAt: new Date(),
}, },
@@ -0,0 +1,16 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { FeatureModel } from '../../models';
export class RefreshFeatures0001 {
static always = true;
// do the migration
static async up(_db: PrismaClient, ref: ModuleRef) {
await ref.get(FeatureModel, { strict: false }).refreshFeatures();
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,24 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { Features } from '../../core/features';
import { Quotas } from '../../core/quota/schema';
import { upsertFeature } from './utils/user-features';
export class UserFeaturesInit1698652531198 {
// do the migration
static async up(db: PrismaClient) {
// upgrade features from lower version to higher version
for (const feature of Features) {
await upsertFeature(db, feature);
}
for (const quota of Quotas) {
await upsertFeature(db, quota);
}
}
// revert the migration
static async down(_db: PrismaClient) {
// noop
}
}
@@ -1,36 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota/types';
export class OldUserFeature1702620653283 {
// do the migration
static async up(db: PrismaClient) {
await db.$transaction(async tx => {
const latestFreePlan = await tx.feature.findFirstOrThrow({
where: { feature: QuotaType.FreePlanV1 },
orderBy: { version: 'desc' },
select: { id: true },
});
// find all users that don't have any features
const userIds = await db.user.findMany({
where: { NOT: { features: { some: { NOT: { id: { gt: 0 } } } } } },
select: { id: true },
});
await tx.userFeature.createMany({
data: userIds.map(({ id: userId }) => ({
userId,
featureId: latestFreePlan.id,
reason: 'old user feature migration',
activated: true,
})),
});
});
}
// revert the migration
// WARN: this will drop all user features
static async down(db: PrismaClient) {
await db.userFeature.deleteMany({});
}
}
@@ -1,17 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { Features } from '../../core/features';
import { upsertFeature } from './utils/user-features';
export class RefreshUserFeatures1704352562369 {
// do the migration
static async up(db: PrismaClient) {
// add early access v2 & copilot feature
for (const feature of Features) {
await upsertFeature(db, feature);
}
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,16 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { Quotas } from '../../core/quota/schema';
import { upgradeQuotaVersion } from './utils/user-quotas';
export class NewFreePlan1705395933447 {
// do the migration
static async up(db: PrismaClient) {
// free plan 1.0
const quota = Quotas[3];
await upgradeQuotaVersion(db, quota, 'free plan 1.0 migration');
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,16 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { Quotas } from '../../core/quota/schema';
import { upgradeQuotaVersion } from './utils/user-quotas';
export class BusinessBlobLimit1706513866287 {
// do the migration
static async up(db: PrismaClient) {
// free plan 1.1
const quota = Quotas[4];
await upgradeQuotaVersion(db, quota, 'free plan 1.1 migration');
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,15 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { FeatureType } from '../../core/features';
import { upsertLatestFeatureVersion } from './utils/user-features';
export class RefreshUnlimitedWorkspaceFeature1708321519830 {
// do the migration
static async up(db: PrismaClient) {
// add unlimited workspace feature
await upsertLatestFeatureVersion(db, FeatureType.UnlimitedWorkspace);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,19 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota/types';
import { upgradeLatestQuotaVersion } from './utils/user-quotas';
export class RefreshFreePlan1712224382221 {
// do the migration
static async up(db: PrismaClient) {
// free plan 1.1
await upgradeLatestQuotaVersion(
db,
QuotaType.FreePlanV1,
'free plan 1.1 migration'
);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,23 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota/types';
import { upgradeLatestQuotaVersion } from './utils/user-quotas';
export class CopilotFeature1713164714634 {
// do the migration
static async up(db: PrismaClient) {
await upgradeLatestQuotaVersion(
db,
QuotaType.ProPlanV1,
'pro plan 1.1 migration'
);
await upgradeLatestQuotaVersion(
db,
QuotaType.RestrictedPlanV1,
'restricted plan 1.1 migration'
);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,14 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { FeatureType } from '../../core/features';
import { upsertLatestFeatureVersion } from './utils/user-features';
export class AiEarlyAccess1713176777814 {
// do the migration
static async up(db: PrismaClient) {
await upsertLatestFeatureVersion(db, FeatureType.AIEarlyAccess);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,14 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { FeatureType } from '../../core/features';
import { upsertLatestFeatureVersion } from './utils/user-features';
export class UnlimitedCopilot1713285638427 {
// do the migration
static async up(db: PrismaClient) {
await upsertLatestFeatureVersion(db, FeatureType.UnlimitedCopilot);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,14 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { FeatureType } from '../../core/features';
import { upsertLatestFeatureVersion } from './utils/user-features';
export class AdministratorFeature1716195522794 {
// do the migration
static async up(db: PrismaClient) {
await upsertLatestFeatureVersion(db, FeatureType.Admin);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,14 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota';
import { upsertLatestQuotaVersion } from './utils/user-quotas';
export class LifetimeProQuota1719917815802 {
// do the migration
static async up(db: PrismaClient) {
await upsertLatestQuotaVersion(db, QuotaType.LifetimeProPlanV1);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,14 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota';
import { upsertLatestQuotaVersion } from './utils/user-quotas';
export class TeamQuota1733804966417 {
// do the migration
static async up(db: PrismaClient) {
await upsertLatestQuotaVersion(db, QuotaType.TeamPlanV1);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,57 @@
import { PrismaClient } from '@prisma/client';
import { FeatureConfigs, FeatureName, FeatureType } from '../../models';
export class FeatureRedundant1738590347632 {
// do the migration
static async up(db: PrismaClient) {
const features = await db.feature.findMany();
const validFeatures = new Map<
number,
{
name: string;
type: FeatureType;
}
>();
for (const feature of features) {
const def = FeatureConfigs[feature.name as FeatureName];
if (!def || def.deprecatedVersion !== feature.deprecatedVersion) {
await db.feature.delete({
where: { id: feature.id },
});
} else {
validFeatures.set(feature.id, {
name: feature.name,
type: def.type,
});
}
}
for (const [id, def] of validFeatures.entries()) {
await db.userFeature.updateMany({
where: {
featureId: id,
},
data: {
name: def.name,
type: def.type,
},
});
await db.workspaceFeature.updateMany({
where: {
featureId: id,
},
data: {
name: def.name,
type: def.type,
},
});
}
}
// revert the migration
static async down(_db: PrismaClient) {
// noop
}
}
@@ -1,29 +0,0 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { Config } from '../../base';
import { FeatureManagementService } from '../../core/features';
export class SelfHostAdmin1 {
// do the migration
static async up(db: PrismaClient, ref: ModuleRef) {
const config = ref.get(Config, { strict: false });
if (config.isSelfhosted) {
const feature = ref.get(FeatureManagementService, { strict: false });
const firstUser = await db.user.findFirst({
orderBy: {
createdAt: 'asc',
},
});
if (firstUser) {
await feature.addAdmin(firstUser.id);
}
}
}
// revert the migration
static async down() {
//
}
}
@@ -1,40 +0,0 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { CommonFeature, Features, FeatureType } from '../../../core/features';
// upgrade features from lower version to higher version
export async function upsertFeature(
db: PrismaClient,
feature: CommonFeature
): Promise<void> {
const hasEqualOrGreaterVersion =
(await db.feature.count({
where: {
feature: feature.feature,
version: {
gte: feature.version,
},
},
})) > 0;
// will not update exists version
if (!hasEqualOrGreaterVersion) {
await db.feature.create({
data: {
feature: feature.feature,
type: feature.type,
version: feature.version,
configs: feature.configs as Prisma.InputJsonValue,
},
});
}
}
export async function upsertLatestFeatureVersion(
db: PrismaClient,
type: FeatureType
) {
const feature = Features.filter(f => f.feature === type);
feature.sort((a, b) => b.version - a.version);
const latestFeature = feature[0];
await upsertFeature(db, latestFeature);
}
@@ -1,89 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { FeatureKind } from '../../../core/features';
import { getLatestQuota } from '../../../core/quota/schema';
import { Quota, QuotaType } from '../../../core/quota/types';
import { upsertFeature } from './user-features';
export async function upgradeQuotaVersion(
db: PrismaClient,
quota: Quota,
reason: string
) {
// add new quota
await upsertFeature(db, quota);
// migrate all users that using old quota to new quota
await db.$transaction(
async tx => {
const latestQuotaVersion = await tx.feature.findFirstOrThrow({
where: { feature: quota.feature },
orderBy: { version: 'desc' },
select: { id: true },
});
// find all users that have old free plan
const userIds = await tx.user.findMany({
where: {
features: {
some: {
feature: {
type: FeatureKind.Quota,
feature: quota.feature,
version: { lt: quota.version },
},
activated: true,
},
},
},
select: { id: true },
});
// deactivate all old quota for the user
await tx.userFeature.updateMany({
where: {
id: undefined,
userId: {
in: userIds.map(({ id }) => id),
},
feature: {
type: FeatureKind.Quota,
},
activated: true,
},
data: {
activated: false,
},
});
await tx.userFeature.createMany({
data: userIds.map(({ id: userId }) => ({
userId,
featureId: latestQuotaVersion.id,
reason,
activated: true,
})),
});
},
{
maxWait: 10000,
timeout: 20000,
}
);
}
export async function upsertLatestQuotaVersion(
db: PrismaClient,
type: QuotaType
) {
const latestQuota = getLatestQuota(type);
await upsertFeature(db, latestQuota);
}
export async function upgradeLatestQuotaVersion(
db: PrismaClient,
type: QuotaType,
reason: string
) {
const latestQuota = getLatestQuota(type);
await upgradeQuotaVersion(db, latestQuota, reason);
}
@@ -1,63 +1,75 @@
import { z } from 'zod'; import { z } from 'zod';
export enum FeatureType { import { OneDay, OneGB, OneMB } from '../../base';
Feature = 0,
Quota = 1,
}
// TODO(@forehalo): quota is a useless extra concept, merge it with feature const UserPlanQuotaConfig = z.object({
export const UserPlanQuotaConfig = z.object({
// quota name // quota name
name: z.string(), name: z.string(),
// single blob limit // single blob limit
blobLimit: z.number(), blobLimit: z.number(),
// server limit will larger then client to handle a edge case:
// when a user downgrades from pro to free, he can still continue
// to upload previously added files that exceed the free limit
// NOTE: this is a product decision, may change in future
businessBlobLimit: z.number().optional(),
// total blob limit // total blob limit
storageQuota: z.number(), storageQuota: z.number(),
// history period of validity // history period of validity
historyPeriod: z.number(), historyPeriod: z.number(),
// member limit // member limit
memberLimit: z.number(), memberLimit: z.number(),
// copilot action limit 10 // copilot action limit
copilotActionLimit: z.number(), copilotActionLimit: z.number().optional(),
}); });
export const WorkspaceQuotaConfig = UserPlanQuotaConfig.extend({ export type UserQuota = z.infer<typeof UserPlanQuotaConfig>;
const WorkspaceQuotaConfig = UserPlanQuotaConfig.extend({
// seat quota // seat quota
seatQuota: z.number(), seatQuota: z.number(),
}).omit({ }).omit({
copilotActionLimit: true, copilotActionLimit: true,
}); });
function feature<T extends z.ZodRawShape>(configs: z.ZodObject<T>) { export type WorkspaceQuota = z.infer<typeof WorkspaceQuotaConfig>;
return z.object({
type: z.literal(FeatureType.Feature), const EMPTY_CONFIG = z.object({});
configs: configs,
}); export enum FeatureType {
Feature,
Quota,
} }
function quota<T extends z.ZodRawShape>(configs: z.ZodObject<T>) { export enum Feature {
return z.object({ // user
type: z.literal(FeatureType.Quota), Admin = 'administrator',
configs: configs, EarlyAccess = 'early_access',
}); AIEarlyAccess = 'ai_early_access',
UnlimitedCopilot = 'unlimited_copilot',
FreePlan = 'free_plan_v1',
ProPlan = 'pro_plan_v1',
LifetimeProPlan = 'lifetime_pro_plan_v1',
// workspace
UnlimitedWorkspace = 'unlimited_workspace',
TeamPlan = 'team_plan_v1',
} }
export const Features = { // TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`?
copilot: feature(z.object({})), export const FeaturesShapes = {
early_access: feature(z.object({ whitelist: z.array(z.string()) })), early_access: z.object({ whitelist: z.array(z.string()) }),
unlimited_workspace: feature(z.object({})), unlimited_workspace: EMPTY_CONFIG,
unlimited_copilot: feature(z.object({})), unlimited_copilot: EMPTY_CONFIG,
ai_early_access: feature(z.object({})), ai_early_access: EMPTY_CONFIG,
administrator: feature(z.object({})), administrator: EMPTY_CONFIG,
free_plan_v1: quota(UserPlanQuotaConfig), free_plan_v1: UserPlanQuotaConfig,
pro_plan_v1: quota(UserPlanQuotaConfig), pro_plan_v1: UserPlanQuotaConfig,
lifetime_pro_plan_v1: quota(UserPlanQuotaConfig), lifetime_pro_plan_v1: UserPlanQuotaConfig,
restricted_plan_v1: quota(UserPlanQuotaConfig), team_plan_v1: WorkspaceQuotaConfig,
team_plan_v1: quota(WorkspaceQuotaConfig), } satisfies Record<Feature, z.ZodObject<any>>;
};
export type UserFeatureName = keyof Pick< export type UserFeatureName = keyof Pick<
typeof Features, typeof FeaturesShapes,
| 'early_access' | 'early_access'
| 'ai_early_access' | 'ai_early_access'
| 'unlimited_copilot' | 'unlimited_copilot'
@@ -65,14 +77,97 @@ export type UserFeatureName = keyof Pick<
| 'free_plan_v1' | 'free_plan_v1'
| 'pro_plan_v1' | 'pro_plan_v1'
| 'lifetime_pro_plan_v1' | 'lifetime_pro_plan_v1'
| 'restricted_plan_v1'
>; >;
export type WorkspaceFeatureName = keyof Pick< export type WorkspaceFeatureName = keyof Pick<
typeof Features, typeof FeaturesShapes,
'unlimited_workspace' | 'team_plan_v1' 'unlimited_workspace' | 'team_plan_v1'
>; >;
export type FeatureName = UserFeatureName | WorkspaceFeatureName; export type FeatureName = UserFeatureName | WorkspaceFeatureName;
export type FeatureConfigs<T extends FeatureName> = z.infer< export type FeatureConfig<T extends FeatureName> = z.infer<
(typeof Features)[T]['shape']['configs'] (typeof FeaturesShapes)[T]
>; >;
export const FeatureConfigs: {
[K in FeatureName]: {
type: FeatureType;
configs: FeatureConfig<K>;
deprecatedVersion: number;
};
} = {
free_plan_v1: {
type: FeatureType.Quota,
deprecatedVersion: 4,
configs: {
// quota name
name: 'Free',
blobLimit: 10 * OneMB,
businessBlobLimit: 100 * OneMB,
storageQuota: 10 * OneGB,
historyPeriod: 7 * OneDay,
memberLimit: 3,
copilotActionLimit: 10,
},
},
pro_plan_v1: {
type: FeatureType.Quota,
deprecatedVersion: 2,
configs: {
name: 'Pro',
blobLimit: 100 * OneMB,
storageQuota: 100 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 10,
copilotActionLimit: 10,
},
},
lifetime_pro_plan_v1: {
type: FeatureType.Quota,
deprecatedVersion: 1,
configs: {
name: 'Lifetime Pro',
blobLimit: 100 * OneMB,
storageQuota: 1024 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 10,
copilotActionLimit: 10,
},
},
team_plan_v1: {
type: FeatureType.Quota,
deprecatedVersion: 1,
configs: {
name: 'Team Workspace',
blobLimit: 500 * OneMB,
storageQuota: 100 * OneGB,
seatQuota: 20 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 1,
},
},
early_access: {
type: FeatureType.Feature,
deprecatedVersion: 2,
configs: { whitelist: [] },
},
unlimited_workspace: {
type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {},
},
unlimited_copilot: {
type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {},
},
ai_early_access: {
type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {},
},
administrator: {
type: FeatureType.Feature,
deprecatedVersion: 1,
configs: {},
},
};
+28 -10
View File
@@ -5,9 +5,10 @@ import { z } from 'zod';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { import {
type FeatureConfigs, type FeatureConfig,
FeatureConfigs,
type FeatureName, type FeatureName,
Features, FeaturesShapes,
FeatureType, FeatureType,
} from './common'; } from './common';
@@ -29,7 +30,12 @@ export class FeatureModel extends BaseModel {
} }
@Transactional() @Transactional()
async upsert<T extends FeatureName>(name: T, configs: FeatureConfigs<T>) { async upsert<T extends FeatureName>(
name: T,
configs: FeatureConfig<T>,
deprecatedType: FeatureType,
deprecatedVersion: number
) {
const parsedConfigs = this.check(name, configs); const parsedConfigs = this.check(name, configs);
// TODO(@forehalo): // TODO(@forehalo):
@@ -41,8 +47,9 @@ export class FeatureModel extends BaseModel {
if (!latest) { if (!latest) {
feature = await this.db.feature.create({ feature = await this.db.feature.create({
data: { data: {
type: FeatureType.Feature, name,
feature: name, deprecatedType,
deprecatedVersion,
configs: parsedConfigs, configs: parsedConfigs,
}, },
}); });
@@ -57,7 +64,7 @@ export class FeatureModel extends BaseModel {
this.logger.verbose(`Feature ${name} upserted`); this.logger.verbose(`Feature ${name} upserted`);
return feature as Feature & { configs: FeatureConfigs<T> }; return feature as Feature & { configs: FeatureConfig<T> };
} }
/** /**
@@ -67,8 +74,7 @@ export class FeatureModel extends BaseModel {
*/ */
async try_get_unchecked<T extends FeatureName>(name: T) { async try_get_unchecked<T extends FeatureName>(name: T) {
const feature = await this.db.feature.findFirst({ const feature = await this.db.feature.findFirst({
where: { feature: name }, where: { name },
orderBy: { version: 'desc' },
}); });
return feature as Omit<Feature, 'configs'> & { return feature as Omit<Feature, 'configs'> & {
@@ -104,10 +110,22 @@ export class FeatureModel extends BaseModel {
}); });
} }
return parseResult.data as FeatureConfigs<T>; return parseResult.data as FeatureConfig<T>;
} }
getConfigShape(name: FeatureName): z.ZodObject<any> { getConfigShape(name: FeatureName): z.ZodObject<any> {
return Features[name]?.shape.configs ?? z.object({}); return FeaturesShapes[name] ?? z.object({});
}
getFeatureType(name: FeatureName): FeatureType {
return FeatureConfigs[name].type;
}
async refreshFeatures() {
for (const key in FeatureConfigs) {
const name = key as FeatureName;
const def = FeatureConfigs[name];
await this.upsert(name, def.configs, def.type, def.deprecatedVersion);
}
} }
} }
@@ -77,6 +77,7 @@ const ModelsSymbolProvider: ExistingProvider = {
}) })
export class ModelsModule {} export class ModelsModule {}
export * from './common';
export * from './feature'; export * from './feature';
export * from './page'; export * from './page';
export * from './session'; export * from './session';
@@ -1,47 +1,49 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base'; import { BaseModel } from './base';
import type { UserFeatureName } from './common'; import { FeatureType, type UserFeatureName } from './common';
@Injectable() @Injectable()
export class UserFeatureModel extends BaseModel { export class UserFeatureModel extends BaseModel {
async get<T extends UserFeatureName>(userId: string, name: T) { async get<T extends UserFeatureName>(userId: string, name: T) {
// TODO(@forehalo):
// all feature query-and-use queries should be simplified like the below when `feature(name)` becomes a unique index
//
// this.db.userFeature.findFirst({
// include: {
// feature: true
// },
// where: {
// userId,
// activated: true,
// feature: {
// feature: name,
// }
// }
// })
const feature = await this.models.feature.get(name);
const count = await this.db.userFeature.count({ const count = await this.db.userFeature.count({
where: { where: {
userId, userId,
featureId: feature.id, name,
activated: true, activated: true,
}, },
}); });
return count > 0 ? feature : null; if (count === 0) {
return null;
}
return await this.models.feature.get(name);
}
async getQuota(userId: string) {
const quota = await this.db.userFeature.findFirst({
where: {
userId,
type: FeatureType.Quota,
activated: true,
},
});
if (!quota) {
return null;
}
return await this.models.feature.get<'free_plan_v1'>(quota.name as any);
} }
async has(userId: string, name: UserFeatureName) { async has(userId: string, name: UserFeatureName) {
const feature = await this.models.feature.get_unchecked(name);
const count = await this.db.userFeature.count({ const count = await this.db.userFeature.count({
where: { where: {
userId, userId,
featureId: feature.id, name,
activated: true, activated: true,
}, },
}); });
@@ -49,29 +51,37 @@ export class UserFeatureModel extends BaseModel {
return count > 0; return count > 0;
} }
async list(userId: string) { async list(userId: string, type?: FeatureType) {
const filter: Prisma.UserFeatureWhereInput =
type === undefined
? {
userId,
activated: true,
}
: {
userId,
activated: true,
type,
};
const userFeatures = await this.db.userFeature.findMany({ const userFeatures = await this.db.userFeature.findMany({
include: { where: filter,
feature: true, select: {
}, name: true,
where: {
userId,
activated: true,
}, },
}); });
return userFeatures.map( return userFeatures.map(
userFeature => userFeature.feature.feature userFeature => userFeature.name
) as UserFeatureName[]; ) as UserFeatureName[];
} }
async add(userId: string, featureName: UserFeatureName, reason: string) { async add(userId: string, name: UserFeatureName, reason: string) {
const feature = await this.models.feature.get_unchecked(featureName); const feature = await this.models.feature.get_unchecked(name);
const existing = await this.db.userFeature.findFirst({ const existing = await this.db.userFeature.findFirst({
where: { where: {
userId, userId,
featureId: feature.id, name: name,
activated: true, activated: true,
}, },
}); });
@@ -84,37 +94,56 @@ export class UserFeatureModel extends BaseModel {
data: { data: {
userId, userId,
featureId: feature.id, featureId: feature.id,
name,
type: this.models.feature.getFeatureType(name),
activated: true, activated: true,
reason, reason,
}, },
}); });
this.logger.verbose(`Feature ${featureName} added to user ${userId}`); this.logger.verbose(`Feature ${name} added to user ${userId}`);
return userFeature; return userFeature;
} }
async remove(userId: string, featureName: UserFeatureName) { async remove(userId: string, featureName: UserFeatureName) {
const feature = await this.models.feature.get_unchecked(featureName); await this.db.userFeature.updateMany({
await this.db.userFeature.deleteMany({
where: { where: {
userId, userId,
featureId: feature.id, name: featureName,
},
data: {
activated: false,
}, },
}); });
this.logger.verbose(`Feature ${featureName} removed from user ${userId}`); this.logger.verbose(
`Feature ${featureName} deactivated for user ${userId}`
);
} }
@Transactional() @Transactional()
async switch( async switchQuota(userId: string, to: UserFeatureName, reason: string) {
userId: string, const quotas = await this.list(userId, FeatureType.Quota);
from: UserFeatureName,
to: UserFeatureName, // deactivate the previous quota
reason: string if (quotas.length) {
) { // db state error
await this.remove(userId, from); if (quotas.length > 1) {
this.logger.error(
`User ${userId} has multiple quotas, please check the database state.`
);
}
const from = quotas.at(-1) as UserFeatureName;
if (from === to) {
return;
}
await this.remove(userId, from);
}
await this.add(userId, to, reason); await this.add(userId, to, reason);
} }
} }
+5 -23
View File
@@ -9,7 +9,6 @@ import {
WrongSignInCredentials, WrongSignInCredentials,
WrongSignInMethod, WrongSignInMethod,
} from '../base'; } from '../base';
import { Quota_FreePlanV1_1 } from '../core/quota/schema';
import { BaseModel } from './base'; import { BaseModel } from './base';
import type { Workspace } from './workspace'; import type { Workspace } from './workspace';
@@ -22,23 +21,6 @@ const publicUserSelect = {
type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string }; type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string };
type UpdateUserInput = Omit<Partial<Prisma.UserCreateInput>, 'id'>; type UpdateUserInput = Omit<Partial<Prisma.UserCreateInput>, 'id'>;
const defaultUserCreatingData = {
name: 'Unnamed',
// TODO(@forehalo): it's actually a external dependency for user
// how could we avoid user model's knowledge of feature?
features: {
create: {
reason: 'sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1_1,
},
},
},
},
};
declare global { declare global {
interface Events { interface Events {
'user.created': User; 'user.created': User;
@@ -48,6 +30,7 @@ declare global {
// dealing of owned workspaces of deleted users to workspace model // dealing of owned workspaces of deleted users to workspace model
ownedWorkspaces: Workspace['id'][]; ownedWorkspaces: Workspace['id'][];
}; };
'user.postCreated': User;
} }
} }
@@ -134,17 +117,16 @@ export class UserModel extends BaseModel {
data.password = await this.crypto.encryptPassword(data.password); data.password = await this.crypto.encryptPassword(data.password);
} }
if (!data.name) {
data.name = data.email.split('@')[0];
}
user = await this.db.user.create({ user = await this.db.user.create({
data: { data: {
...defaultUserCreatingData,
...data, ...data,
name: data.name ?? data.email.split('@')[0],
}, },
}); });
// delegate the responsibility of finish user creating setup to the corresponding models
await this.event.emitAsync('user.postCreated', user);
this.logger.debug(`User [${user.id}] created with email [${user.email}]`); this.logger.debug(`User [${user.id}] created with email [${user.email}]`);
this.event.emit('user.created', user); this.event.emit('user.created', user);
@@ -1,18 +1,21 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base'; import { BaseModel } from './base';
import type { FeatureConfigs, WorkspaceFeatureName } from './common'; import {
type FeatureConfig,
FeatureType,
type WorkspaceFeatureName,
} from './common';
@Injectable() @Injectable()
export class WorkspaceFeatureModel extends BaseModel { export class WorkspaceFeatureModel extends BaseModel {
async get<T extends WorkspaceFeatureName>(workspaceId: string, name: T) { async get<T extends WorkspaceFeatureName>(workspaceId: string, name: T) {
const feature = await this.models.feature.get_unchecked(name);
const workspaceFeature = await this.db.workspaceFeature.findFirst({ const workspaceFeature = await this.db.workspaceFeature.findFirst({
where: { where: {
workspaceId, workspaceId,
featureId: feature.id, name,
activated: true, activated: true,
}, },
}); });
@@ -21,6 +24,8 @@ export class WorkspaceFeatureModel extends BaseModel {
return null; return null;
} }
const feature = await this.models.feature.get_unchecked(name);
return { return {
...feature, ...feature,
configs: this.models.feature.check(name, { configs: this.models.feature.check(name, {
@@ -30,13 +35,47 @@ export class WorkspaceFeatureModel extends BaseModel {
}; };
} }
async has(workspaceId: string, name: WorkspaceFeatureName) { async getQuota(workspaceId: string) {
const feature = await this.models.feature.get_unchecked(name); const quota = await this.db.workspaceFeature.findFirst({
where: {
workspaceId,
type: FeatureType.Quota,
activated: true,
},
orderBy: {
createdAt: 'desc',
},
});
if (!quota) {
return null;
}
const rawFeature = await this.models.feature.get_unchecked(
quota.name as WorkspaceFeatureName
);
const feature = {
...rawFeature,
configs: this.models.feature.check(quota.name as 'team_plan_v1', {
...rawFeature.configs,
...(quota?.configs as {}),
}),
};
// workspace's storage quota is the sum of base quota and seats * quota per seat
feature.configs.storageQuota =
feature.configs.seatQuota * feature.configs.memberLimit +
feature.configs.storageQuota;
return feature;
}
async has(workspaceId: string, name: WorkspaceFeatureName) {
const count = await this.db.workspaceFeature.count({ const count = await this.db.workspaceFeature.count({
where: { where: {
workspaceId, workspaceId,
featureId: feature.id, name,
activated: true, activated: true,
}, },
}); });
@@ -44,35 +83,62 @@ export class WorkspaceFeatureModel extends BaseModel {
return count > 0; return count > 0;
} }
async list(workspaceId: string) { /**
* helper function to check if a list of workspaces have a standalone quota feature when calculating owner's quota usage
*/
async batchHasQuota(workspaceIds: string[]) {
const workspaceFeatures = await this.db.workspaceFeature.findMany({ const workspaceFeatures = await this.db.workspaceFeature.findMany({
include: { select: {
feature: true, workspaceId: true,
}, },
where: { where: {
workspaceId, workspaceId: { in: workspaceIds },
type: FeatureType.Quota,
activated: true, activated: true,
}, },
}); });
return workspaceFeatures.map(feature => feature.workspaceId);
}
async list(workspaceId: string, type?: FeatureType) {
const filter: Prisma.WorkspaceFeatureWhereInput =
type === undefined
? {
workspaceId,
activated: true,
}
: {
workspaceId,
activated: true,
type,
};
const workspaceFeatures = await this.db.workspaceFeature.findMany({
select: {
name: true,
},
where: filter,
});
return workspaceFeatures.map( return workspaceFeatures.map(
workspaceFeature => workspaceFeature.feature.feature workspaceFeature => workspaceFeature.name
) as WorkspaceFeatureName[]; ) as WorkspaceFeatureName[];
} }
@Transactional() @Transactional()
async add<T extends WorkspaceFeatureName>( async add<T extends WorkspaceFeatureName>(
workspaceId: string, workspaceId: string,
featureName: T, name: T,
reason: string, reason: string,
overrides?: Partial<FeatureConfigs<T>> overrides?: Partial<FeatureConfig<T>>
) { ) {
const feature = await this.models.feature.get_unchecked(featureName); const feature = await this.models.feature.get_unchecked(name);
const existing = await this.db.workspaceFeature.findFirst({ const existing = await this.db.workspaceFeature.findFirst({
where: { where: {
workspaceId, workspaceId,
featureId: feature.id, name: name,
activated: true, activated: true,
}, },
}); });
@@ -87,12 +153,12 @@ export class WorkspaceFeatureModel extends BaseModel {
}; };
const parseResult = this.models.feature const parseResult = this.models.feature
.getConfigShape(featureName) .getConfigShape(name)
.partial() .partial()
.safeParse(configs); .safeParse(configs);
if (!parseResult.success) { if (!parseResult.success) {
throw new Error(`Invalid feature config for ${featureName}`, { throw new Error(`Invalid feature config for ${name}`, {
cause: parseResult.error, cause: parseResult.error,
}); });
} }
@@ -113,6 +179,8 @@ export class WorkspaceFeatureModel extends BaseModel {
data: { data: {
workspaceId, workspaceId,
featureId: feature.id, featureId: feature.id,
name,
type: this.models.feature.getFeatureType(name),
activated: true, activated: true,
reason, reason,
configs: parseResult.data, configs: parseResult.data,
@@ -120,20 +188,16 @@ export class WorkspaceFeatureModel extends BaseModel {
}); });
} }
this.logger.verbose( this.logger.verbose(`Feature ${name} added to workspace ${workspaceId}`);
`Feature ${featureName} added to workspace ${workspaceId}`
);
return workspaceFeature; return workspaceFeature;
} }
async remove(workspaceId: string, featureName: WorkspaceFeatureName) { async remove(workspaceId: string, featureName: WorkspaceFeatureName) {
const feature = await this.models.feature.get_unchecked(featureName);
await this.db.workspaceFeature.deleteMany({ await this.db.workspaceFeature.deleteMany({
where: { where: {
workspaceId, workspaceId,
featureId: feature.id, name: featureName,
}, },
}); });
@@ -141,16 +205,4 @@ export class WorkspaceFeatureModel extends BaseModel {
`Feature ${featureName} removed from workspace ${workspaceId}` `Feature ${featureName} removed from workspace ${workspaceId}`
); );
} }
@Transactional()
async switch<T extends WorkspaceFeatureName>(
workspaceId: string,
from: WorkspaceFeatureName,
to: T,
reason: string,
overrides?: Partial<FeatureConfigs<T>>
) {
await this.remove(workspaceId, from);
return await this.add(workspaceId, to, reason, overrides);
}
} }

Some files were not shown because too many files have changed in this diff Show More