Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(server): use feature model #9932

Open
wants to merge 1 commit into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX "_data_migrations_name_key" ON "_data_migrations"("name");
Original file line number Diff line number Diff line change
@@ -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");
130 changes: 62 additions & 68 deletions packages/backend/server/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]
previewFeatures = ["metrics", "tracing", "relationJoins", "nativeDistinct"]
previewFeatures = ["metrics", "relationJoins", "nativeDistinct"]
}

datasource db {
Expand Down Expand Up @@ -102,10 +102,10 @@ model Workspace {
enableAi Boolean @default(true) @map("enable_ai")
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
features WorkspaceFeature[]
pages WorkspacePage[]
permissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
features WorkspaceFeature[]
blobs Blob[]
@@map("workspaces")
Expand Down Expand Up @@ -178,82 +178,76 @@ model WorkspacePageUserPermission {
@@map("workspace_page_user_permissions")
}

// feature gates is a way to enable/disable features for a user
// for example:
// - early access is a feature that allow some users to access the insider version
// - pro plan is a quota that allow some users access to more resources after they pay
model UserFeature {
id Int @id @default(autoincrement())
userId String @map("user_id") @db.VarChar
featureId Int @map("feature_id") @db.Integer
model Feature {
id Int @id @default(autoincrement())
name String @map("feature") @db.VarChar
configs Json @default("{}") @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
/// TODO(@forehalo): remove in the coming version
/// @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
userFeatures UserFeature[]
workspaceFeatures WorkspaceFeature[]
@@unique([name, deprecatedVersion])
@@map("features")
}

// we will record the reason why the feature is enabled/disabled
// for example:
// - pro_plan_v1: "user buy the pro plan"
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
// record the quota enabled time
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)
// 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)
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([name])
@@index([featureId])
@@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 {
id Int @id @default(autoincrement())
workspaceId String @map("workspace_id") @db.VarChar
featureId Int @map("feature_id") @db.Integer
// override quota's configs
configs Json @default("{}") @db.Json
// we will record the reason why the feature is enabled/disabled
// for example:
// - copilet_v1: "owner buy the copilet feature package"
reason String @db.VarChar
// record the feature enabled time
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)
// 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)
id Int @id @default(autoincrement())
workspaceId String @map("workspace_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
/// overrides for the default feature configs
configs Json @default("{}") @db.Json
reason String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
activated Boolean @default(false)
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId])
@@index([name])
@@index([featureId])
@@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
// Snapshot + Updates are the latest state of the doc
model Snapshot {
Expand Down Expand Up @@ -417,7 +411,7 @@ model AiSession {

model DataMigration {
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)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
Expand Down Expand Up @@ -552,21 +546,21 @@ model Subscription {
}

model Invoice {
stripeInvoiceId String @id @map("stripe_invoice_id")
targetId String @map("target_id") @db.VarChar
currency String @db.VarChar(3)
stripeInvoiceId String @id @map("stripe_invoice_id")
targetId String @map("target_id") @db.VarChar
currency String @db.VarChar(3)
// CNY 12.50 stored as 1250
amount Int @db.Integer
status String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
amount Int @db.Integer
status String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason
reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
// stripe hosted invoice link
link String? @db.Text
link String? @db.Text
// 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])
@@map("invoices")
Expand Down
12 changes: 6 additions & 6 deletions packages/backend/server/src/__tests__/app/selfhost.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';

import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
Expand All @@ -10,10 +9,10 @@ import request from 'supertest';
import { buildAppModule } from '../../app.module';
import { Config } from '../../base';
import { ServerService } from '../../core/config';
import { createTestingApp, initTestingDB } from '../utils';
import { createTestingApp, type TestingApp } from '../utils';

const test = ava as TestFn<{
app: INestApplication;
app: TestingApp;
db: PrismaClient;
}>;

Expand Down Expand Up @@ -54,7 +53,7 @@ test.before('init selfhost server', async t => {
});

test.beforeEach(async t => {
await initTestingDB(t.context.db);
await t.context.app.initTestingDB();
const server = t.context.app.get(ServerService);
// @ts-expect-error disable cache
server._initialized = false;
Expand Down Expand Up @@ -188,7 +187,8 @@ test('should redirect to admin if initialized', async t => {
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('should return web assets if visited by mobile', async t => {
await t.context.db.user.create({
data: {
name: 'test',
Expand All @@ -201,5 +201,5 @@ test('should return mobile assets if visited by mobile', async t => {
.set('user-agent', mobileUAString)
.expect(200);

t.true(res.text.includes('AFFiNE mobile'));
t.false(res.text.includes('AFFiNE mobile'));
});
5 changes: 2 additions & 3 deletions packages/backend/server/src/__tests__/auth/service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';

Expand All @@ -8,7 +7,7 @@ import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user';
import { Models } from '../../models';
import { createTestingModule, initTestingDB } from '../utils';
import { createTestingModule, type TestingModule } from '../utils';

const test = ava as TestFn<{
auth: AuthService;
Expand All @@ -31,7 +30,7 @@ test.before(async t => {
});

test.beforeEach(async t => {
await initTestingDB(t.context.db);
await t.context.m.initTestingDB();
t.context.u1 = await t.context.auth.signUp('[email protected]', '1');
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/// <reference types="../global.d.ts" />

import { TestingModule } from '@nestjs/testing';
import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';

Expand All @@ -27,7 +26,7 @@ import {
CopilotCheckHtmlExecutor,
CopilotCheckJsonExecutor,
} from '../plugins/copilot/workflow/executor';
import { createTestingModule } from './utils';
import { createTestingModule, TestingModule } from './utils';
import { TestAssets } from './utils/copilot';

type Tester = {
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/server/src/__tests__/copilot.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { randomUUID } from 'node:crypto';

import { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
Expand All @@ -27,6 +26,7 @@ import {
createWorkspace,
inviteUser,
signUp,
TestingApp,
} from './utils';
import {
array2sse,
Expand All @@ -47,7 +47,7 @@ import {

const test = ava as TestFn<{
auth: AuthService;
app: INestApplication;
app: TestingApp;
prompt: PromptService;
provider: CopilotProviderService;
storage: CopilotStorage;
Expand Down
5 changes: 1 addition & 4 deletions packages/backend/server/src/__tests__/copilot.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
/// <reference types="../global.d.ts" />

import { TestingModule } from '@nestjs/testing';
import type { TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
Expand Down Expand Up @@ -41,7 +38,7 @@ import {
} from '../plugins/copilot/workflow/executor';
import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils';
import { WorkflowGraphList } from '../plugins/copilot/workflow/graph';
import { createTestingModule } from './utils';
import { createTestingModule, TestingModule } from './utils';
import { MockCopilotTestProvider, WorkflowTestCases } from './utils/copilot';

const test = ava as TestFn<{
Expand Down
5 changes: 2 additions & 3 deletions packages/backend/server/src/__tests__/doc/history.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { TestingModule } from '@nestjs/testing';
import type { Snapshot } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
Expand All @@ -7,7 +6,7 @@ import * as Sinon from 'sinon';
import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { DocStorageOptions } from '../../core/doc/options';
import { DocRecord } from '../../core/doc/storage';
import { createTestingModule, initTestingDB } from '../utils';
import { createTestingModule, type TestingModule } from '../utils';

let m: TestingModule;
let adapter: PgWorkspaceDocStorageAdapter;
Expand All @@ -24,7 +23,7 @@ test.before(async () => {
});

test.beforeEach(async () => {
await initTestingDB(db);
await m.initTestingDB();
const options = m.get(DocStorageOptions);
Sinon.stub(options, 'historyMaxAge').resolves(1000);
});
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/server/src/__tests__/doc/renderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
.get('/workspace/xxxx/xxx')
.set('user-agent', mobileUAString)
Expand Down
Loading
Loading