From 7826e2b7c8a8eec257a6f4edcb1119b8d346b0da Mon Sep 17 00:00:00 2001 From: forehalo Date: Wed, 5 Feb 2025 10:27:26 +0000 Subject: [PATCH] refactor(server): use feature model (#9932) --- .../migration.sql | 2 + .../migration.sql | 24 + packages/backend/server/schema.prisma | 130 ++-- .../server/src/__tests__/app/selfhost.e2e.ts | 10 +- .../server/src/__tests__/auth/service.spec.ts | 5 +- .../src/__tests__/copilot-provider.spec.ts | 3 +- .../server/src/__tests__/copilot.e2e.ts | 4 +- .../server/src/__tests__/copilot.spec.ts | 5 +- .../server/src/__tests__/doc/history.spec.ts | 5 +- .../server/src/__tests__/doc/renderer.spec.ts | 3 +- .../src/__tests__/doc/workspace.spec.ts | 5 +- .../server/src/__tests__/feature.spec.ts | 176 ------ .../server/src/__tests__/mailer.spec.ts | 8 - .../__snapshots__/feature-user.spec.ts.md | 44 ++ .../__snapshots__/feature-user.spec.ts.snap | Bin 0 -> 418 bytes .../feature-workspace.spec.ts.md | 18 + .../feature-workspace.spec.ts.snap | Bin 0 -> 311 bytes .../models/__snapshots__/feature.spec.ts.md | 2 + .../models/__snapshots__/feature.spec.ts.snap | Bin 330 -> 344 bytes .../src/__tests__/models/feature-user.spec.ts | 56 +- .../models/feature-workspace.spec.ts | 72 ++- .../src/__tests__/models/feature.spec.ts | 36 +- .../server/src/__tests__/models/page.spec.ts | 5 +- .../src/__tests__/models/session.spec.ts | 5 +- .../server/src/__tests__/models/user.spec.ts | 5 +- .../models/verification-token.spec.ts | 5 +- .../src/__tests__/models/workspace.spec.ts | 5 +- .../src/__tests__/nestjs/throttler.spec.ts | 15 +- .../src/__tests__/oauth/controller.spec.ts | 10 +- .../src/__tests__/payment/service.spec.ts | 17 +- .../server/src/__tests__/quota.spec.ts | 212 ------- .../backend/server/src/__tests__/team.e2e.ts | 46 +- .../backend/server/src/__tests__/user.e2e.ts | 13 +- .../server/src/__tests__/utils/utils.ts | 61 +- .../src/__tests__/workspace-invite.e2e.ts | 31 +- .../server/src/__tests__/workspace.e2e.ts | 53 +- .../src/__tests__/workspace/blobs.e2e.ts | 59 +- packages/backend/server/src/base/error/def.ts | 4 + .../server/src/base/error/errors.gen.ts | 7 + packages/backend/server/src/base/index.ts | 8 +- .../server/src/base/runtime/service.ts | 9 +- .../backend/server/src/base/utils/index.ts | 4 + .../quota/constant.ts => base/utils/unit.ts} | 1 - .../backend/server/src/core/auth/service.ts | 24 +- .../server/src/core/common/admin-guard.ts | 6 +- .../server/src/core/config/resolver.ts | 10 +- .../backend/server/src/core/doc/options.ts | 2 +- .../server/src/core/features/feature.ts | 51 -- .../backend/server/src/core/features/index.ts | 32 +- .../server/src/core/features/management.ts | 170 ------ .../server/src/core/features/resolver.ts | 89 +-- .../server/src/core/features/service.ts | 397 ++----------- .../backend/server/src/core/features/types.ts | 31 + .../server/src/core/features/types/admin.ts | 8 - .../server/src/core/features/types/common.ts | 17 - .../server/src/core/features/types/copilot.ts | 8 - .../src/core/features/types/early-access.ts | 16 - .../server/src/core/features/types/index.ts | 100 ---- .../core/features/types/unlimited-copilot.ts | 8 - .../features/types/unlimited-workspace.ts | 8 - .../backend/server/src/core/quota/index.ts | 21 +- .../backend/server/src/core/quota/quota.ts | 146 ----- .../backend/server/src/core/quota/resolver.ts | 81 +-- .../backend/server/src/core/quota/schema.ts | 216 ------- .../backend/server/src/core/quota/service.ts | 562 ++++++++---------- .../backend/server/src/core/quota/storage.ts | 264 -------- .../backend/server/src/core/quota/types.ts | 191 +++--- .../backend/server/src/core/quota/utils.ts | 19 + .../server/src/core/storage/wrappers/blob.ts | 13 +- .../server/src/core/workspaces/index.ts | 2 - .../server/src/core/workspaces/management.ts | 98 --- .../src/core/workspaces/resolvers/blob.ts | 6 +- .../src/core/workspaces/resolvers/service.ts | 3 + .../src/core/workspaces/resolvers/team.ts | 21 +- .../core/workspaces/resolvers/workspace.ts | 41 +- .../backend/server/src/data/commands/run.ts | 20 +- .../data/migrations/0001-refresh-features.ts | 16 + .../1698652531198-user-features-init.ts | 24 - .../1702620653283-old-user-feature.ts | 36 -- .../1704352562369-refresh-user-features.ts | 17 - .../migrations/1705395933447-new-free-plan.ts | 16 - .../1706513866287-business-blob-limit.ts | 16 - ...830-refresh-unlimited-workspace-feature.ts | 15 - .../1712224382221-refresh-free-plan.ts | 19 - .../1713164714634-copilot-feature.ts | 23 - .../1713176777814-ai-early-access.ts | 14 - .../1713285638427-unlimited-copilot.ts | 14 - .../1716195522794-administrator-feature.ts | 14 - .../1719917815802-lifetime-pro-quota.ts | 14 - .../migrations/1733804966417-team-quota.ts | 14 - .../1738590347632-feature-redundant.ts | 57 ++ .../data/migrations/99999-self-host-admin.ts | 29 - .../data/migrations/utils/user-features.ts | 40 -- .../src/data/migrations/utils/user-quotas.ts | 89 --- .../server/src/models/common/feature.ts | 169 ++++-- packages/backend/server/src/models/feature.ts | 38 +- packages/backend/server/src/models/index.ts | 1 + .../backend/server/src/models/user-feature.ts | 125 ++-- packages/backend/server/src/models/user.ts | 28 +- .../server/src/models/workspace-feature.ts | 124 ++-- .../server/src/plugins/copilot/session.ts | 13 +- .../server/src/plugins/copilot/storage.ts | 6 +- .../server/src/plugins/license/service.ts | 21 +- .../src/plugins/payment/manager/user.ts | 7 +- .../server/src/plugins/payment/quota.ts | 60 +- .../server/src/plugins/payment/service.ts | 4 +- packages/backend/server/src/schema.gql | 103 ++-- .../src/modules/cloud/entities/user-quota.ts | 4 +- .../core/src/modules/quota/entities/quota.ts | 2 +- .../src/graphql/get-workspace-features.gql | 5 - .../frontend/graphql/src/graphql/index.ts | 120 +--- .../graphql/workspace-enabled-features.gql | 5 - .../workspace-experimental-feature-get.gql | 5 - .../workspace-experimental-feature-set.gql | 11 - .../src/graphql/workspace-feature-add.gql | 3 - .../src/graphql/workspace-feature-list.gql | 12 - .../src/graphql/workspace-feature-remove.gql | 3 - .../graphql/src/graphql/workspace-quota.gql | 2 +- packages/frontend/graphql/src/schema.ts | 327 ++++------ tests/affine-cloud/e2e/collaboration.spec.ts | 5 +- tests/kit/src/utils/cloud.ts | 15 +- 121 files changed, 1723 insertions(+), 3826 deletions(-) create mode 100644 packages/backend/server/migrations/20250203112209_data_migration_table_unique_name/migration.sql create mode 100644 packages/backend/server/migrations/20250203142831_standardize_features/migration.sql delete mode 100644 packages/backend/server/src/__tests__/feature.spec.ts create mode 100644 packages/backend/server/src/__tests__/models/__snapshots__/feature-user.spec.ts.md create mode 100644 packages/backend/server/src/__tests__/models/__snapshots__/feature-user.spec.ts.snap create mode 100644 packages/backend/server/src/__tests__/models/__snapshots__/feature-workspace.spec.ts.md create mode 100644 packages/backend/server/src/__tests__/models/__snapshots__/feature-workspace.spec.ts.snap delete mode 100644 packages/backend/server/src/__tests__/quota.spec.ts create mode 100644 packages/backend/server/src/base/utils/index.ts rename packages/backend/server/src/{core/quota/constant.ts => base/utils/unit.ts} (64%) delete mode 100644 packages/backend/server/src/core/features/feature.ts delete mode 100644 packages/backend/server/src/core/features/management.ts create mode 100644 packages/backend/server/src/core/features/types.ts delete mode 100644 packages/backend/server/src/core/features/types/admin.ts delete mode 100644 packages/backend/server/src/core/features/types/common.ts delete mode 100644 packages/backend/server/src/core/features/types/copilot.ts delete mode 100644 packages/backend/server/src/core/features/types/early-access.ts delete mode 100644 packages/backend/server/src/core/features/types/index.ts delete mode 100644 packages/backend/server/src/core/features/types/unlimited-copilot.ts delete mode 100644 packages/backend/server/src/core/features/types/unlimited-workspace.ts delete mode 100644 packages/backend/server/src/core/quota/quota.ts delete mode 100644 packages/backend/server/src/core/quota/schema.ts delete mode 100644 packages/backend/server/src/core/quota/storage.ts create mode 100644 packages/backend/server/src/core/quota/utils.ts delete mode 100644 packages/backend/server/src/core/workspaces/management.ts create mode 100644 packages/backend/server/src/data/migrations/0001-refresh-features.ts delete mode 100644 packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts delete mode 100644 packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts delete mode 100644 packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts delete mode 100644 packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts delete mode 100644 packages/backend/server/src/data/migrations/1706513866287-business-blob-limit.ts delete mode 100644 packages/backend/server/src/data/migrations/1708321519830-refresh-unlimited-workspace-feature.ts delete mode 100644 packages/backend/server/src/data/migrations/1712224382221-refresh-free-plan.ts delete mode 100644 packages/backend/server/src/data/migrations/1713164714634-copilot-feature.ts delete mode 100644 packages/backend/server/src/data/migrations/1713176777814-ai-early-access.ts delete mode 100644 packages/backend/server/src/data/migrations/1713285638427-unlimited-copilot.ts delete mode 100644 packages/backend/server/src/data/migrations/1716195522794-administrator-feature.ts delete mode 100644 packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts delete mode 100644 packages/backend/server/src/data/migrations/1733804966417-team-quota.ts create mode 100644 packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts delete mode 100644 packages/backend/server/src/data/migrations/99999-self-host-admin.ts delete mode 100644 packages/backend/server/src/data/migrations/utils/user-features.ts delete mode 100644 packages/backend/server/src/data/migrations/utils/user-quotas.ts delete mode 100644 packages/frontend/graphql/src/graphql/get-workspace-features.gql delete mode 100644 packages/frontend/graphql/src/graphql/workspace-enabled-features.gql delete mode 100644 packages/frontend/graphql/src/graphql/workspace-experimental-feature-get.gql delete mode 100644 packages/frontend/graphql/src/graphql/workspace-experimental-feature-set.gql delete mode 100644 packages/frontend/graphql/src/graphql/workspace-feature-add.gql delete mode 100644 packages/frontend/graphql/src/graphql/workspace-feature-list.gql delete mode 100644 packages/frontend/graphql/src/graphql/workspace-feature-remove.gql diff --git a/packages/backend/server/migrations/20250203112209_data_migration_table_unique_name/migration.sql b/packages/backend/server/migrations/20250203112209_data_migration_table_unique_name/migration.sql new file mode 100644 index 0000000000..5f93e192f9 --- /dev/null +++ b/packages/backend/server/migrations/20250203112209_data_migration_table_unique_name/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE UNIQUE INDEX "_data_migrations_name_key" ON "_data_migrations"("name"); diff --git a/packages/backend/server/migrations/20250203142831_standardize_features/migration.sql b/packages/backend/server/migrations/20250203142831_standardize_features/migration.sql new file mode 100644 index 0000000000..9a89b687dc --- /dev/null +++ b/packages/backend/server/migrations/20250203142831_standardize_features/migration.sql @@ -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"); diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 7dcffd8057..0453735a04 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -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 { @@ -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") @@ -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 - // we will record the reason why the feature is enabled/disabled - // for example: - // - pro_plan_v1: "user buy the pro plan" + userFeatures UserFeature[] + workspaceFeatures WorkspaceFeature[] + + @@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 - // 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 { @@ -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) @@ -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") diff --git a/packages/backend/server/src/__tests__/app/selfhost.e2e.ts b/packages/backend/server/src/__tests__/app/selfhost.e2e.ts index 4133fa7704..7533beeb4d 100644 --- a/packages/backend/server/src/__tests__/app/selfhost.e2e.ts +++ b/packages/backend/server/src/__tests__/app/selfhost.e2e.ts @@ -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'; @@ -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; }>; @@ -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; @@ -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.skip('should return web assets if visited by mobile', async t => { await t.context.db.user.create({ data: { name: 'test', diff --git a/packages/backend/server/src/__tests__/auth/service.spec.ts b/packages/backend/server/src/__tests__/auth/service.spec.ts index 2f1a7c21c4..6f5cd11902 100644 --- a/packages/backend/server/src/__tests__/auth/service.spec.ts +++ b/packages/backend/server/src/__tests__/auth/service.spec.ts @@ -1,4 +1,3 @@ -import { TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; @@ -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; @@ -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('u1@affine.pro', '1'); }); diff --git a/packages/backend/server/src/__tests__/copilot-provider.spec.ts b/packages/backend/server/src/__tests__/copilot-provider.spec.ts index 70344ac26a..72bdee3e1b 100644 --- a/packages/backend/server/src/__tests__/copilot-provider.spec.ts +++ b/packages/backend/server/src/__tests__/copilot-provider.spec.ts @@ -1,6 +1,5 @@ /// -import { TestingModule } from '@nestjs/testing'; import type { ExecutionContext, TestFn } from 'ava'; import ava from 'ava'; @@ -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 = { diff --git a/packages/backend/server/src/__tests__/copilot.e2e.ts b/packages/backend/server/src/__tests__/copilot.e2e.ts index f2a698221a..1f6e11ce17 100644 --- a/packages/backend/server/src/__tests__/copilot.e2e.ts +++ b/packages/backend/server/src/__tests__/copilot.e2e.ts @@ -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'; @@ -27,6 +26,7 @@ import { createWorkspace, inviteUser, signUp, + TestingApp, } from './utils'; import { array2sse, @@ -47,7 +47,7 @@ import { const test = ava as TestFn<{ auth: AuthService; - app: INestApplication; + app: TestingApp; prompt: PromptService; provider: CopilotProviderService; storage: CopilotStorage; diff --git a/packages/backend/server/src/__tests__/copilot.spec.ts b/packages/backend/server/src/__tests__/copilot.spec.ts index a0920a1b9b..7b9c5d2507 100644 --- a/packages/backend/server/src/__tests__/copilot.spec.ts +++ b/packages/backend/server/src/__tests__/copilot.spec.ts @@ -1,6 +1,3 @@ -/// - -import { TestingModule } from '@nestjs/testing'; import type { TestFn } from 'ava'; import ava from 'ava'; import Sinon from 'sinon'; @@ -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<{ diff --git a/packages/backend/server/src/__tests__/doc/history.spec.ts b/packages/backend/server/src/__tests__/doc/history.spec.ts index e53f09fceb..d9534b2051 100644 --- a/packages/backend/server/src/__tests__/doc/history.spec.ts +++ b/packages/backend/server/src/__tests__/doc/history.spec.ts @@ -1,4 +1,3 @@ -import { TestingModule } from '@nestjs/testing'; import type { Snapshot } from '@prisma/client'; import { PrismaClient } from '@prisma/client'; import test from 'ava'; @@ -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; @@ -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); }); diff --git a/packages/backend/server/src/__tests__/doc/renderer.spec.ts b/packages/backend/server/src/__tests__/doc/renderer.spec.ts index d8840f9134..6638f7b4b1 100644 --- a/packages/backend/server/src/__tests__/doc/renderer.spec.ts +++ b/packages/backend/server/src/__tests__/doc/renderer.spec.ts @@ -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) diff --git a/packages/backend/server/src/__tests__/doc/workspace.spec.ts b/packages/backend/server/src/__tests__/doc/workspace.spec.ts index 47e6ec7855..4be1ffb5b3 100644 --- a/packages/backend/server/src/__tests__/doc/workspace.spec.ts +++ b/packages/backend/server/src/__tests__/doc/workspace.spec.ts @@ -1,4 +1,3 @@ -import { TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import test from 'ava'; import * as Sinon from 'sinon'; @@ -9,7 +8,7 @@ import { DocStorageModule, PgWorkspaceDocStorageAdapter as Adapter, } from '../../core/doc'; -import { createTestingModule, initTestingDB } from '../utils'; +import { createTestingModule, type TestingModule } from '../utils'; let m: TestingModule; let db: PrismaClient; @@ -35,7 +34,7 @@ test.before('init testing module', async () => { }); test.beforeEach(async () => { - await initTestingDB(db); + await m.initTestingDB(); }); test.after.always(async () => { diff --git a/packages/backend/server/src/__tests__/feature.spec.ts b/packages/backend/server/src/__tests__/feature.spec.ts deleted file mode 100644 index bb3bbac61f..0000000000 --- a/packages/backend/server/src/__tests__/feature.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -/// - -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'); -}); diff --git a/packages/backend/server/src/__tests__/mailer.spec.ts b/packages/backend/server/src/__tests__/mailer.spec.ts index cde65207f0..5d24495eab 100644 --- a/packages/backend/server/src/__tests__/mailer.spec.ts +++ b/packages/backend/server/src/__tests__/mailer.spec.ts @@ -5,7 +5,6 @@ import Sinon from 'sinon'; import { AppModule } from '../app.module'; import { MailService } from '../base/mailer'; -import { FeatureManagementService } from '../core/features'; import { createTestingApp, createWorkspace, inviteUser, signUp } from './utils'; const test = ava as TestFn<{ app: INestApplication; @@ -16,13 +15,6 @@ import * as renderers from '../mails'; test.beforeEach(async t => { const { module, app } = await createTestingApp({ imports: [AppModule], - tapModule: module => { - module.overrideProvider(FeatureManagementService).useValue({ - hasWorkspaceFeature() { - return false; - }, - }); - }, }); const mail = module.get(MailService); diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/feature-user.spec.ts.md b/packages/backend/server/src/__tests__/models/__snapshots__/feature-user.spec.ts.md new file mode 100644 index 0000000000..9221205f5d --- /dev/null +++ b/packages/backend/server/src/__tests__/models/__snapshots__/feature-user.spec.ts.md @@ -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, + } diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/feature-user.spec.ts.snap b/packages/backend/server/src/__tests__/models/__snapshots__/feature-user.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..faecfcd6236654f458eb704ddc7b1d72f7de4878 GIT binary patch literal 418 zcmV;T0bTwYgit`Y1r;SHfE0)#5}|}{cQZ*0&U&%E z0vZGnXP`h7kc-eE&cOjV1BCn#Oe81}64Fal^t59w? zIONb0O^)Sq8B_x0L5rwTYMIiYY=!0mse-!b3j2PO$pCk7m`acs5wS6~FYIQ{VV6P` z3#sf@XoZTVV48QxQb!o8^jU+oP|fkgtKyo&Ep)jXu$~^3c92ZucX{%&t(hI5IC#l# z^WMPla7gw|=tYX9AFvcH&Fsa}k49-uS4M5jiLha3W}e++w+)tMN M6&El$BAEmL02L*@lmGw# literal 0 HcmV?d00001 diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/feature-workspace.spec.ts.md b/packages/backend/server/src/__tests__/models/__snapshots__/feature-workspace.spec.ts.md new file mode 100644 index 0000000000..f3c67c3588 --- /dev/null +++ b/packages/backend/server/src/__tests__/models/__snapshots__/feature-workspace.spec.ts.md @@ -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, + } diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/feature-workspace.spec.ts.snap b/packages/backend/server/src/__tests__/models/__snapshots__/feature-workspace.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..df1c07a0a050fc43c020e8fa4867682b4ab0fa95 GIT binary patch literal 311 zcmV-70m%MARzVK#54tvKnz8{+axW5K&rB0L$1IL5Gxi)fP{ot&LpN8I+F<* zSHK3ThhW2wo`NHA4=#cN3aa9j{q^Mk?T>qHeX$Jd4T}-W&qty1D_iCZmi)=L%h0HT z^T(AJWkRi*P-B|kMt+qj0#1(yOj?;BzJ0mk*7)*|i}CgPjsdvq*lBLJ z)=@b?qFOobhy5E?F2C)MA4UtTwcsr->9};nR%y0II*Z1t`UjFo Jsx=+{rP3A~_YqPPlDatLP|_M~02`-z)L?({TKapfAOoto@V zC8+av7wpBQzll%1!T4sS=xeEkCA7tf!=bGo$zJl)P9GwJ{Nkw_0z-r qc(Vv2nc=y5YckycvQ1=+XDF6rH3BW6WHvlaHM${Qn=RCXST!~ieU@!pnK^@Qz zM%3D)1k@hVX6Af$CY9)?lBU2j8q99)r{cUG { }); 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({ email: 'u1@affine.pro', registered: true, @@ -41,7 +40,13 @@ test('should get null if user feature not found', async t => { test('should get user feature', async t => { const { model, u1 } = t.context; 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 => { @@ -50,6 +55,16 @@ test('should list user features', async t => { 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 => { 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')); }); -test('should switch user feature', async t => { +test('should switch user quota', async t => { 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')); - t.true(await model.has(u1.id, 'pro_plan_v1')); - - t.false((await model.list(u1.id)).includes('free_plan_v1')); - t.true((await model.list(u1.id)).includes('pro_plan_v1')); + await model.switchQuota(u1.id, 'free_plan_v1', 'test'); + const quota2 = await model.getQuota(u1.id); + t.snapshot(quota2?.configs, 'switch to free plan'); +}); + +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'); }); diff --git a/packages/backend/server/src/__tests__/models/feature-workspace.spec.ts b/packages/backend/server/src/__tests__/models/feature-workspace.spec.ts index a3726ee141..4e03b369c0 100644 --- a/packages/backend/server/src/__tests__/models/feature-workspace.spec.ts +++ b/packages/backend/server/src/__tests__/models/feature-workspace.spec.ts @@ -1,9 +1,13 @@ -import { TestingModule } from '@nestjs/testing'; -import { PrismaClient, Workspace } from '@prisma/client'; +import { Workspace } from '@prisma/client'; import ava, { TestFn } from 'ava'; -import { UserModel, WorkspaceFeatureModel, WorkspaceModel } from '../../models'; -import { createTestingModule, initTestingDB } from '../utils'; +import { + FeatureType, + UserModel, + WorkspaceFeatureModel, + WorkspaceModel, +} from '../../models'; +import { createTestingModule, type TestingModule } from '../utils'; interface Context { module: TestingModule; @@ -21,7 +25,7 @@ test.before(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({ email: 'u1@affine.pro', registered: true, @@ -46,18 +50,52 @@ test('should directly test workspace feature existence', async t => { 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 => { const { model, ws } = t.context; 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 => { const { model, ws } = t.context; await model.add(ws.id, 'unlimited_workspace', 'test'); t.is( - (await model.get(ws.id, 'unlimited_workspace'))?.feature, + (await model.get(ws.id, 'unlimited_workspace'))?.name, '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.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); -}); diff --git a/packages/backend/server/src/__tests__/models/feature.spec.ts b/packages/backend/server/src/__tests__/models/feature.spec.ts index 10a8e61401..1389d3fa80 100644 --- a/packages/backend/server/src/__tests__/models/feature.spec.ts +++ b/packages/backend/server/src/__tests__/models/feature.spec.ts @@ -1,9 +1,8 @@ -import { TestingModule } from '@nestjs/testing'; -import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; +import { FeatureType } from '../../models'; import { FeatureModel } from '../../models/feature'; -import { createTestingModule, initTestingDB } from '../utils'; +import { createTestingModule, type TestingModule } from '../utils'; interface Context { module: TestingModule; @@ -20,7 +19,7 @@ test.before(async t => { }); test.beforeEach(async t => { - await initTestingDB(t.context.module.get(PrismaClient)); + await t.context.module.initTestingDB(); }); 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 => { 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, {}); }); @@ -100,10 +104,15 @@ test('should update feature', async t => { const { feature } = t.context; const freePlanFeature = await feature.get('free_plan_v1'); - const newFreePlanFeature = await feature.upsert('free_plan_v1', { - ...freePlanFeature.configs, - memberLimit: 10, - }); + const newFreePlanFeature = await feature.upsert( + 'free_plan_v1', + { + ...freePlanFeature.configs, + memberLimit: 10, + }, + FeatureType.Quota, + 1 + ); t.deepEqual(newFreePlanFeature.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 => { const { feature } = t.context; - await t.throwsAsync(feature.upsert('free_plan_v1', {} as any), { - message: 'Invalid feature config for free_plan_v1', - }); + await t.throwsAsync( + feature.upsert('free_plan_v1', {} as any, FeatureType.Quota, 1), + { + message: 'Invalid feature config for free_plan_v1', + } + ); }); diff --git a/packages/backend/server/src/__tests__/models/page.spec.ts b/packages/backend/server/src/__tests__/models/page.spec.ts index 5974c86fac..d8bfd2bb4f 100644 --- a/packages/backend/server/src/__tests__/models/page.spec.ts +++ b/packages/backend/server/src/__tests__/models/page.spec.ts @@ -1,4 +1,3 @@ -import { TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; @@ -8,7 +7,7 @@ import { PublicPageMode } from '../../models/common'; import { PageModel } from '../../models/page'; import { type User, UserModel } from '../../models/user'; import { type Workspace, WorkspaceModel } from '../../models/workspace'; -import { createTestingModule, initTestingDB } from '../utils'; +import { createTestingModule, type TestingModule } from '../utils'; interface Context { config: Config; @@ -36,7 +35,7 @@ let user: User; let workspace: Workspace; test.beforeEach(async t => { - await initTestingDB(t.context.db); + await t.context.module.initTestingDB(); user = await t.context.user.create({ email: 'test@affine.pro', }); diff --git a/packages/backend/server/src/__tests__/models/session.spec.ts b/packages/backend/server/src/__tests__/models/session.spec.ts index bc91a58b07..83332aad13 100644 --- a/packages/backend/server/src/__tests__/models/session.spec.ts +++ b/packages/backend/server/src/__tests__/models/session.spec.ts @@ -1,11 +1,10 @@ -import { TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; import { Config } from '../../base/config'; import { SessionModel } from '../../models/session'; import { UserModel } from '../../models/user'; -import { createTestingModule, initTestingDB } from '../utils'; +import { createTestingModule, type TestingModule } from '../utils'; interface Context { config: Config; @@ -28,7 +27,7 @@ test.before(async t => { }); test.beforeEach(async t => { - await initTestingDB(t.context.db); + await t.context.module.initTestingDB(); }); test.after(async t => { diff --git a/packages/backend/server/src/__tests__/models/user.spec.ts b/packages/backend/server/src/__tests__/models/user.spec.ts index 0947ce4041..d48d629c7d 100644 --- a/packages/backend/server/src/__tests__/models/user.spec.ts +++ b/packages/backend/server/src/__tests__/models/user.spec.ts @@ -1,4 +1,3 @@ -import { TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; @@ -7,7 +6,7 @@ import { EmailAlreadyUsed, EventBus } from '../../base'; import { WorkspaceRole } from '../../core/permission'; import { UserModel } from '../../models/user'; import { WorkspaceMemberStatus } from '../../models/workspace'; -import { createTestingModule, initTestingDB } from '../utils'; +import { createTestingModule, type TestingModule } from '../utils'; interface Context { module: TestingModule; @@ -24,7 +23,7 @@ test.before(async t => { }); test.beforeEach(async t => { - await initTestingDB(t.context.module.get(PrismaClient)); + await t.context.module.initTestingDB(); }); test.after(async t => { diff --git a/packages/backend/server/src/__tests__/models/verification-token.spec.ts b/packages/backend/server/src/__tests__/models/verification-token.spec.ts index 30814dc06d..7e65244d1f 100644 --- a/packages/backend/server/src/__tests__/models/verification-token.spec.ts +++ b/packages/backend/server/src/__tests__/models/verification-token.spec.ts @@ -1,4 +1,3 @@ -import { TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; @@ -6,7 +5,7 @@ import { TokenType, VerificationTokenModel, } from '../../models/verification-token'; -import { createTestingModule, initTestingDB } from '../utils'; +import { createTestingModule, type TestingModule } from '../utils'; interface Context { module: TestingModule; @@ -25,7 +24,7 @@ test.before(async t => { }); test.beforeEach(async t => { - await initTestingDB(t.context.db); + await t.context.module.initTestingDB(); }); test.after(async t => { diff --git a/packages/backend/server/src/__tests__/models/workspace.spec.ts b/packages/backend/server/src/__tests__/models/workspace.spec.ts index 32ae94637f..435a277555 100644 --- a/packages/backend/server/src/__tests__/models/workspace.spec.ts +++ b/packages/backend/server/src/__tests__/models/workspace.spec.ts @@ -1,4 +1,3 @@ -import { TestingModule } from '@nestjs/testing'; import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; @@ -7,7 +6,7 @@ import { Config, EventBus } from '../../base'; import { WorkspaceRole } from '../../core/permission'; import { UserModel } from '../../models/user'; import { WorkspaceModel } from '../../models/workspace'; -import { createTestingModule, initTestingDB } from '../utils'; +import { createTestingModule, type TestingModule } from '../utils'; interface Context { config: Config; @@ -29,7 +28,7 @@ test.before(async t => { }); test.beforeEach(async t => { - await initTestingDB(t.context.db); + await t.context.module.initTestingDB(); }); test.after(async t => { diff --git a/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts b/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts index bf1ed49644..c139200067 100644 --- a/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts +++ b/packages/backend/server/src/__tests__/nestjs/throttler.spec.ts @@ -1,13 +1,6 @@ import '../../plugins/config'; -import { - Controller, - Get, - HttpStatus, - INestApplication, - UseGuards, -} from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { Controller, Get, HttpStatus, UseGuards } from '@nestjs/common'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; import request, { type Response } from 'supertest'; @@ -21,12 +14,12 @@ import { ThrottlerStorage, } from '../../base/throttler'; import { AuthService, Public } from '../../core/auth'; -import { createTestingApp, initTestingDB, internalSignIn } from '../utils'; +import { createTestingApp, internalSignIn, TestingApp } from '../utils'; const test = ava as TestFn<{ storage: ThrottlerStorage; cookie: string; - app: INestApplication; + app: TestingApp; }>; @UseGuards(CloudThrottlerGuard) @@ -115,7 +108,7 @@ test.before(async t => { }); test.beforeEach(async t => { - await initTestingDB(t.context.app.get(PrismaClient)); + await t.context.app.initTestingDB(); const { app } = t.context; const auth = app.get(AuthService); const u1 = await auth.signUp('u1@affine.pro', 'test'); diff --git a/packages/backend/server/src/__tests__/oauth/controller.spec.ts b/packages/backend/server/src/__tests__/oauth/controller.spec.ts index b7c363d039..5fa7668bbb 100644 --- a/packages/backend/server/src/__tests__/oauth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/oauth/controller.spec.ts @@ -1,6 +1,6 @@ import '../../plugins/config'; -import { HttpStatus, INestApplication } from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; @@ -15,7 +15,7 @@ import { Models } from '../../models'; import { OAuthProviderName } from '../../plugins/oauth/config'; import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google'; import { OAuthService } from '../../plugins/oauth/service'; -import { createTestingApp, getSession, initTestingDB } from '../utils'; +import { createTestingApp, getSession, TestingApp } from '../utils'; const test = ava as TestFn<{ auth: AuthService; @@ -23,7 +23,7 @@ const test = ava as TestFn<{ models: Models; u1: CurrentUser; db: PrismaClient; - app: INestApplication; + app: TestingApp; }>; test.before(async t => { @@ -54,7 +54,7 @@ test.before(async t => { test.beforeEach(async t => { Sinon.restore(); - await initTestingDB(t.context.db); + await t.context.app.initTestingDB(); 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(); }); -function mockOAuthProvider(app: INestApplication, email: string) { +function mockOAuthProvider(app: TestingApp, email: string) { const provider = app.get(GoogleOAuthProvider); const oauth = app.get(OAuthService); diff --git a/packages/backend/server/src/__tests__/payment/service.spec.ts b/packages/backend/server/src/__tests__/payment/service.spec.ts index 1e4ae8417e..202060a891 100644 --- a/packages/backend/server/src/__tests__/payment/service.spec.ts +++ b/packages/backend/server/src/__tests__/payment/service.spec.ts @@ -1,6 +1,5 @@ import '../../plugins/payment'; -import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import ava, { TestFn } from 'ava'; import Sinon from 'sinon'; @@ -11,7 +10,7 @@ import { EventBus, Runtime } from '../../base'; import { ConfigModule } from '../../base/config'; import { CurrentUser } from '../../core/auth'; 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 { CouponType, @@ -21,7 +20,7 @@ import { SubscriptionStatus, SubscriptionVariant, } from '../../plugins/payment/types'; -import { createTestingApp, initTestingDB } from '../utils'; +import { createTestingApp, type TestingApp } from '../utils'; const PRO_MONTHLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Monthly}`; const PRO_YEARLY = `${SubscriptionPlan.Pro}_${SubscriptionRecurring.Yearly}`; @@ -156,10 +155,10 @@ const sub: Stripe.Subscription = { const test = ava as TestFn<{ u1: CurrentUser; db: PrismaClient; - app: INestApplication; + app: TestingApp; service: SubscriptionService; event: Sinon.SinonStubbedInstance; - feature: Sinon.SinonStubbedInstance; + feature: Sinon.SinonStubbedInstance; runtime: Sinon.SinonStubbedInstance; stripe: { customers: Sinon.SinonStubbedInstance; @@ -200,8 +199,8 @@ test.before(async t => { AppModule, ], tapModule: m => { - m.overrideProvider(FeatureManagementService).useValue( - Sinon.createStubInstance(FeatureManagementService) + m.overrideProvider(FeatureService).useValue( + Sinon.createStubInstance(FeatureService) ); m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus)); m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime)); @@ -210,7 +209,7 @@ test.before(async t => { t.context.event = app.get(EventBus); 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.db = app.get(PrismaClient); t.context.app = app; @@ -232,7 +231,7 @@ test.before(async t => { test.beforeEach(async t => { 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'); await db.workspace.create({ diff --git a/packages/backend/server/src/__tests__/quota.spec.ts b/packages/backend/server/src/__tests__/quota.spec.ts deleted file mode 100644 index 6686f3e594..0000000000 --- a/packages/backend/server/src/__tests__/quota.spec.ts +++ /dev/null @@ -1,212 +0,0 @@ -/// - -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'); - } -}); diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index e48f3d50e1..3d9e5998bc 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto'; import { getCurrentMailMessageCount } from '@affine-test/kit/utils/cloud'; -import { INestApplication } from '@nestjs/common'; import { WorkspaceMemberStatus } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; @@ -14,8 +13,8 @@ import { EventBus } from '../base'; import { AuthService } from '../core/auth'; import { DocContentService } from '../core/doc-renderer'; import { PermissionService, WorkspaceRole } from '../core/permission'; -import { QuotaManagementService, QuotaService, QuotaType } from '../core/quota'; import { WorkspaceType } from '../core/workspaces'; +import { Models } from '../models'; import { acceptInviteById, approveMember, @@ -34,19 +33,19 @@ import { revokeUser, signUp, sleep, + TestingApp, UserAuthedType, } from './utils'; const test = ava as TestFn<{ - app: INestApplication; + app: TestingApp; auth: AuthService; event: Sinon.SinonStubbedInstance; - quota: QuotaService; - quotaManager: QuotaManagementService; + models: Models; permissions: PermissionService; }>; -test.beforeEach(async t => { +test.before(async t => { const { app } = await createTestingApp({ imports: [AppModule], tapModule: module => { @@ -67,17 +66,20 @@ test.beforeEach(async t => { t.context.app = app; t.context.auth = app.get(AuthService); t.context.event = app.get(EventBus); - t.context.quota = app.get(QuotaService); - t.context.quotaManager = app.get(QuotaManagementService); + t.context.models = app.get(Models); 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(); }); const init = async ( - app: INestApplication, + app: TestingApp, memberLimit = 10, prefix = randomUUID() ) => { @@ -87,17 +89,15 @@ const init = async ( `${prefix}owner@affine.pro`, '123456' ); + const models = app.get(Models); { - const quota = app.get(QuotaService); - await quota.switchUserQuota(owner.id, QuotaType.ProPlanV1); + await models.userFeature.add(owner.id, 'pro_plan_v1', 'test'); } const workspace = await createWorkspace(app, owner.token.token); const teamWorkspace = await createWorkspace(app, owner.token.token); { - const quota = app.get(QuotaManagementService); - await quota.addTeamWorkspace(teamWorkspace.id, 'test'); - await quota.updateWorkspaceConfig(teamWorkspace.id, QuotaType.TeamPlanV1, { + models.workspaceFeature.add(teamWorkspace.id, 'team_plan_v1', 'test', { 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 => { - const { app, permissions, quotaManager } = t.context; + const { app, permissions, models } = t.context; 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.' }, '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, }); 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 => { 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( grantMember( @@ -350,13 +350,13 @@ test('should be able to grant team member permission', async t => { await t.throwsAsync( grantMember( app, - admin.token.token, + write.token.token, ws.id, read.id, WorkspaceRole.Collaborator ), { 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 => { - const { app, permissions, quotaManager } = t.context; + const { app, permissions, models } = t.context; const { createInviteLink, owner, @@ -631,7 +631,7 @@ test('should be able to invite by link', async t => { 'should not change status' ); - await quotaManager.updateWorkspaceConfig(tws.id, QuotaType.TeamPlanV1, { + models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', { memberLimit: 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' ); - await quotaManager.updateWorkspaceConfig(tws.id, QuotaType.TeamPlanV1, { + models.workspaceFeature.add(tws.id, 'team_plan_v1', 'test', { memberLimit: 6, }); await permissions.refreshSeatStatus(tws.id, 6); diff --git a/packages/backend/server/src/__tests__/user.e2e.ts b/packages/backend/server/src/__tests__/user.e2e.ts index ef8c4fc4b5..3b41c0ef17 100644 --- a/packages/backend/server/src/__tests__/user.e2e.ts +++ b/packages/backend/server/src/__tests__/user.e2e.ts @@ -1,20 +1,23 @@ -import type { INestApplication } from '@nestjs/common'; import test from 'ava'; import request from 'supertest'; 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({ imports: [AppModule], }); app = testApp; }); -test.afterEach.always(async () => { +test.beforeEach(async () => { + await app.initTestingDB(); +}); + +test.after.always(async () => { await app.close(); }); diff --git a/packages/backend/server/src/__tests__/utils/utils.ts b/packages/backend/server/src/__tests__/utils/utils.ts index cb507b4c2e..43774f8e9e 100644 --- a/packages/backend/server/src/__tests__/utils/utils.ts +++ b/packages/backend/server/src/__tests__/utils/utils.ts @@ -3,9 +3,13 @@ import { INestApplication, ModuleMetadata, } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, ModuleRef } from '@nestjs/core'; 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 cookieParser from 'cookie-parser'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; @@ -16,7 +20,7 @@ import { AppModule, FunctionalityModules } from '../../app.module'; import { GlobalExceptionFilter, Runtime } from '../../base'; import { GqlModule } from '../../base/graphql'; 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'; 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 { tapModule?(m: TestingModuleBuilder): 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; +}; + +export type TestingApp = INestApplication & { + initTestingDB(): Promise; +}; + function dedupeModules(modules: NonNullable) { const map = new Map(); @@ -73,7 +82,7 @@ class MockResolver { export async function createTestingModule( moduleDef: TestingModuleMeatdata = {}, - init = true + autoInitialize = true ) { // setting up let imports = moduleDef.imports ?? []; @@ -107,13 +116,9 @@ export async function createTestingModule( const m = await builder.compile(); - const prisma = m.get(PrismaClient); - if (prisma instanceof PrismaClient) { - await initTestingDB(prisma); - } - - if (init) { - await m.init(); + const testingModule = m as TestingModule; + testingModule.initTestingDB = async () => { + await initTestingDB(m.get(ModuleRef)); // we got a lot smoking tests try to break nestjs // can't tolerate the noisy logs // @ts-expect-error private @@ -123,9 +128,14 @@ export async function createTestingModule( const runtime = m.get(Runtime); // by pass password min length validation 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 = {}) { @@ -135,7 +145,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { cors: true, bodyParser: true, rawBody: true, - }); + }) as TestingApp; const logger = new ConsoleLogger(); logger.setLogLevels(['fatal']); @@ -155,15 +165,14 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) { moduleDef.tapApp(app); } + await m.initTestingDB(); await app.init(); - const runtime = app.get(Runtime); - // by pass password min length validation - await runtime.set('auth/password.min', 1); + app.initTestingDB = m.initTestingDB.bind(m); return { module: m, - app, + app: app, }; } diff --git a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts index 78f7920618..b7765c6f57 100644 --- a/packages/backend/server/src/__tests__/workspace-invite.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace-invite.e2e.ts @@ -2,7 +2,6 @@ import { getCurrentMailMessageCount, getLatestMailMessage, } from '@affine-test/kit/utils/cloud'; -import type { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; @@ -20,17 +19,18 @@ import { leaveWorkspace, revokeUser, signUp, + TestingApp, } from './utils'; const test = ava as TestFn<{ - app: INestApplication; + app: TestingApp; client: PrismaClient; auth: AuthService; mail: MailService; models: Models; }>; -test.beforeEach(async t => { +test.before(async t => { const { app } = await createTestingApp({ imports: [AppModule], }); @@ -41,7 +41,11 @@ test.beforeEach(async t => { 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(); }); @@ -227,15 +231,12 @@ test('should support pagination for member', async t => { test('should limit member count correctly', async t => { const { app } = t.context; 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); - await Promise.allSettled( - Array.from({ length: 10 }).map(async (_, i) => - 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 workspace = await createWorkspace(app, u1.token.token); + await Promise.allSettled( + Array.from({ length: 10 }).map(async (_, i) => + 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'); }); diff --git a/packages/backend/server/src/__tests__/workspace.e2e.ts b/packages/backend/server/src/__tests__/workspace.e2e.ts index 2d269f6b95..c32ed40b57 100644 --- a/packages/backend/server/src/__tests__/workspace.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace.e2e.ts @@ -1,30 +1,28 @@ -import type { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import type { TestFn } from 'ava'; import ava from 'ava'; import request from 'supertest'; import { AppModule } from '../app.module'; -import { WorkspaceRole } from '../core/permission/types'; import { acceptInviteById, createTestingApp, createWorkspace, getWorkspacePublicPages, - grantMember, inviteUser, publishPage, revokePublicPage, signUp, + TestingApp, updateWorkspace, } from './utils'; const test = ava as TestFn<{ - app: INestApplication; + app: TestingApp; client: PrismaClient; }>; -test.beforeEach(async t => { +test.before(async t => { const { app } = await createTestingApp({ imports: [AppModule], }); @@ -33,7 +31,11 @@ test.beforeEach(async t => { 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(); }); @@ -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.', '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( app, u1.token.token, @@ -168,9 +146,7 @@ test('should share a page', async t => { u1.token.token, workspace.id ); - t.is(pages2.length, 1, 'failed to get shared pages'); - t.is(pages2[0].id, 'page2', 'failed to get shared page: page2'); - + t.is(pages2.length, 0, 'failed to get shared pages'); const msg4 = await revokePublicPage( app, u1.token.token, @@ -179,19 +155,12 @@ test('should share a page', async t => { ); t.is(msg4, 'Page is not public'); - const revoked = await revokePublicPage( - app, - u1.token.token, - workspace.id, - 'page2' - ); - t.false(revoked.public, 'failed to revoke page'); - const page3 = await getWorkspacePublicPages( + const pages3 = await getWorkspacePublicPages( app, u1.token.token, 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 => { diff --git a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts index 873c8c46ca..62beaabca8 100644 --- a/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts +++ b/packages/backend/server/src/__tests__/workspace/blobs.e2e.ts @@ -1,10 +1,8 @@ -import type { INestApplication } from '@nestjs/common'; import test from 'ava'; import request from 'supertest'; import { AppModule } from '../../app.module'; -import { FeatureManagementService, FeatureType } from '../../core/features'; -import { QuotaService, QuotaType } from '../../core/quota'; +import { WorkspaceFeatureModel } from '../../models'; import { collectAllBlobSizes, createTestingApp, @@ -13,25 +11,35 @@ import { listBlobs, setBlob, signUp, + TestingApp, } from '../utils'; const OneMB = 1024 * 1024; +const RESTRICTED_QUOTA = { + seatQuota: 0, + blobLimit: OneMB, + storageQuota: 2 * OneMB - 1, + historyPeriod: 1, + memberLimit: 1, +}; -let app: INestApplication; -let quota: QuotaService; -let feature: FeatureManagementService; +let app: TestingApp; +let model: WorkspaceFeatureModel; -test.beforeEach(async () => { +test.before(async () => { const { app: testApp } = await createTestingApp({ imports: [AppModule], }); app = testApp; - quota = app.get(QuotaService); - feature = app.get(FeatureManagementService); + model = app.get(WorkspaceFeatureModel); }); -test.afterEach.always(async () => { +test.beforeEach(async () => { + await app.initTestingDB(); +}); + +test.after.always(async () => { 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 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 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 => { const u1 = await signUp(app, 'darksky', 'darksky@affine.pro', '1'); 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)); - 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)); }); @@ -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 workspace = await createWorkspace(app, u1.token.token); - await quota.switchUserQuota(u1.id, QuotaType.RestrictedPlanV1); - feature.addWorkspaceFeatures(workspace.id, FeatureType.UnlimitedWorkspace); + await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA); + await model.add(workspace.id, 'unlimited_workspace', 'test'); 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.notThrowsAsync(setBlob(app, u1.token.token, workspace.id, buffer)); }); diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index 2c1b85e3ea..578dcbb296 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -478,6 +478,10 @@ export const USER_FRIENDLY_ERRORS = { type: 'internal_server_error', 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 unsupported_subscription_plan: { diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index e578cd9fc8..3d12ed1638 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -399,6 +399,12 @@ export class FailedToUpsertSnapshot extends UserFriendlyError { 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() class UnsupportedSubscriptionPlanDataType { @Field() plan!: string @@ -754,6 +760,7 @@ export enum ErrorNames { PAGE_IS_NOT_PUBLIC, FAILED_TO_SAVE_UPDATES, FAILED_TO_UPSERT_SNAPSHOT, + ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE, UNSUPPORTED_SUBSCRIPTION_PLAN, FAILED_TO_CHECKOUT, INVALID_CHECKOUT_PARAMETERS, diff --git a/packages/backend/server/src/base/index.ts b/packages/backend/server/src/base/index.ts index d6ad06de8a..599a19a754 100644 --- a/packages/backend/server/src/base/index.ts +++ b/packages/backend/server/src/base/index.ts @@ -35,10 +35,4 @@ export { Runtime } from './runtime'; export * from './storage'; export { type StorageProvider, StorageProviderFactory } from './storage'; export { CloudThrottlerGuard, SkipThrottle, Throttle } from './throttler'; -export { - getRequestFromHost, - getRequestResponseFromContext, - getRequestResponseFromHost, - parseCookies, -} from './utils/request'; -export * from './utils/types'; +export * from './utils'; diff --git a/packages/backend/server/src/base/runtime/service.ts b/packages/backend/server/src/base/runtime/service.ts index 68275f2980..9f401f21c4 100644 --- a/packages/backend/server/src/base/runtime/service.ts +++ b/packages/backend/server/src/base/runtime/service.ts @@ -126,14 +126,19 @@ export class Runtime implements OnModuleInit { V = FlattenedAppRuntimeConfig[K], >(key: K, value: V) { validateConfigType(key, value); - const config = await this.db.runtimeConfig.update({ + const config = await this.db.runtimeConfig.upsert({ where: { id: key, deletedAt: null, }, - data: { + create: { + ...defaultRuntimeConfig[key], value: value as any, }, + update: { + value: value as any, + deletedAt: null, + }, }); await this.setCache(key, config.value as FlattenedAppRuntimeConfig[K]); diff --git a/packages/backend/server/src/base/utils/index.ts b/packages/backend/server/src/base/utils/index.ts new file mode 100644 index 0000000000..09cc714ed4 --- /dev/null +++ b/packages/backend/server/src/base/utils/index.ts @@ -0,0 +1,4 @@ +export * from './promise'; +export * from './request'; +export * from './types'; +export * from './unit'; diff --git a/packages/backend/server/src/core/quota/constant.ts b/packages/backend/server/src/base/utils/unit.ts similarity index 64% rename from packages/backend/server/src/core/quota/constant.ts rename to packages/backend/server/src/base/utils/unit.ts index e6fb0d25a0..f0798822b6 100644 --- a/packages/backend/server/src/core/quota/constant.ts +++ b/packages/backend/server/src/base/utils/unit.ts @@ -2,4 +2,3 @@ export const OneKB = 1024; export const OneMB = OneKB * OneKB; export const OneGB = OneKB * OneMB; export const OneDay = 1000 * 60 * 60 * 24; -export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 59ca9197c0..1526e32ffb 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -4,9 +4,7 @@ import { assign, pick } from 'lodash-es'; import { Config, MailService, SignUpForbidden } from '../../base'; import { Models, type User, type UserSession } from '../../models'; -import { FeatureManagementService } from '../features/management'; -import { QuotaService } from '../quota/service'; -import { QuotaType } from '../quota/types'; +import { FeatureService } from '../features'; import type { CurrentUser } from './session'; export function sessionUser( @@ -45,8 +43,7 @@ export class AuthService implements OnApplicationBootstrap { private readonly config: Config, private readonly models: Models, private readonly mailer: MailService, - private readonly feature: FeatureManagementService, - private readonly quota: QuotaService + private readonly feature: FeatureService ) {} async onApplicationBootstrap() { @@ -61,17 +58,24 @@ export class AuthService implements OnApplicationBootstrap { password, }); } - await this.quota.switchUserQuota(devUser.id, QuotaType.ProPlanV1); - await this.feature.addAdmin(devUser.id); - await this.feature.addCopilot(devUser.id); + await this.models.userFeature.add( + devUser.id, + 'administrator', + 'dev user' + ); + await this.models.userFeature.add( + devUser.id, + 'unlimited_copilot', + 'dev user' + ); } catch { // ignore } } } - canSignIn(email: string) { - return this.feature.canEarlyAccess(email); + async canSignIn(email: string) { + return await this.feature.canEarlyAccess(email); } /** diff --git a/packages/backend/server/src/core/common/admin-guard.ts b/packages/backend/server/src/core/common/admin-guard.ts index 062716f48d..f964199f10 100644 --- a/packages/backend/server/src/core/common/admin-guard.ts +++ b/packages/backend/server/src/core/common/admin-guard.ts @@ -7,16 +7,16 @@ import { Injectable, UseGuards } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { ActionForbidden, getRequestResponseFromContext } from '../../base'; -import { FeatureManagementService } from '../features/management'; +import { FeatureService } from '../features/service'; @Injectable() export class AdminGuard implements CanActivate, OnModuleInit { - private feature!: FeatureManagementService; + private feature!: FeatureService; constructor(private readonly ref: ModuleRef) {} onModuleInit() { - this.feature = this.ref.get(FeatureManagementService, { strict: false }); + this.feature = this.ref.get(FeatureService, { strict: false }); } async canActivate(context: ExecutionContext) { diff --git a/packages/backend/server/src/core/config/resolver.ts b/packages/backend/server/src/core/config/resolver.ts index ef64eae24c..ed6d6c3ba1 100644 --- a/packages/backend/server/src/core/config/resolver.ts +++ b/packages/backend/server/src/core/config/resolver.ts @@ -13,10 +13,10 @@ import { RuntimeConfig, RuntimeConfigType } from '@prisma/client'; import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars'; import { Config, Runtime, URLHelper } from '../../base'; +import { Feature } from '../../models'; import { Public } from '../auth'; import { Admin } from '../common'; -import { FeatureType } from '../features'; -import { AvailableUserFeatureConfig } from '../features/resolver'; +import { AvailableUserFeatureConfig } from '../features'; import { ServerFlags } from './config'; import { ENABLED_FEATURES } from './server-feature'; import { ServerService } from './service'; @@ -139,11 +139,7 @@ export class ServerConfigResolver { @Resolver(() => ServerConfigType) export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig { - constructor(config: Config) { - super(config); - } - - @ResolveField(() => [FeatureType], { + @ResolveField(() => [Feature], { description: 'Features for user that can be configured', }) override availableUserFeatures() { diff --git a/packages/backend/server/src/core/doc/options.ts b/packages/backend/server/src/core/doc/options.ts index 9f9dd1c2b3..807887aa7b 100644 --- a/packages/backend/server/src/core/doc/options.ts +++ b/packages/backend/server/src/core/doc/options.ts @@ -89,7 +89,7 @@ export class DocStorageOptions implements IDocStorageOptions { historyMaxAge = async (spaceId: string) => { const owner = await this.permission.getWorkspaceOwner(spaceId); const quota = await this.quota.getUserQuota(owner.id); - return quota.feature.historyPeriod; + return quota.historyPeriod; }; historyMinInterval = (_spaceId: string) => { diff --git a/packages/backend/server/src/core/features/feature.ts b/packages/backend/server/src/core/features/feature.ts deleted file mode 100644 index bc5a554b56..0000000000 --- a/packages/backend/server/src/core/features/feature.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { PrismaTransaction } from '../../base'; -import { Feature, FeatureSchema, FeatureType } from './types'; - -class FeatureConfig { - 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 = FeatureConfig; - -const FeatureCache = new Map>(); - -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; -} diff --git a/packages/backend/server/src/core/features/index.ts b/packages/backend/server/src/core/features/index.ts index 0d953e3235..a36aa08e00 100644 --- a/packages/backend/server/src/core/features/index.ts +++ b/packages/backend/server/src/core/features/index.ts @@ -1,38 +1,20 @@ import { Module } from '@nestjs/common'; -import { UserModule } from '../user'; -import { EarlyAccessType, FeatureManagementService } from './management'; import { AdminFeatureManagementResolver, - FeatureManagementResolver, + UserFeatureResolver, } 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({ - imports: [UserModule], providers: [ - FeatureService, - FeatureManagementService, - FeatureManagementResolver, + UserFeatureResolver, AdminFeatureManagementResolver, + FeatureService, ], - exports: [FeatureService, FeatureManagementService], + exports: [FeatureService], }) export class FeatureModule {} -export type { FeatureConfigType } from './feature'; -export { - type CommonFeature, - commonFeatureSchema, - type FeatureConfig, - FeatureKind, - Features, - FeatureType, -} from './types'; -export { EarlyAccessType, FeatureManagementService, FeatureService }; +export { EarlyAccessType, FeatureService }; +export { AvailableUserFeatureConfig } from './types'; diff --git a/packages/backend/server/src/core/features/management.ts b/packages/backend/server/src/core/features/management.ts deleted file mode 100644 index 898c139e58..0000000000 --- a/packages/backend/server/src/core/features/management.ts +++ /dev/null @@ -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 { - 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); - } -} diff --git a/packages/backend/server/src/core/features/resolver.ts b/packages/backend/server/src/core/features/resolver.ts index cc672c27c0..90d8973080 100644 --- a/packages/backend/server/src/core/features/resolver.ts +++ b/packages/backend/server/src/core/features/resolver.ts @@ -8,70 +8,91 @@ import { } from '@nestjs/graphql'; import { difference } from 'lodash-es'; -import { Config } from '../../base'; +import { + Feature, + Models, + type UserFeatureName, + type WorkspaceFeatureName, +} from '../../models'; import { Admin } from '../common'; import { UserType } from '../user/types'; -import { EarlyAccessType, FeatureManagementService } from './management'; -import { FeatureService } from './service'; -import { FeatureType } from './types'; +import { AvailableUserFeatureConfig } from './types'; -registerEnumType(EarlyAccessType, { - name: 'EarlyAccessType', +registerEnumType(Feature, { + name: 'FeatureType', }); @Resolver(() => UserType) -export class FeatureManagementResolver { - constructor(private readonly feature: FeatureManagementService) {} +export class UserFeatureResolver extends AvailableUserFeatureConfig { + constructor(private readonly models: Models) { + super(); + } - @ResolveField(() => [FeatureType], { + @ResolveField(() => [Feature], { name: 'features', description: 'Enabled features of a user', }) async userFeatures(@Parent() user: UserType) { - return this.feature.getActivatedUserFeatures(user.id); - } -} - -export class AvailableUserFeatureConfig { - constructor(private readonly config: Config) {} - - async availableUserFeatures() { - return this.config.isSelfhosted - ? [FeatureType.Admin, FeatureType.UnlimitedCopilot] - : [FeatureType.EarlyAccess, FeatureType.AIEarlyAccess, FeatureType.Admin]; + const features = await this.models.userFeature.list(user.id); + const availableUserFeatures = this.availableUserFeatures(); + return features.filter(feature => availableUserFeatures.has(feature)); } } @Admin() @Resolver(() => Boolean) export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig { - constructor( - config: Config, - private readonly feature: FeatureService - ) { - super(config); + constructor(private readonly models: Models) { + super(); } - @Mutation(() => [FeatureType], { + @Mutation(() => [Feature], { description: 'update user enabled feature', }) async updateUserFeatures( @Args('id') id: string, - @Args({ name: 'features', type: () => [FeatureType] }) - features: FeatureType[] + @Args({ name: 'features', type: () => [Feature] }) + 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( - features.map(feature => - this.feature.addUserFeature(id, feature, 'admin panel') - ) + features.map(async feature => { + if (configurableUserFeatures.has(feature)) { + return this.models.userFeature.add(id, feature, 'admin panel'); + } else { + return; + } + }) ); + await Promise.all( - removed.map(feature => this.feature.removeUserFeature(id, feature)) + removed.map(feature => this.models.userFeature.remove(id, feature)) ); 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; + } } diff --git a/packages/backend/server/src/core/features/service.ts b/packages/backend/server/src/core/features/service.ts index a5a9fec2ef..deb9879395 100644 --- a/packages/backend/server/src/core/features/service.ts +++ b/packages/backend/server/src/core/features/service.ts @@ -1,355 +1,90 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { Injectable, Logger } from '@nestjs/common'; -import { CannotDeleteAllAdminAccount } from '../../base'; -import { WorkspaceFeatureType } from '../workspaces/types'; -import { FeatureConfigType, getFeature } from './feature'; -import { FeatureKind, FeatureType } from './types'; +import { Runtime } from '../../base'; +import { Models } from '../../models'; + +const STAFF = ['@toeverything.info', '@affine.pro']; + +export enum EarlyAccessType { + App = 'app', + AI = 'ai', +} @Injectable() export class FeatureService { - constructor(private readonly prisma: PrismaClient) {} + protected logger = new Logger(FeatureService.name); - async getFeature(feature: F) { - const data = await this.prisma.feature.findFirst({ - where: { feature, type: FeatureKind.Feature }, - select: { id: true }, - orderBy: { version: 'desc' }, - }); + constructor( + private readonly models: Models, + private readonly runtime: Runtime + ) {} - if (data) { - return getFeature(this.prisma, data.id) as Promise>; + // ======== Admin ======== + 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, - feature: FeatureType, - reason: string, - expiredAt?: Date | string + type: EarlyAccessType = EarlyAccessType.App ) { - return this.prisma.$transaction(async tx => { - const latestFlag = await tx.userFeature.findFirst({ - where: { - userId, - 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 this.models.userFeature.add( + userId, + type === EarlyAccessType.App ? 'early_access' : 'ai_early_access', + 'Early access user' ); - - return configs.filter(feature => !!feature.feature); } - async getUserActivatedFeatures(userId: string) { - const features = await this.prisma.userFeature.findMany({ - where: { - 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 + async removeEarlyAccess( + userId: string, + type: EarlyAccessType = EarlyAccessType.App ) { - return this.prisma.$transaction(async tx => { - const latestFlag = await tx.workspaceFeature.findFirst({ - where: { - 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); - } - }); + return this.models.userFeature.remove( + userId, + type === EarlyAccessType.App ? 'early_access' : 'ai_early_access' + ); } - async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) { - return this.prisma.workspaceFeature - .updateMany({ - where: { - workspaceId, - feature: { - feature, - type: FeatureKind.Feature, - }, - activated: true, - }, - data: { - activated: false, - }, - }) - .then(r => r.count); + async isEarlyAccessUser( + userId: string, + type: EarlyAccessType = EarlyAccessType.App + ) { + return await this.models.userFeature.has( + userId, + type === EarlyAccessType.App ? 'early_access' : 'ai_early_access' + ); } - /** - * get workspace's features, will included inactivated features - * @param workspaceId workspace id - * @returns list of features - */ - async getWorkspaceFeatures(workspaceId: string) { - 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), - })) + async canEarlyAccess( + email: string, + type: EarlyAccessType = EarlyAccessType.App + ) { + const earlyAccessControlEnabled = await this.runtime.fetch( + 'flags/earlyAccessControl' ); - return configs.filter(feature => !!feature.feature); - } - - async listWorkspacesByFeature( - feature: FeatureType - ): Promise { - return this.prisma.workspaceFeature - .findMany({ - 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); + 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; + } } } diff --git a/packages/backend/server/src/core/features/types.ts b/packages/backend/server/src/core/features/types.ts new file mode 100644 index 0000000000..08db922999 --- /dev/null +++ b/packages/backend/server/src/core/features/types.ts @@ -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 { + return new Set([ + Feature.Admin, + Feature.UnlimitedCopilot, + Feature.EarlyAccess, + Feature.AIEarlyAccess, + ]); + } + + configurableUserFeatures(): Set { + return new Set( + this.config.isSelfhosted + ? [Feature.Admin, Feature.UnlimitedCopilot] + : [ + Feature.EarlyAccess, + Feature.AIEarlyAccess, + Feature.Admin, + Feature.UnlimitedCopilot, + ] + ); + } +} diff --git a/packages/backend/server/src/core/features/types/admin.ts b/packages/backend/server/src/core/features/types/admin.ts deleted file mode 100644 index 4896415184..0000000000 --- a/packages/backend/server/src/core/features/types/admin.ts +++ /dev/null @@ -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({}), -}); diff --git a/packages/backend/server/src/core/features/types/common.ts b/packages/backend/server/src/core/features/types/common.ts deleted file mode 100644 index 4cdfa05394..0000000000 --- a/packages/backend/server/src/core/features/types/common.ts +++ /dev/null @@ -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', -}); diff --git a/packages/backend/server/src/core/features/types/copilot.ts b/packages/backend/server/src/core/features/types/copilot.ts deleted file mode 100644 index 0a58096354..0000000000 --- a/packages/backend/server/src/core/features/types/copilot.ts +++ /dev/null @@ -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({}), -}); diff --git a/packages/backend/server/src/core/features/types/early-access.ts b/packages/backend/server/src/core/features/types/early-access.ts deleted file mode 100644 index bad8a9ea84..0000000000 --- a/packages/backend/server/src/core/features/types/early-access.ts +++ /dev/null @@ -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({}), -}); diff --git a/packages/backend/server/src/core/features/types/index.ts b/packages/backend/server/src/core/features/types/index.ts deleted file mode 100644 index 4623deb986..0000000000 --- a/packages/backend/server/src/core/features/types/index.ts +++ /dev/null @@ -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; - -/// ======== 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 = (z.infer< - typeof FeatureConfigSchema -> & { feature: F })['configs']; - -export type Feature = z.infer; - -export { FeatureType }; diff --git a/packages/backend/server/src/core/features/types/unlimited-copilot.ts b/packages/backend/server/src/core/features/types/unlimited-copilot.ts deleted file mode 100644 index fd69e791a6..0000000000 --- a/packages/backend/server/src/core/features/types/unlimited-copilot.ts +++ /dev/null @@ -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({}), -}); diff --git a/packages/backend/server/src/core/features/types/unlimited-workspace.ts b/packages/backend/server/src/core/features/types/unlimited-workspace.ts deleted file mode 100644 index b9b471e9e1..0000000000 --- a/packages/backend/server/src/core/features/types/unlimited-workspace.ts +++ /dev/null @@ -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({}), -}); diff --git a/packages/backend/server/src/core/quota/index.ts b/packages/backend/server/src/core/quota/index.ts index 6564566121..1fc7791f85 100644 --- a/packages/backend/server/src/core/quota/index.ts +++ b/packages/backend/server/src/core/quota/index.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; -import { FeatureModule } from '../features'; import { PermissionModule } from '../permission'; import { StorageModule } from '../storage'; -import { QuotaManagementResolver } from './resolver'; +import { QuotaResolver } from './resolver'; import { QuotaService } from './service'; -import { QuotaManagementService } from './storage'; /** * Quota module provider pre-user quota management. @@ -14,18 +12,11 @@ import { QuotaManagementService } from './storage'; * - quota statistics */ @Module({ - imports: [FeatureModule, StorageModule, PermissionModule], - providers: [QuotaService, QuotaManagementResolver, QuotaManagementService], - exports: [QuotaService, QuotaManagementService], + imports: [StorageModule, PermissionModule], + providers: [QuotaService, QuotaResolver], + exports: [QuotaService], }) export class QuotaModule {} -export { QuotaManagementService, QuotaService }; -export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema'; -export { - formatDate, - formatSize, - type QuotaBusinessType, - QuotaQueryType, - QuotaType, -} from './types'; +export { QuotaService }; +export { WorkspaceQuotaHumanReadableType, WorkspaceQuotaType } from './types'; diff --git a/packages/backend/server/src/core/quota/quota.ts b/packages/backend/server/src/core/quota/quota.ts deleted file mode 100644 index 1930f0c136..0000000000 --- a/packages/backend/server/src/core/quota/quota.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { pick } from 'lodash-es'; - -import { PrismaTransaction } from '../../base'; -import { formatDate, formatSize, Quota, QuotaSchema } from './types'; - -const QuotaCache = new Map(); - -export class QuotaConfig { - readonly config: Quota; - readonly override?: Partial; - - 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', - }; - } -} diff --git a/packages/backend/server/src/core/quota/resolver.ts b/packages/backend/server/src/core/quota/resolver.ts index 309f43833a..f287afd37d 100644 --- a/packages/backend/server/src/core/quota/resolver.ts +++ b/packages/backend/server/src/core/quota/resolver.ts @@ -1,86 +1,29 @@ -import { - Field, - ObjectType, - registerEnumType, - ResolveField, - Resolver, -} from '@nestjs/graphql'; -import { SafeIntResolver } from 'graphql-scalars'; +import { ResolveField, Resolver } from '@nestjs/graphql'; import { CurrentUser } from '../auth/session'; -import { EarlyAccessType } from '../features'; import { UserType } from '../user'; import { QuotaService } from './service'; -import { QuotaManagementService } from './storage'; - -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; -} +import { UserQuotaType, UserQuotaUsageType } from './types'; @Resolver(() => UserType) -export class QuotaManagementResolver { - constructor( - private readonly quota: QuotaService, - private readonly management: QuotaManagementService - ) {} +export class QuotaResolver { + constructor(private readonly quota: QuotaService) {} - @ResolveField(() => UserQuotaType, { name: 'quota', nullable: true }) - async getQuota(@CurrentUser() me: UserType) { - const quota = await this.quota.getUserQuota(me.id); + @ResolveField(() => UserQuotaType, { name: 'quota' }) + async getQuota(@CurrentUser() me: UserType): Promise { + const quota = await this.quota.getUserQuotaWithUsage(me.id); - return quota.feature; + return { + ...quota, + humanReadable: this.quota.formatUserQuota(quota), + }; } @ResolveField(() => UserQuotaUsageType, { name: 'quotaUsage' }) async getQuotaUsage( @CurrentUser() me: UserType ): Promise { - const usage = await this.management.getUserStorageUsage(me.id); + const usage = await this.quota.getUserStorageUsage(me.id); return { storageQuota: usage, diff --git a/packages/backend/server/src/core/quota/schema.ts b/packages/backend/server/src/core/quota/schema.ts deleted file mode 100644 index 535f43e854..0000000000 --- a/packages/backend/server/src/core/quota/schema.ts +++ /dev/null @@ -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(type: Q): Quota { - const quota = Quotas.filter(f => f.feature === type); - quota.sort((a, b) => b.version - a.version); - return quota[0] as Quota; -} - -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, -}; diff --git a/packages/backend/server/src/core/quota/service.ts b/packages/backend/server/src/core/quota/service.ts index e782603de4..a7e47c3888 100644 --- a/packages/backend/server/src/core/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -1,329 +1,269 @@ -import { Injectable } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { Injectable, Logger } from '@nestjs/common'; -import { PrismaTransaction } from '../../base'; -import { FeatureKind } from '../features/types'; -import { QuotaConfig } from './quota'; -import { QuotaType } from './types'; +import { InternalServerError, MemberQuotaExceeded, OnEvent } from '../../base'; +import { + Models, + 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; +type WorkspaceQuota = Omit & { + ownerQuota?: string; +}; +type WorkspaceQuotaWithUsage = Omit; @Injectable() export class QuotaService { - constructor(private readonly prisma: PrismaClient) {} + protected logger = new Logger(QuotaService.name); - async getQuota( - quota: Q, - tx?: PrismaTransaction - ): Promise { - const executor = tx ?? this.prisma; + constructor( + private readonly models: Models, + private readonly permissions: PermissionService, + private readonly storage: WorkspaceBlobStorage + ) {} - const data = await executor.feature.findFirst({ - where: { feature: quota, type: FeatureKind.Quota }, - select: { id: true }, - orderBy: { version: 'desc' }, - }); - - if (data) { - return QuotaConfig.get(this.prisma, data.id); - } - return undefined; + @OnEvent('user.postCreated') + async onUserCreated({ id }: Events['user.postCreated']) { + await this.setupUserBaseQuota(id); } - // ======== User Quota ======== - - // 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, - }, - }); + async getUserQuota(userId: string): Promise { + let quota = await this.models.userFeature.getQuota(userId); + // not possible, but just in case, we do a little fix for user to avoid system dump if (!quota) { - // this should unreachable - throw new Error(`User ${userId} has no quota`); + await this.setupUserBaseQuota(userId); + quota = await this.models.userFeature.getQuota(userId); } - const feature = await QuotaConfig.get(this.prisma, quota.featureId); - return { ...quota, feature }; - } - - // 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; - }; - }) + const unlimitedCopilot = await this.models.userFeature.has( + userId, + 'unlimited_copilot' ); - return configs.filter(quota => !!quota); - } - - // 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 { - 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( - workspaceId: string, - type: Q - ): Promise { - 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'}` + if (!quota) { + throw new InternalServerError( + 'User quota not found and can not be created.' ); } - const r = await this.prisma.workspaceFeature.updateMany({ - where: { - workspaceId, - feature: { feature: quota, type: FeatureKind.Quota }, - activated: true, - }, - data: { configs }, - }); - return r.count; + + return { + ...quota.configs, + copilotActionLimit: unlimitedCopilot + ? undefined + : quota.configs.copilotActionLimit, + } as UserQuotaWithUsage; + } + + async getUserQuotaWithUsage(userId: string): Promise { + 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 { + 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 { + 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 + ): 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 + ): 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; } } diff --git a/packages/backend/server/src/core/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts deleted file mode 100644 index 0f3a84bb0d..0000000000 --- a/packages/backend/server/src/core/quota/storage.ts +++ /dev/null @@ -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( - workspaceId: string, - quota: Q - ): Promise { - return this.quota.getWorkspaceConfig(workspaceId, quota); - } - - async updateWorkspaceConfig( - workspaceId: string, - quota: Q, - configs: Partial['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 { - 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); - } -} diff --git a/packages/backend/server/src/core/quota/types.ts b/packages/backend/server/src/core/quota/types.ts index 122be3bb33..fbe6f41ddc 100644 --- a/packages/backend/server/src/core/quota/types.ts +++ b/packages/backend/server/src/core/quota/types.ts @@ -1,142 +1,123 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { SafeIntResolver } from 'graphql-scalars'; -import { z } from 'zod'; -import { commonFeatureSchema, FeatureKind } from '../features/types'; -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 = z.infer< - typeof QuotaSchema -> & { feature: Q }; -export type QuotaConfigType = Quota['configs']; - -/// ======== query types ======== +import { UserQuota, WorkspaceQuota } from '../../models'; @ObjectType() -export class HumanReadableQuotaType { - @Field(() => String) +export class UserQuotaHumanReadableType { + @Field() name!: string; - @Field(() => String) + @Field() blobLimit!: string; - @Field(() => String) + @Field() storageQuota!: string; - @Field(() => String) + @Field() + usedStorageQuota!: string; + + @Field() historyPeriod!: string; - @Field(() => String) + @Field() memberLimit!: string; - @Field(() => String, { nullable: true }) - copilotActionLimit?: string; + @Field() + copilotActionLimit!: string; } @ObjectType() -export class QuotaQueryType { - @Field(() => String) +export class UserQuotaType implements UserQuota { + @Field() name!: string; @Field(() => SafeIntResolver) blobLimit!: number; + @Field(() => SafeIntResolver) + storageQuota!: number; + + @Field(() => SafeIntResolver) + usedStorageQuota!: number; + @Field(() => SafeIntResolver) historyPeriod!: number; - @Field(() => SafeIntResolver) + @Field() 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 { + @Field() + name!: string; + @Field(() => SafeIntResolver) - memberCount!: number; + blobLimit!: number; @Field(() => SafeIntResolver) storageQuota!: number; - @Field(() => SafeIntResolver, { nullable: true }) - copilotActionLimit?: number; - - @Field(() => HumanReadableQuotaType) - humanReadable!: HumanReadableQuotaType; + @Field(() => SafeIntResolver) + usedStorageQuota!: number; @Field(() => SafeIntResolver) + historyPeriod!: number; + + @Field() + memberLimit!: number; + + @Field() + memberCount!: number; + + @Field() + humanReadable!: WorkspaceQuotaHumanReadableType; + + /** + * @deprecated + */ + @Field(() => SafeIntResolver, { + deprecationReason: 'use `usedStorageQuota` instead', + }) 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; -}; diff --git a/packages/backend/server/src/core/quota/utils.ts b/packages/backend/server/src/core/quota/utils.ts new file mode 100644 index 0000000000..b4688799d1 --- /dev/null +++ b/packages/backend/server/src/core/quota/utils.ts @@ -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`; +} diff --git a/packages/backend/server/src/core/storage/wrappers/blob.ts b/packages/backend/server/src/core/storage/wrappers/blob.ts index caf8ca972b..5c3959fa55 100644 --- a/packages/backend/server/src/core/storage/wrappers/blob.ts +++ b/packages/backend/server/src/core/storage/wrappers/blob.ts @@ -112,8 +112,17 @@ export class WorkspaceBlobStorage { } async totalSize(workspaceId: string) { - const blobs = await this.list(workspaceId); - return blobs.reduce((acc, item) => acc + item.size, 0); + const sum = await this.db.blob.aggregate({ + where: { + workspaceId, + deletedAt: null, + }, + _sum: { + size: true, + }, + }); + + return sum._sum.size ?? 0; } private trySyncBlobsMeta(workspaceId: string, blobs: ListObjectsMetadata[]) { diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index 2aaa12a974..f284de71db 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -8,7 +8,6 @@ import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; import { UserModule } from '../user'; import { WorkspacesController } from './controller'; -import { WorkspaceManagementResolver } from './management'; import { DocHistoryResolver, PagePermissionResolver, @@ -32,7 +31,6 @@ import { providers: [ WorkspaceResolver, TeamWorkspaceResolver, - WorkspaceManagementResolver, PagePermissionResolver, DocHistoryResolver, WorkspaceBlobResolver, diff --git a/packages/backend/server/src/core/workspaces/management.ts b/packages/backend/server/src/core/workspaces/management.ts deleted file mode 100644 index 95209c87b9..0000000000 --- a/packages/backend/server/src/core/workspaces/management.ts +++ /dev/null @@ -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 { - return this.feature.addWorkspaceFeatures(workspaceId, feature); - } - - @Admin() - @Mutation(() => Int) - async removeWorkspaceFeature( - @Args('workspaceId') workspaceId: string, - @Args('feature', { type: () => FeatureType }) feature: FeatureType - ): Promise { - return this.feature.removeWorkspaceFeature(workspaceId, feature); - } - - @Admin() - @Query(() => [WorkspaceFeatureType]) - async listWorkspaceFeatures( - @Args('feature', { type: () => FeatureType }) feature: FeatureType - ): Promise { - 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 { - 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 { - return await this.feature.getActivatedUserFeatures(user.id); - } - - @ResolveField(() => [FeatureType], { - description: 'Enabled features of workspace', - complexity: 2, - }) - async features(@Parent() workspace: WorkspaceType): Promise { - return this.feature.getWorkspaceFeatures(workspace.id); - } -} diff --git a/packages/backend/server/src/core/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts index dac1d747de..a9633ce8fc 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/blob.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/blob.ts @@ -16,7 +16,7 @@ import type { FileUpload } from '../../../base'; import { BlobQuotaExceeded, CloudThrottlerGuard } from '../../../base'; import { CurrentUser } from '../../auth'; import { PermissionService, WorkspaceRole } from '../../permission'; -import { QuotaManagementService } from '../../quota'; +import { QuotaService } from '../../quota'; import { WorkspaceBlobStorage } from '../../storage'; import { WorkspaceBlobSizes, WorkspaceType } from '../types'; @@ -41,7 +41,7 @@ export class WorkspaceBlobResolver { logger = new Logger(WorkspaceBlobResolver.name); constructor( private readonly permissions: PermissionService, - private readonly quota: QuotaManagementService, + private readonly quota: QuotaService, private readonly storage: WorkspaceBlobStorage ) {} @@ -106,7 +106,7 @@ export class WorkspaceBlobResolver { ); const checkExceeded = - await this.quota.getQuotaCalculatorByWorkspace(workspaceId); + await this.quota.getWorkspaceQuotaCalculator(workspaceId); // TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge` if (checkExceeded(0)) { diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index 043039fe57..4955d39c82 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -148,6 +148,9 @@ export class WorkspaceService { } // ================ Team ================ + async isTeamWorkspace(workspaceId: string) { + return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1'); + } async sendTeamWorkspaceUpgradedEmail(workspaceId: string) { const workspace = await this.getWorkspaceInfo(workspaceId); diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index 51dc10e600..a89e33de0c 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -10,6 +10,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client'; import { nanoid } from 'nanoid'; import { + ActionForbiddenOnNonTeamWorkspace, Cache, EventBus, MemberNotFoundInSpace, @@ -22,7 +23,7 @@ import { import { Models } from '../../../models'; import { CurrentUser } from '../../auth'; import { PermissionService, WorkspaceRole } from '../../permission'; -import { QuotaManagementService } from '../../quota'; +import { QuotaService } from '../../quota'; import { InviteLink, InviteResult, @@ -47,7 +48,7 @@ export class TeamWorkspaceResolver { private readonly prisma: PrismaClient, private readonly permissions: PermissionService, private readonly models: Models, - private readonly quota: QuotaManagementService, + private readonly quota: QuotaService, private readonly mutex: RequestMutex, private readonly workspaceService: WorkspaceService ) {} @@ -58,7 +59,7 @@ export class TeamWorkspaceResolver { complexity: 2, }) team(@Parent() workspace: WorkspaceType) { - return this.quota.isTeamWorkspace(workspace.id); + return this.workspaceService.isTeamWorkspace(workspace.id); } @Mutation(() => [InviteResult]) @@ -85,7 +86,7 @@ export class TeamWorkspaceResolver { return new TooManyRequest(); } - const quota = await this.quota.getWorkspaceUsage(workspaceId); + const quota = await this.quota.getWorkspaceSeatQuota(workspaceId); const results = []; for (const [idx, email] of emails.entries()) { @@ -285,10 +286,20 @@ export class TeamWorkspaceResolver { @Args('userId') userId: string, @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( workspaceId, user.id, - WorkspaceRole.Owner + permission >= WorkspaceRole.Admin + ? WorkspaceRole.Owner + : WorkspaceRole.Admin ); try { diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index f0d472ef4d..d735000fca 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -38,7 +38,7 @@ import { mapWorkspaceRoleToWorkspaceActions, WorkspacePermissionsList, } from '../../permission/types'; -import { QuotaManagementService, QuotaQueryType } from '../../quota'; +import { QuotaService, WorkspaceQuotaType } from '../../quota'; import { UserType } from '../../user'; import { InvitationType, @@ -122,7 +122,7 @@ export class WorkspaceResolver { private readonly cache: Cache, private readonly prisma: PrismaClient, private readonly permissions: PermissionService, - private readonly quota: QuotaManagementService, + private readonly quota: QuotaService, private readonly models: Models, private readonly event: EventBus, private readonly mutex: RequestMutex, @@ -260,13 +260,19 @@ export class WorkspaceResolver { }; } - @ResolveField(() => QuotaQueryType, { + @ResolveField(() => WorkspaceQuotaType, { name: 'quota', description: 'quota of workspace', complexity: 2, }) - workspaceQuota(@Parent() workspace: WorkspaceType) { - return this.quota.getWorkspaceUsage(workspace.id); + async workspaceQuota( + @Parent() workspace: WorkspaceType + ): Promise { + const quota = await this.quota.getWorkspaceQuotaWithUsage(workspace.id); + return { + ...quota, + humanReadable: this.quota.formatWorkspaceQuota(quota), + }; } @Query(() => Boolean, { @@ -421,12 +427,7 @@ export class WorkspaceResolver { @Args({ name: 'input', type: () => UpdateWorkspaceInput }) { id, ...updates }: UpdateWorkspaceInput ) { - const isTeam = await this.quota.isTeamWorkspace(id); - await this.permissions.checkWorkspace( - id, - user.id, - isTeam ? WorkspaceRole.Owner : WorkspaceRole.Admin - ); + await this.permissions.checkWorkspace(id, user.id, WorkspaceRole.Admin); return this.prisma.workspace.update({ where: { @@ -483,7 +484,7 @@ export class WorkspaceResolver { } // member limit check - await this.quota.checkWorkspaceSeat(workspaceId); + await this.quota.checkSeat(workspaceId); let target = await this.models.user.getUserByEmail(email); if (target) { @@ -569,14 +570,14 @@ export class WorkspaceResolver { @Args('workspaceId') workspaceId: string, @Args('userId') userId: string ) { - const isTeam = await this.quota.isTeamWorkspace(workspaceId); const isAdmin = await this.permissions.tryCheckWorkspaceIs( workspaceId, userId, WorkspaceRole.Admin ); - if (isTeam && isAdmin) { - // only owner can revoke team workspace admin + + if (isAdmin) { + // only owner can revoke workspace admin await this.permissions.checkWorkspaceIs( workspaceId, user.id, @@ -607,7 +608,6 @@ export class WorkspaceResolver { throw new TooManyRequest(); } - const isTeam = await this.quota.isTeamWorkspace(workspaceId); if (user) { const status = await this.permissions.getWorkspaceMemberStatus( workspaceId, @@ -622,8 +622,9 @@ export class WorkspaceResolver { `workspace:inviteLink:${workspaceId}` ); if (invite?.inviteId === inviteId) { - const quota = await this.quota.getWorkspaceUsage(workspaceId); - if (quota.memberCount >= quota.memberLimit) { + const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId); + const seatAvailable = await this.quota.tryCheckSeat(workspaceId); + if (!seatAvailable) { // only team workspace allow over limit if (isTeam) { 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) { const success = await this.workspaceService.sendAcceptedEmail(inviteId); if (!success) throw new UserNotFound(); diff --git a/packages/backend/server/src/data/commands/run.ts b/packages/backend/server/src/data/commands/run.ts index 33b4482b02..50f5016020 100644 --- a/packages/backend/server/src/data/commands/run.ts +++ b/packages/backend/server/src/data/commands/run.ts @@ -5,16 +5,18 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { Logger } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { PrismaClient } from '@prisma/client'; +import { once } from 'lodash-es'; import { Command, CommandRunner } from 'nest-commander'; interface Migration { file: string; name: string; + always?: boolean; up: (db: PrismaClient, injector: ModuleRef) => Promise; down: (db: PrismaClient, injector: ModuleRef) => Promise; } -export async function collectMigrations(): Promise { +export const collectMigrations = once(async () => { const folder = join(fileURLToPath(import.meta.url), '../../migrations'); const migrationFiles = readdirSync(folder) @@ -33,6 +35,7 @@ export async function collectMigrations(): Promise { return { file, name: migration.name, + always: migration.always, up: migration.up, down: migration.down, }; @@ -41,7 +44,8 @@ export async function collectMigrations(): Promise { ); return migrations; -} +}); + @Command({ name: 'run', description: 'Run all pending data migrations', @@ -65,7 +69,7 @@ export class RunCommand extends CommandRunner { }, }); - if (exists) { + if (exists && !migration.always) { continue; } @@ -100,8 +104,14 @@ export class RunCommand extends CommandRunner { private async runMigration(migration: Migration) { this.logger.log(`Running ${migration.name}...`); - const record = await this.db.dataMigration.create({ - data: { + const record = await this.db.dataMigration.upsert({ + where: { + name: migration.name, + }, + update: { + startedAt: new Date(), + }, + create: { name: migration.name, startedAt: new Date(), }, diff --git a/packages/backend/server/src/data/migrations/0001-refresh-features.ts b/packages/backend/server/src/data/migrations/0001-refresh-features.ts new file mode 100644 index 0000000000..42e16193ab --- /dev/null +++ b/packages/backend/server/src/data/migrations/0001-refresh-features.ts @@ -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) {} +} diff --git a/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts b/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts deleted file mode 100644 index d15a00864e..0000000000 --- a/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts +++ /dev/null @@ -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 - } -} diff --git a/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts b/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts deleted file mode 100644 index 8fe978336d..0000000000 --- a/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts +++ /dev/null @@ -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({}); - } -} diff --git a/packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts b/packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts deleted file mode 100644 index 69555563c1..0000000000 --- a/packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts b/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts deleted file mode 100644 index 51b869e9c7..0000000000 --- a/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1706513866287-business-blob-limit.ts b/packages/backend/server/src/data/migrations/1706513866287-business-blob-limit.ts deleted file mode 100644 index 4c61590057..0000000000 --- a/packages/backend/server/src/data/migrations/1706513866287-business-blob-limit.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1708321519830-refresh-unlimited-workspace-feature.ts b/packages/backend/server/src/data/migrations/1708321519830-refresh-unlimited-workspace-feature.ts deleted file mode 100644 index 347f90ded9..0000000000 --- a/packages/backend/server/src/data/migrations/1708321519830-refresh-unlimited-workspace-feature.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1712224382221-refresh-free-plan.ts b/packages/backend/server/src/data/migrations/1712224382221-refresh-free-plan.ts deleted file mode 100644 index 5db27509a8..0000000000 --- a/packages/backend/server/src/data/migrations/1712224382221-refresh-free-plan.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1713164714634-copilot-feature.ts b/packages/backend/server/src/data/migrations/1713164714634-copilot-feature.ts deleted file mode 100644 index 9b6e2033b3..0000000000 --- a/packages/backend/server/src/data/migrations/1713164714634-copilot-feature.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1713176777814-ai-early-access.ts b/packages/backend/server/src/data/migrations/1713176777814-ai-early-access.ts deleted file mode 100644 index 058c0cceef..0000000000 --- a/packages/backend/server/src/data/migrations/1713176777814-ai-early-access.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1713285638427-unlimited-copilot.ts b/packages/backend/server/src/data/migrations/1713285638427-unlimited-copilot.ts deleted file mode 100644 index c2521302c5..0000000000 --- a/packages/backend/server/src/data/migrations/1713285638427-unlimited-copilot.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1716195522794-administrator-feature.ts b/packages/backend/server/src/data/migrations/1716195522794-administrator-feature.ts deleted file mode 100644 index 904363a397..0000000000 --- a/packages/backend/server/src/data/migrations/1716195522794-administrator-feature.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts b/packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts deleted file mode 100644 index a673b7f466..0000000000 --- a/packages/backend/server/src/data/migrations/1719917815802-lifetime-pro-quota.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1733804966417-team-quota.ts b/packages/backend/server/src/data/migrations/1733804966417-team-quota.ts deleted file mode 100644 index 6fa3ca105d..0000000000 --- a/packages/backend/server/src/data/migrations/1733804966417-team-quota.ts +++ /dev/null @@ -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) {} -} diff --git a/packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts b/packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts new file mode 100644 index 0000000000..cf6c01f7f7 --- /dev/null +++ b/packages/backend/server/src/data/migrations/1738590347632-feature-redundant.ts @@ -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 + } +} diff --git a/packages/backend/server/src/data/migrations/99999-self-host-admin.ts b/packages/backend/server/src/data/migrations/99999-self-host-admin.ts deleted file mode 100644 index f21b022488..0000000000 --- a/packages/backend/server/src/data/migrations/99999-self-host-admin.ts +++ /dev/null @@ -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() { - // - } -} diff --git a/packages/backend/server/src/data/migrations/utils/user-features.ts b/packages/backend/server/src/data/migrations/utils/user-features.ts deleted file mode 100644 index 0f30cb6118..0000000000 --- a/packages/backend/server/src/data/migrations/utils/user-features.ts +++ /dev/null @@ -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 { - 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); -} diff --git a/packages/backend/server/src/data/migrations/utils/user-quotas.ts b/packages/backend/server/src/data/migrations/utils/user-quotas.ts deleted file mode 100644 index 771ab46f0e..0000000000 --- a/packages/backend/server/src/data/migrations/utils/user-quotas.ts +++ /dev/null @@ -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); -} diff --git a/packages/backend/server/src/models/common/feature.ts b/packages/backend/server/src/models/common/feature.ts index 055c1959cd..1fc5117fa9 100644 --- a/packages/backend/server/src/models/common/feature.ts +++ b/packages/backend/server/src/models/common/feature.ts @@ -1,63 +1,75 @@ import { z } from 'zod'; -export enum FeatureType { - Feature = 0, - Quota = 1, -} +import { OneDay, OneGB, OneMB } from '../../base'; -// TODO(@forehalo): quota is a useless extra concept, merge it with feature -export const UserPlanQuotaConfig = z.object({ +const UserPlanQuotaConfig = z.object({ // quota name name: z.string(), // single blob limit 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 storageQuota: z.number(), // history period of validity historyPeriod: z.number(), // member limit memberLimit: z.number(), - // copilot action limit 10 - copilotActionLimit: z.number(), + // copilot action limit + copilotActionLimit: z.number().optional(), }); -export const WorkspaceQuotaConfig = UserPlanQuotaConfig.extend({ +export type UserQuota = z.infer; + +const WorkspaceQuotaConfig = UserPlanQuotaConfig.extend({ // seat quota seatQuota: z.number(), }).omit({ copilotActionLimit: true, }); -function feature(configs: z.ZodObject) { - return z.object({ - type: z.literal(FeatureType.Feature), - configs: configs, - }); +export type WorkspaceQuota = z.infer; + +const EMPTY_CONFIG = z.object({}); + +export enum FeatureType { + Feature, + Quota, } -function quota(configs: z.ZodObject) { - return z.object({ - type: z.literal(FeatureType.Quota), - configs: configs, - }); +export enum Feature { + // user + Admin = 'administrator', + 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 = { - copilot: feature(z.object({})), - early_access: feature(z.object({ whitelist: z.array(z.string()) })), - unlimited_workspace: feature(z.object({})), - unlimited_copilot: feature(z.object({})), - ai_early_access: feature(z.object({})), - administrator: feature(z.object({})), - free_plan_v1: quota(UserPlanQuotaConfig), - pro_plan_v1: quota(UserPlanQuotaConfig), - lifetime_pro_plan_v1: quota(UserPlanQuotaConfig), - restricted_plan_v1: quota(UserPlanQuotaConfig), - team_plan_v1: quota(WorkspaceQuotaConfig), -}; +// TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`? +export const FeaturesShapes = { + early_access: z.object({ whitelist: z.array(z.string()) }), + unlimited_workspace: EMPTY_CONFIG, + unlimited_copilot: EMPTY_CONFIG, + ai_early_access: EMPTY_CONFIG, + administrator: EMPTY_CONFIG, + free_plan_v1: UserPlanQuotaConfig, + pro_plan_v1: UserPlanQuotaConfig, + lifetime_pro_plan_v1: UserPlanQuotaConfig, + team_plan_v1: WorkspaceQuotaConfig, +} satisfies Record>; export type UserFeatureName = keyof Pick< - typeof Features, + typeof FeaturesShapes, | 'early_access' | 'ai_early_access' | 'unlimited_copilot' @@ -65,14 +77,97 @@ export type UserFeatureName = keyof Pick< | 'free_plan_v1' | 'pro_plan_v1' | 'lifetime_pro_plan_v1' - | 'restricted_plan_v1' >; export type WorkspaceFeatureName = keyof Pick< - typeof Features, + typeof FeaturesShapes, 'unlimited_workspace' | 'team_plan_v1' >; export type FeatureName = UserFeatureName | WorkspaceFeatureName; -export type FeatureConfigs = z.infer< - (typeof Features)[T]['shape']['configs'] +export type FeatureConfig = z.infer< + (typeof FeaturesShapes)[T] >; + +export const FeatureConfigs: { + [K in FeatureName]: { + type: FeatureType; + configs: FeatureConfig; + 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: {}, + }, +}; diff --git a/packages/backend/server/src/models/feature.ts b/packages/backend/server/src/models/feature.ts index 6f7803b8a2..6e87405e4a 100644 --- a/packages/backend/server/src/models/feature.ts +++ b/packages/backend/server/src/models/feature.ts @@ -5,9 +5,10 @@ import { z } from 'zod'; import { BaseModel } from './base'; import { - type FeatureConfigs, + type FeatureConfig, + FeatureConfigs, type FeatureName, - Features, + FeaturesShapes, FeatureType, } from './common'; @@ -29,7 +30,12 @@ export class FeatureModel extends BaseModel { } @Transactional() - async upsert(name: T, configs: FeatureConfigs) { + async upsert( + name: T, + configs: FeatureConfig, + deprecatedType: FeatureType, + deprecatedVersion: number + ) { const parsedConfigs = this.check(name, configs); // TODO(@forehalo): @@ -41,8 +47,9 @@ export class FeatureModel extends BaseModel { if (!latest) { feature = await this.db.feature.create({ data: { - type: FeatureType.Feature, - feature: name, + name, + deprecatedType, + deprecatedVersion, configs: parsedConfigs, }, }); @@ -57,7 +64,7 @@ export class FeatureModel extends BaseModel { this.logger.verbose(`Feature ${name} upserted`); - return feature as Feature & { configs: FeatureConfigs }; + return feature as Feature & { configs: FeatureConfig }; } /** @@ -67,8 +74,7 @@ export class FeatureModel extends BaseModel { */ async try_get_unchecked(name: T) { const feature = await this.db.feature.findFirst({ - where: { feature: name }, - orderBy: { version: 'desc' }, + where: { name }, }); return feature as Omit & { @@ -104,10 +110,22 @@ export class FeatureModel extends BaseModel { }); } - return parseResult.data as FeatureConfigs; + return parseResult.data as FeatureConfig; } getConfigShape(name: FeatureName): z.ZodObject { - 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); + } } } diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 7e7ccb38b4..65522a0f71 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -77,6 +77,7 @@ const ModelsSymbolProvider: ExistingProvider = { }) export class ModelsModule {} +export * from './common'; export * from './feature'; export * from './page'; export * from './session'; diff --git a/packages/backend/server/src/models/user-feature.ts b/packages/backend/server/src/models/user-feature.ts index 356bad49c1..d8bc5a6ecb 100644 --- a/packages/backend/server/src/models/user-feature.ts +++ b/packages/backend/server/src/models/user-feature.ts @@ -1,47 +1,49 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; +import { Prisma } from '@prisma/client'; import { BaseModel } from './base'; -import type { UserFeatureName } from './common'; +import { FeatureType, type UserFeatureName } from './common'; @Injectable() export class UserFeatureModel extends BaseModel { async get(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({ where: { userId, - featureId: feature.id, + name, 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) { - const feature = await this.models.feature.get_unchecked(name); - const count = await this.db.userFeature.count({ where: { userId, - featureId: feature.id, + name, activated: true, }, }); @@ -49,29 +51,37 @@ export class UserFeatureModel extends BaseModel { 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({ - include: { - feature: true, - }, - where: { - userId, - activated: true, + where: filter, + select: { + name: true, }, }); return userFeatures.map( - userFeature => userFeature.feature.feature + userFeature => userFeature.name ) as UserFeatureName[]; } - async add(userId: string, featureName: UserFeatureName, reason: string) { - const feature = await this.models.feature.get_unchecked(featureName); - + async add(userId: string, name: UserFeatureName, reason: string) { + const feature = await this.models.feature.get_unchecked(name); const existing = await this.db.userFeature.findFirst({ where: { userId, - featureId: feature.id, + name: name, activated: true, }, }); @@ -84,37 +94,56 @@ export class UserFeatureModel extends BaseModel { data: { userId, featureId: feature.id, + name, + type: this.models.feature.getFeatureType(name), activated: true, reason, }, }); - this.logger.verbose(`Feature ${featureName} added to user ${userId}`); + this.logger.verbose(`Feature ${name} added to user ${userId}`); return userFeature; } async remove(userId: string, featureName: UserFeatureName) { - const feature = await this.models.feature.get_unchecked(featureName); - - await this.db.userFeature.deleteMany({ + await this.db.userFeature.updateMany({ where: { 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() - async switch( - userId: string, - from: UserFeatureName, - to: UserFeatureName, - reason: string - ) { - await this.remove(userId, from); + async switchQuota(userId: string, to: UserFeatureName, reason: string) { + const quotas = await this.list(userId, FeatureType.Quota); + + // deactivate the previous quota + if (quotas.length) { + // db state error + 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); } } diff --git a/packages/backend/server/src/models/user.ts b/packages/backend/server/src/models/user.ts index 021b77958c..b122b3ab84 100644 --- a/packages/backend/server/src/models/user.ts +++ b/packages/backend/server/src/models/user.ts @@ -9,7 +9,6 @@ import { WrongSignInCredentials, WrongSignInMethod, } from '../base'; -import { Quota_FreePlanV1_1 } from '../core/quota/schema'; import { BaseModel } from './base'; import type { Workspace } from './workspace'; @@ -22,23 +21,6 @@ const publicUserSelect = { type CreateUserInput = Omit & { name?: string }; type UpdateUserInput = Omit, '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 { interface Events { 'user.created': User; @@ -48,6 +30,7 @@ declare global { // dealing of owned workspaces of deleted users to workspace model ownedWorkspaces: Workspace['id'][]; }; + 'user.postCreated': User; } } @@ -134,17 +117,16 @@ export class UserModel extends BaseModel { data.password = await this.crypto.encryptPassword(data.password); } - if (!data.name) { - data.name = data.email.split('@')[0]; - } - user = await this.db.user.create({ data: { - ...defaultUserCreatingData, ...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.event.emit('user.created', user); diff --git a/packages/backend/server/src/models/workspace-feature.ts b/packages/backend/server/src/models/workspace-feature.ts index 34f89f7433..90d82b6bee 100644 --- a/packages/backend/server/src/models/workspace-feature.ts +++ b/packages/backend/server/src/models/workspace-feature.ts @@ -1,18 +1,21 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; +import { Prisma } from '@prisma/client'; import { BaseModel } from './base'; -import type { FeatureConfigs, WorkspaceFeatureName } from './common'; +import { + type FeatureConfig, + FeatureType, + type WorkspaceFeatureName, +} from './common'; @Injectable() export class WorkspaceFeatureModel extends BaseModel { async get(workspaceId: string, name: T) { - const feature = await this.models.feature.get_unchecked(name); - const workspaceFeature = await this.db.workspaceFeature.findFirst({ where: { workspaceId, - featureId: feature.id, + name, activated: true, }, }); @@ -21,6 +24,8 @@ export class WorkspaceFeatureModel extends BaseModel { return null; } + const feature = await this.models.feature.get_unchecked(name); + return { ...feature, configs: this.models.feature.check(name, { @@ -30,13 +35,47 @@ export class WorkspaceFeatureModel extends BaseModel { }; } - async has(workspaceId: string, name: WorkspaceFeatureName) { - const feature = await this.models.feature.get_unchecked(name); + async getQuota(workspaceId: string) { + 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({ where: { workspaceId, - featureId: feature.id, + name, activated: true, }, }); @@ -44,35 +83,62 @@ export class WorkspaceFeatureModel extends BaseModel { 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({ - include: { - feature: true, + select: { + workspaceId: true, }, where: { - workspaceId, + workspaceId: { in: workspaceIds }, + type: FeatureType.Quota, 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( - workspaceFeature => workspaceFeature.feature.feature + workspaceFeature => workspaceFeature.name ) as WorkspaceFeatureName[]; } @Transactional() async add( workspaceId: string, - featureName: T, + name: T, reason: string, - overrides?: Partial> + overrides?: Partial> ) { - 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({ where: { workspaceId, - featureId: feature.id, + name: name, activated: true, }, }); @@ -87,12 +153,12 @@ export class WorkspaceFeatureModel extends BaseModel { }; const parseResult = this.models.feature - .getConfigShape(featureName) + .getConfigShape(name) .partial() .safeParse(configs); if (!parseResult.success) { - throw new Error(`Invalid feature config for ${featureName}`, { + throw new Error(`Invalid feature config for ${name}`, { cause: parseResult.error, }); } @@ -113,6 +179,8 @@ export class WorkspaceFeatureModel extends BaseModel { data: { workspaceId, featureId: feature.id, + name, + type: this.models.feature.getFeatureType(name), activated: true, reason, configs: parseResult.data, @@ -120,20 +188,16 @@ export class WorkspaceFeatureModel extends BaseModel { }); } - this.logger.verbose( - `Feature ${featureName} added to workspace ${workspaceId}` - ); + this.logger.verbose(`Feature ${name} added to workspace ${workspaceId}`); return workspaceFeature; } async remove(workspaceId: string, featureName: WorkspaceFeatureName) { - const feature = await this.models.feature.get_unchecked(featureName); - await this.db.workspaceFeature.deleteMany({ where: { workspaceId, - featureId: feature.id, + name: featureName, }, }); @@ -141,16 +205,4 @@ export class WorkspaceFeatureModel extends BaseModel { `Feature ${featureName} removed from workspace ${workspaceId}` ); } - - @Transactional() - async switch( - workspaceId: string, - from: WorkspaceFeatureName, - to: T, - reason: string, - overrides?: Partial> - ) { - await this.remove(workspaceId, from); - return await this.add(workspaceId, to, reason, overrides); - } } diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index bb126bc747..ffa8c1a9e2 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -12,8 +12,8 @@ import { CopilotSessionNotFound, PrismaTransaction, } from '../../base'; -import { FeatureManagementService } from '../../core/features'; import { QuotaService } from '../../core/quota'; +import { Models } from '../../models'; import { ChatMessageCache } from './message'; import { PromptService } from './prompt'; import { @@ -195,10 +195,10 @@ export class ChatSessionService { constructor( private readonly db: PrismaClient, - private readonly feature: FeatureManagementService, private readonly quota: QuotaService, private readonly messageCache: ChatMessageCache, - private readonly prompt: PromptService + private readonly prompt: PromptService, + private readonly models: Models ) {} private async haveSession( @@ -545,12 +545,15 @@ export class ChatSessionService { } async getQuota(userId: string) { - const isCopilotUser = await this.feature.isCopilotUser(userId); + const isCopilotUser = await this.models.userFeature.has( + userId, + 'unlimited_copilot' + ); let limit: number | undefined; if (!isCopilotUser) { const quota = await this.quota.getUserQuota(userId); - limit = quota.feature.copilotActionLimit; + limit = quota.copilotActionLimit; } const used = await this.countUserMessages(userId); diff --git a/packages/backend/server/src/plugins/copilot/storage.ts b/packages/backend/server/src/plugins/copilot/storage.ts index e592274321..c004b911c7 100644 --- a/packages/backend/server/src/plugins/copilot/storage.ts +++ b/packages/backend/server/src/plugins/copilot/storage.ts @@ -12,7 +12,7 @@ import { StorageProviderFactory, URLHelper, } from '../../base'; -import { QuotaManagementService } from '../../core/quota'; +import { QuotaService } from '../../core/quota'; @Injectable() export class CopilotStorage { @@ -22,7 +22,7 @@ export class CopilotStorage { private readonly config: Config, private readonly url: URLHelper, private readonly storageFactory: StorageProviderFactory, - private readonly quota: QuotaManagementService + private readonly quota: QuotaService ) { this.provider = this.storageFactory.create( this.config.plugins.copilot.storage @@ -57,7 +57,7 @@ export class CopilotStorage { @CallMetric('ai', 'blob_upload') async handleUpload(userId: string, blob: FileUpload) { - const checkExceeded = await this.quota.getQuotaCalculator(userId); + const checkExceeded = await this.quota.getUserQuotaCalculator(userId); if (checkExceeded(0)) { throw new BlobQuotaExceeded(); diff --git a/packages/backend/server/src/plugins/license/service.ts b/packages/backend/server/src/plugins/license/service.ts index e31992af78..93f6a553f7 100644 --- a/packages/backend/server/src/plugins/license/service.ts +++ b/packages/backend/server/src/plugins/license/service.ts @@ -12,7 +12,7 @@ import { WorkspaceLicenseAlreadyExists, } from '../../base'; import { PermissionService } from '../../core/permission'; -import { QuotaManagementService, QuotaType } from '../../core/quota'; +import { Models } from '../../models'; import { SubscriptionPlan, SubscriptionRecurring } from '../payment/types'; interface License { @@ -29,9 +29,9 @@ export class LicenseService { constructor( private readonly config: Config, private readonly db: PrismaClient, - private readonly quota: QuotaManagementService, private readonly event: EventBus, - private readonly permission: PermissionService + private readonly permission: PermissionService, + private readonly models: Models ) {} async getLicense(workspaceId: string) { @@ -316,14 +316,13 @@ export class LicenseService { }: Events['workspace.subscription.activated']) { switch (plan) { case SubscriptionPlan.SelfHostedTeam: - await this.quota.addTeamWorkspace( + await this.models.workspaceFeature.add( workspaceId, - `${recurring} team subscription activated` - ); - await this.quota.updateWorkspaceConfig( - workspaceId, - QuotaType.TeamPlanV1, - { memberLimit: quantity } + 'team_plan_v1', + `${recurring} team subscription activated`, + { + memberLimit: quantity, + } ); await this.permission.refreshSeatStatus(workspaceId, quantity); break; @@ -339,7 +338,7 @@ export class LicenseService { }: Events['workspace.subscription.canceled']) { switch (plan) { case SubscriptionPlan.SelfHostedTeam: - await this.quota.removeTeamWorkspace(workspaceId); + await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1'); break; default: break; diff --git a/packages/backend/server/src/plugins/payment/manager/user.ts b/packages/backend/server/src/plugins/payment/manager/user.ts index 5f54974033..437610d721 100644 --- a/packages/backend/server/src/plugins/payment/manager/user.ts +++ b/packages/backend/server/src/plugins/payment/manager/user.ts @@ -15,10 +15,7 @@ import { TooManyRequest, URLHelper, } from '../../../base'; -import { - EarlyAccessType, - FeatureManagementService, -} from '../../../core/features'; +import { EarlyAccessType, FeatureService } from '../../../core/features'; import { CouponType, KnownStripeInvoice, @@ -59,7 +56,7 @@ export class UserSubscriptionManager extends SubscriptionManager { stripe: Stripe, db: PrismaClient, private readonly runtime: Runtime, - private readonly feature: FeatureManagementService, + private readonly feature: FeatureService, private readonly event: EventBus, private readonly url: URLHelper, private readonly mutex: Mutex diff --git a/packages/backend/server/src/plugins/payment/quota.ts b/packages/backend/server/src/plugins/payment/quota.ts index a7296d82c4..0294139fe1 100644 --- a/packages/backend/server/src/plugins/payment/quota.ts +++ b/packages/backend/server/src/plugins/payment/quota.ts @@ -1,25 +1,17 @@ import { Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { FeatureManagementService } from '../../core/features'; import { PermissionService } from '../../core/permission'; -import { - QuotaManagementService, - QuotaService, - QuotaType, -} from '../../core/quota'; import { WorkspaceService } from '../../core/workspaces/resolvers'; +import { Models } from '../../models'; import { SubscriptionPlan } from './types'; @Injectable() export class QuotaOverride { constructor( - private readonly quota: QuotaService, - private readonly manager: QuotaManagementService, private readonly permission: PermissionService, private readonly workspace: WorkspaceService, - private readonly feature: FeatureManagementService, - private readonly quotaService: QuotaService + private readonly models: Models ) {} @OnEvent('workspace.subscription.activated') @@ -31,21 +23,17 @@ export class QuotaOverride { }: Events['workspace.subscription.activated']) { switch (plan) { case 'team': { - const hasTeamWorkspace = await this.quota.hasWorkspaceQuota( + const isTeam = await this.workspace.isTeamWorkspace(workspaceId); + await this.models.workspaceFeature.add( workspaceId, - QuotaType.TeamPlanV1 - ); - await this.manager.addTeamWorkspace( - workspaceId, - `${recurring} team subscription activated` - ); - await this.quota.updateWorkspaceConfig( - workspaceId, - QuotaType.TeamPlanV1, - { memberLimit: quantity } + 'team_plan_v1', + `${recurring} team subscription activated`, + { + memberLimit: quantity, + } ); await this.permission.refreshSeatStatus(workspaceId, quantity); - if (!hasTeamWorkspace) { + if (!isTeam) { // this event will triggered when subscription is activated or changed // we only send emails when the team workspace is activated await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId); @@ -64,7 +52,7 @@ export class QuotaOverride { }: Events['workspace.subscription.canceled']) { switch (plan) { case SubscriptionPlan.Team: - await this.manager.removeTeamWorkspace(workspaceId); + await this.models.workspaceFeature.remove(workspaceId, 'team_plan_v1'); break; default: break; @@ -79,14 +67,16 @@ export class QuotaOverride { }: Events['user.subscription.activated']) { switch (plan) { case SubscriptionPlan.AI: - await this.feature.addCopilot(userId, 'subscription activated'); + await this.models.userFeature.add( + userId, + 'unlimited_copilot', + 'subscription activated' + ); break; case SubscriptionPlan.Pro: - await this.quotaService.switchUserQuota( + await this.models.userFeature.add( userId, - recurring === 'lifetime' - ? QuotaType.LifetimeProPlanV1 - : QuotaType.ProPlanV1, + recurring === 'lifetime' ? 'lifetime_pro_plan_v1' : 'pro_plan_v1', 'subscription activated' ); break; @@ -102,16 +92,20 @@ export class QuotaOverride { }: Events['user.subscription.canceled']) { switch (plan) { case SubscriptionPlan.AI: - await this.feature.removeCopilot(userId); + await this.models.userFeature.remove(userId, 'unlimited_copilot'); break; case SubscriptionPlan.Pro: { // edge case: when user switch from recurring Pro plan to `Lifetime` plan, // a subscription canceled event will be triggered because `Lifetime` plan is not subscription based - const quota = await this.quotaService.getUserQuota(userId); - if (quota.feature.name !== QuotaType.LifetimeProPlanV1) { - await this.quotaService.switchUserQuota( + const isLifetimeUser = await this.models.userFeature.has( + userId, + 'lifetime_pro_plan_v1' + ); + + if (!isLifetimeUser) { + await this.models.userFeature.switchQuota( userId, - QuotaType.FreePlanV1, + 'free_plan_v1', 'subscription canceled' ); } diff --git a/packages/backend/server/src/plugins/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts index dbbcf727b3..eb08825a9c 100644 --- a/packages/backend/server/src/plugins/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -26,7 +26,7 @@ import { UserNotFound, } from '../../base'; import { CurrentUser } from '../../core/auth'; -import { FeatureManagementService } from '../../core/features'; +import { FeatureService } from '../../core/features'; import { Models } from '../../models'; import { CheckoutParams, @@ -83,7 +83,7 @@ export class SubscriptionService implements OnApplicationBootstrap { private readonly config: Config, private readonly stripe: Stripe, private readonly db: PrismaClient, - private readonly feature: FeatureManagementService, + private readonly feature: FeatureService, private readonly models: Models, private readonly userManager: UserSubscriptionManager, private readonly workspaceManager: WorkspaceSubscriptionManager, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index a8878d0524..8fda75ec32 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -230,6 +230,7 @@ union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMe enum ErrorNames { ACCESS_DENIED ACTION_FORBIDDEN + ACTION_FORBIDDEN_ON_NON_TEAM_WORKSPACE ALREADY_IN_SPACE AUTHENTICATION_REQUIRED BLOB_NOT_FOUND @@ -336,12 +337,14 @@ type ExpectToUpdateDocUserRoleDataType { spaceId: String! } -"""The type of workspace feature""" enum FeatureType { AIEarlyAccess Admin - Copilot EarlyAccess + FreePlan + LifetimeProPlan + ProPlan + TeamPlan UnlimitedCopilot UnlimitedWorkspace } @@ -380,15 +383,6 @@ type GrantedDocUsersConnection { totalCount: Int! } -type HumanReadableQuotaType { - blobLimit: String! - copilotActionLimit: String - historyPeriod: String! - memberLimit: String! - name: String! - storageQuota: String! -} - type InvalidEmailDataType { email: String! } @@ -565,7 +559,7 @@ type MissingOauthQueryParameterDataType { type Mutation { acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! activateLicense(license: String!, workspaceId: String!): License! - addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! + addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! approveMember(userId: String!, workspaceId: String!): String! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! changeEmail(email: String!, token: String!): UserType! @@ -621,7 +615,7 @@ type Mutation { """Remove user avatar""" removeAvatar: RemoveAvatar! - removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! + removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType! revoke(userId: String!, workspaceId: String!): Boolean! revokeDocUserRoles(docId: String!, userIds: [String!]!): Boolean! @@ -634,7 +628,6 @@ type Mutation { sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: String!): Boolean! sendVerifyEmail(callbackUrl: String!): Boolean! setBlob(blob: Upload!, workspaceId: String!): String! - setWorkspaceExperimentalFeature(enable: Boolean!, feature: FeatureType!, workspaceId: String!): Boolean! sharePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "renamed to publishPage") """Update a copilot prompt""" @@ -733,7 +726,6 @@ type Query { """List all copilot prompts""" listCopilotPrompts: [CopilotPromptType!]! - listWorkspaceFeatures(feature: FeatureType!): [WorkspaceFeatureType!]! prices: [SubscriptionPrice!]! """server config""" @@ -782,18 +774,6 @@ type QueryTooLongDataType { max: Int! } -type QuotaQueryType { - blobLimit: SafeInt! - copilotActionLimit: SafeInt - historyPeriod: SafeInt! - humanReadable: HumanReadableQuotaType! - memberCount: SafeInt! - memberLimit: SafeInt! - name: String! - storageQuota: SafeInt! - usedSize: SafeInt! -} - type RemoveAvatar { success: Boolean! } @@ -1043,25 +1023,29 @@ scalar Upload union UserOrLimitedUser = LimitedUserType | UserType -type UserQuota { - blobLimit: SafeInt! - historyPeriod: SafeInt! - humanReadable: UserQuotaHumanReadable! - memberLimit: Int! - name: String! - storageQuota: SafeInt! -} - -type UserQuotaHumanReadable { +type UserQuotaHumanReadableType { blobLimit: String! + copilotActionLimit: String! historyPeriod: String! memberLimit: String! name: String! storageQuota: String! + usedStorageQuota: String! } -type UserQuotaUsage { +type UserQuotaType { + blobLimit: SafeInt! + copilotActionLimit: Int + historyPeriod: SafeInt! + humanReadable: UserQuotaHumanReadableType! + memberLimit: Int! + name: String! storageQuota: SafeInt! + usedStorageQuota: SafeInt! +} + +type UserQuotaUsageType { + storageQuota: SafeInt! @deprecated(reason: "use `UserQuotaType['usedStorageQuota']` instead") } type UserType { @@ -1091,8 +1075,8 @@ type UserType { """User name""" name: String! - quota: UserQuota - quotaUsage: UserQuotaUsage! + quota: UserQuotaType! + quotaUsage: UserQuotaUsageType! subscriptions: [SubscriptionType!]! token: tokenType! @deprecated(reason: "use [/api/auth/sign-in?native=true] instead") } @@ -1106,15 +1090,6 @@ type WorkspaceBlobSizes { size: SafeInt! } -type WorkspaceFeatureType { - """Workspace created date""" - createdAt: DateTime! - id: ID! - - """is Public workspace""" - public: Boolean! -} - """Workspace invite link expire time""" enum WorkspaceInviteLinkExpireTime { OneDay @@ -1170,15 +1145,34 @@ type WorkspacePermissions { Workspace_Users_Read: Boolean! } +type WorkspaceQuotaHumanReadableType { + blobLimit: String! + historyPeriod: String! + memberCount: String! + memberLimit: String! + name: String! + storageQuota: String! + storageQuotaUsed: String! +} + +type WorkspaceQuotaType { + blobLimit: SafeInt! + historyPeriod: SafeInt! + humanReadable: WorkspaceQuotaHumanReadableType! + memberCount: Int! + memberLimit: Int! + name: String! + storageQuota: SafeInt! + usedSize: SafeInt! @deprecated(reason: "use `usedStorageQuota` instead") + usedStorageQuota: SafeInt! +} + type WorkspaceRolePermissions { permissions: WorkspacePermissions! role: Permission! } type WorkspaceType { - """Available features of workspace""" - availableFeatures: [FeatureType!]! - """List blobs of workspace""" blobs: [ListedBlob!]! @@ -1193,9 +1187,6 @@ type WorkspaceType { """Enable url previous when sharing""" enableUrlPreview: Boolean! - - """Enabled features of workspace""" - features: [FeatureType!]! histories(before: DateTime, guid: String!, take: Int): [DocHistoryType!]! id: ID! @@ -1240,7 +1231,7 @@ type WorkspaceType { publicPages: [WorkspacePage!]! """quota of workspace""" - quota: QuotaQueryType! + quota: WorkspaceQuotaType! """Role of current signed in user in workspace""" role: Permission! diff --git a/packages/frontend/core/src/modules/cloud/entities/user-quota.ts b/packages/frontend/core/src/modules/cloud/entities/user-quota.ts index 01110aa062..265508d45d 100644 --- a/packages/frontend/core/src/modules/cloud/entities/user-quota.ts +++ b/packages/frontend/core/src/modules/cloud/entities/user-quota.ts @@ -19,7 +19,9 @@ import type { AuthService } from '../services/auth'; import type { UserQuotaStore } from '../stores/user-quota'; export class UserQuota extends Entity { - quota$ = new LiveData['quota']>(null); + quota$ = new LiveData['quota'] | null>( + null + ); /** Used storage in bytes */ used$ = new LiveData(null); /** Formatted used storage */ diff --git a/packages/frontend/core/src/modules/quota/entities/quota.ts b/packages/frontend/core/src/modules/quota/entities/quota.ts index 7b56d9777d..e220a5c372 100644 --- a/packages/frontend/core/src/modules/quota/entities/quota.ts +++ b/packages/frontend/core/src/modules/quota/entities/quota.ts @@ -74,7 +74,7 @@ export class WorkspaceQuota extends Entity { this.workspaceService.workspace.id, signal ); - return { quota: data, used: data.usedSize }; + return { quota: data, used: data.usedStorageQuota }; }).pipe( backoffRetry({ when: isNetworkError, diff --git a/packages/frontend/graphql/src/graphql/get-workspace-features.gql b/packages/frontend/graphql/src/graphql/get-workspace-features.gql deleted file mode 100644 index 8644712835..0000000000 --- a/packages/frontend/graphql/src/graphql/get-workspace-features.gql +++ /dev/null @@ -1,5 +0,0 @@ -query getWorkspaceFeatures($workspaceId: String!) { - workspace(id: $workspaceId) { - features - } -} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index ce583d93c5..a95a830055 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -206,17 +206,6 @@ mutation createCopilotSession($options: CreateChatSessionInput!) { }`, }; -export const updateCopilotSessionMutation = { - id: 'updateCopilotSessionMutation' as const, - operationName: 'updateCopilotSession', - definitionName: 'updateCopilotSession', - containsFile: false, - query: ` -mutation updateCopilotSession($options: UpdateChatSessionInput!) { - updateCopilotSession(options: $options) -}`, -}; - export const createCustomerPortalMutation = { id: 'createCustomerPortalMutation' as const, operationName: 'createCustomerPortal', @@ -637,19 +626,6 @@ query getUsersCount { }`, }; -export const getWorkspaceFeaturesQuery = { - id: 'getWorkspaceFeaturesQuery' as const, - operationName: 'getWorkspaceFeatures', - definitionName: 'workspace', - containsFile: false, - query: ` -query getWorkspaceFeatures($workspaceId: String!) { - workspace(id: $workspaceId) { - features - } -}`, -}; - export const getWorkspaceInfoQuery = { id: 'getWorkspaceInfoQuery' as const, operationName: 'getWorkspaceInfo', @@ -1143,6 +1119,17 @@ mutation updateAccount($id: String!, $input: ManageUserInput!) { }`, }; +export const updateCopilotSessionMutation = { + id: 'updateCopilotSessionMutation' as const, + operationName: 'updateCopilotSession', + definitionName: 'updateCopilotSession', + containsFile: false, + query: ` +mutation updateCopilotSession($options: UpdateChatSessionInput!) { + updateCopilotSession(options: $options) +}`, +}; + export const updatePromptMutation = { id: 'updatePromptMutation' as const, operationName: 'updatePrompt', @@ -1289,89 +1276,6 @@ mutation setEnableUrlPreview($id: ID!, $enableUrlPreview: Boolean!) { }`, }; -export const enabledFeaturesQuery = { - id: 'enabledFeaturesQuery' as const, - operationName: 'enabledFeatures', - definitionName: 'workspace', - containsFile: false, - query: ` -query enabledFeatures($id: String!) { - workspace(id: $id) { - features - } -}`, -}; - -export const availableFeaturesQuery = { - id: 'availableFeaturesQuery' as const, - operationName: 'availableFeatures', - definitionName: 'workspace', - containsFile: false, - query: ` -query availableFeatures($id: String!) { - workspace(id: $id) { - availableFeatures - } -}`, -}; - -export const setWorkspaceExperimentalFeatureMutation = { - id: 'setWorkspaceExperimentalFeatureMutation' as const, - operationName: 'setWorkspaceExperimentalFeature', - definitionName: 'setWorkspaceExperimentalFeature', - containsFile: false, - query: ` -mutation setWorkspaceExperimentalFeature($workspaceId: String!, $feature: FeatureType!, $enable: Boolean!) { - setWorkspaceExperimentalFeature( - workspaceId: $workspaceId - feature: $feature - enable: $enable - ) -}`, -}; - -export const addWorkspaceFeatureMutation = { - id: 'addWorkspaceFeatureMutation' as const, - operationName: 'addWorkspaceFeature', - definitionName: 'addWorkspaceFeature', - containsFile: false, - query: ` -mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { - addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) -}`, -}; - -export const listWorkspaceFeaturesQuery = { - id: 'listWorkspaceFeaturesQuery' as const, - operationName: 'listWorkspaceFeatures', - definitionName: 'listWorkspaceFeatures', - containsFile: false, - query: ` -query listWorkspaceFeatures($feature: FeatureType!) { - listWorkspaceFeatures(feature: $feature) { - id - public - createdAt - memberCount - owner { - id - } - features - } -}`, -}; - -export const removeWorkspaceFeatureMutation = { - id: 'removeWorkspaceFeatureMutation' as const, - operationName: 'removeWorkspaceFeature', - definitionName: 'removeWorkspaceFeature', - containsFile: false, - query: ` -mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { - removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) -}`, -}; - export const inviteByEmailMutation = { id: 'inviteByEmailMutation' as const, operationName: 'inviteByEmail', @@ -1500,6 +1404,7 @@ query workspaceQuota($id: String!) { name blobLimit storageQuota + usedStorageQuota historyPeriod memberLimit memberCount @@ -1510,7 +1415,6 @@ query workspaceQuota($id: String!) { historyPeriod memberLimit } - usedSize } } }`, diff --git a/packages/frontend/graphql/src/graphql/workspace-enabled-features.gql b/packages/frontend/graphql/src/graphql/workspace-enabled-features.gql deleted file mode 100644 index 837dddc86c..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-enabled-features.gql +++ /dev/null @@ -1,5 +0,0 @@ -query enabledFeatures($id: String!) { - workspace(id: $id) { - features - } -} diff --git a/packages/frontend/graphql/src/graphql/workspace-experimental-feature-get.gql b/packages/frontend/graphql/src/graphql/workspace-experimental-feature-get.gql deleted file mode 100644 index 2720fa6fe3..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-experimental-feature-get.gql +++ /dev/null @@ -1,5 +0,0 @@ -query availableFeatures($id: String!) { - workspace(id: $id) { - availableFeatures - } -} diff --git a/packages/frontend/graphql/src/graphql/workspace-experimental-feature-set.gql b/packages/frontend/graphql/src/graphql/workspace-experimental-feature-set.gql deleted file mode 100644 index 1b9ce1f683..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-experimental-feature-set.gql +++ /dev/null @@ -1,11 +0,0 @@ -mutation setWorkspaceExperimentalFeature( - $workspaceId: String! - $feature: FeatureType! - $enable: Boolean! -) { - setWorkspaceExperimentalFeature( - workspaceId: $workspaceId - feature: $feature - enable: $enable - ) -} diff --git a/packages/frontend/graphql/src/graphql/workspace-feature-add.gql b/packages/frontend/graphql/src/graphql/workspace-feature-add.gql deleted file mode 100644 index d77eafbd16..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-feature-add.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { - addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) -} diff --git a/packages/frontend/graphql/src/graphql/workspace-feature-list.gql b/packages/frontend/graphql/src/graphql/workspace-feature-list.gql deleted file mode 100644 index 4bd21fbe45..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-feature-list.gql +++ /dev/null @@ -1,12 +0,0 @@ -query listWorkspaceFeatures($feature: FeatureType!) { - listWorkspaceFeatures(feature: $feature) { - id - public - createdAt - memberCount - owner { - id - } - features - } -} diff --git a/packages/frontend/graphql/src/graphql/workspace-feature-remove.gql b/packages/frontend/graphql/src/graphql/workspace-feature-remove.gql deleted file mode 100644 index e856885b56..0000000000 --- a/packages/frontend/graphql/src/graphql/workspace-feature-remove.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { - removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) -} diff --git a/packages/frontend/graphql/src/graphql/workspace-quota.gql b/packages/frontend/graphql/src/graphql/workspace-quota.gql index 176e1fc7b5..7d51730c15 100644 --- a/packages/frontend/graphql/src/graphql/workspace-quota.gql +++ b/packages/frontend/graphql/src/graphql/workspace-quota.gql @@ -4,6 +4,7 @@ query workspaceQuota($id: String!) { name blobLimit storageQuota + usedStorageQuota historyPeriod memberLimit memberCount @@ -14,7 +15,6 @@ query workspaceQuota($id: String!) { historyPeriod memberLimit } - usedSize } } } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index b6eac30be6..1a0c69b124 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -1,4 +1,3 @@ -/* oxlint-disable */ export type Maybe = T | null; export type InputMaybe = T | null; export type Exact = { @@ -188,11 +187,6 @@ export interface CreateChatSessionInput { workspaceId: Scalars['String']['input']; } -export interface UpdateChatSessionInput { - sessionId: Scalars['String']['input']; - promptName: Scalars['String']['input']; -} - export interface CreateCheckoutSessionInput { args?: InputMaybe; coupon?: InputMaybe; @@ -282,6 +276,7 @@ export type ErrorDataUnion = | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType + | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType @@ -356,6 +351,7 @@ export enum ErrorNames { OAUTH_STATE_EXPIRED = 'OAUTH_STATE_EXPIRED', PAGE_IS_NOT_PUBLIC = 'PAGE_IS_NOT_PUBLIC', PASSWORD_REQUIRED = 'PASSWORD_REQUIRED', + QUERY_TOO_LONG = 'QUERY_TOO_LONG', RUNTIME_CONFIG_NOT_FOUND = 'RUNTIME_CONFIG_NOT_FOUND', SAME_EMAIL_PROVIDED = 'SAME_EMAIL_PROVIDED', SAME_SUBSCRIPTION_RECURRING = 'SAME_SUBSCRIPTION_RECURRING', @@ -384,12 +380,14 @@ export enum ErrorNames { WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD', } -/** The type of workspace feature */ export enum FeatureType { AIEarlyAccess = 'AIEarlyAccess', Admin = 'Admin', - Copilot = 'Copilot', EarlyAccess = 'EarlyAccess', + FreePlan = 'FreePlan', + LifetimeProPlan = 'LifetimeProPlan', + ProPlan = 'ProPlan', + TeamPlan = 'TeamPlan', UnlimitedCopilot = 'UnlimitedCopilot', UnlimitedWorkspace = 'UnlimitedWorkspace', } @@ -402,16 +400,6 @@ export interface ForkChatSessionInput { workspaceId: Scalars['String']['input']; } -export interface HumanReadableQuotaType { - __typename?: 'HumanReadableQuotaType'; - blobLimit: Scalars['String']['output']; - copilotActionLimit: Maybe; - historyPeriod: Scalars['String']['output']; - memberLimit: Scalars['String']['output']; - name: Scalars['String']['output']; - storageQuota: Scalars['String']['output']; -} - export interface InvalidEmailDataType { __typename?: 'InvalidEmailDataType'; email: Scalars['String']['output']; @@ -533,6 +521,15 @@ export interface InvoiceType { updatedAt: Scalars['DateTime']['output']; } +export interface License { + __typename?: 'License'; + expiredAt: Maybe; + installedAt: Scalars['DateTime']['output']; + quantity: Scalars['Int']['output']; + recurring: SubscriptionRecurring; + validatedAt: Scalars['DateTime']['output']; +} + export interface LimitedUserType { __typename?: 'LimitedUserType'; /** User email */ @@ -574,7 +571,8 @@ export interface MissingOauthQueryParameterDataType { export interface Mutation { __typename?: 'Mutation'; acceptInviteById: Scalars['Boolean']['output']; - addWorkspaceFeature: Scalars['Int']['output']; + activateLicense: License; + addWorkspaceFeature: Scalars['Boolean']['output']; approveMember: Scalars['String']['output']; cancelSubscription: SubscriptionType; changeEmail: UserType; @@ -594,10 +592,12 @@ export interface Mutation { /** Create a stripe customer portal to manage payment methods */ createCustomerPortal: Scalars['String']['output']; createInviteLink: InviteLink; + createSelfhostWorkspaceCustomerPortal: Scalars['String']['output']; /** Create a new user */ createUser: UserType; /** Create a new workspace */ createWorkspace: WorkspaceType; + deactivateLicense: Scalars['Boolean']['output']; deleteAccount: DeleteAccount; deleteBlob: Scalars['Boolean']['output']; /** Delete a user account */ @@ -615,7 +615,7 @@ export interface Mutation { releaseDeletedBlobs: Scalars['Boolean']['output']; /** Remove user avatar */ removeAvatar: RemoveAvatar; - removeWorkspaceFeature: Scalars['Int']['output']; + removeWorkspaceFeature: Scalars['Boolean']['output']; resumeSubscription: SubscriptionType; revoke: Scalars['Boolean']['output']; revokeInviteLink: Scalars['Boolean']['output']; @@ -628,11 +628,12 @@ export interface Mutation { sendVerifyChangeEmail: Scalars['Boolean']['output']; sendVerifyEmail: Scalars['Boolean']['output']; setBlob: Scalars['String']['output']; - setWorkspaceExperimentalFeature: Scalars['Boolean']['output']; /** @deprecated renamed to publishPage */ sharePage: Scalars['Boolean']['output']; /** Update a copilot prompt */ updateCopilotPrompt: CopilotPromptType; + /** Update a chat session */ + updateCopilotSession: Scalars['String']['output']; updateProfile: UserType; /** update server runtime configurable setting */ updateRuntimeConfig: ServerRuntimeConfigType; @@ -656,6 +657,11 @@ export interface MutationAcceptInviteByIdArgs { workspaceId: Scalars['String']['input']; } +export interface MutationActivateLicenseArgs { + license: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +} + export interface MutationAddWorkspaceFeatureArgs { feature: FeatureType; workspaceId: Scalars['String']['input']; @@ -708,15 +714,15 @@ export interface MutationCreateCopilotSessionArgs { options: CreateChatSessionInput; } -export interface MutationUpdateCopilotSessionArgs { - options: UpdateChatSessionInput; -} - export interface MutationCreateInviteLinkArgs { expireTime: WorkspaceInviteLinkExpireTime; workspaceId: Scalars['String']['input']; } +export interface MutationCreateSelfhostWorkspaceCustomerPortalArgs { + workspaceId: Scalars['String']['input']; +} + export interface MutationCreateUserArgs { input: CreateUserInput; } @@ -725,6 +731,10 @@ export interface MutationCreateWorkspaceArgs { init?: InputMaybe; } +export interface MutationDeactivateLicenseArgs { + workspaceId: Scalars['String']['input']; +} + export interface MutationDeleteBlobArgs { hash?: InputMaybe; key?: InputMaybe; @@ -849,12 +859,6 @@ export interface MutationSetBlobArgs { workspaceId: Scalars['String']['input']; } -export interface MutationSetWorkspaceExperimentalFeatureArgs { - enable: Scalars['Boolean']['input']; - feature: FeatureType; - workspaceId: Scalars['String']['input']; -} - export interface MutationSharePageArgs { pageId: Scalars['String']['input']; workspaceId: Scalars['String']['input']; @@ -865,6 +869,10 @@ export interface MutationUpdateCopilotPromptArgs { name: Scalars['String']['input']; } +export interface MutationUpdateCopilotSessionArgs { + options: UpdateChatSessionInput; +} + export interface MutationUpdateProfileArgs { input: UpdateUserInput; } @@ -958,7 +966,6 @@ export interface Query { listBlobs: Array; /** List all copilot prompts */ listCopilotPrompts: Array; - listWorkspaceFeatures: Array; prices: Array; /** server config */ serverConfig: ServerConfigType; @@ -1001,10 +1008,6 @@ export interface QueryListBlobsArgs { workspaceId: Scalars['String']['input']; } -export interface QueryListWorkspaceFeaturesArgs { - feature: FeatureType; -} - export interface QueryUserArgs { email: Scalars['String']['input']; } @@ -1035,17 +1038,9 @@ export interface QueryChatHistoriesInput { skip?: InputMaybe; } -export interface QuotaQueryType { - __typename?: 'QuotaQueryType'; - blobLimit: Scalars['SafeInt']['output']; - copilotActionLimit: Maybe; - historyPeriod: Scalars['SafeInt']['output']; - humanReadable: HumanReadableQuotaType; - memberCount: Scalars['SafeInt']['output']; - memberLimit: Scalars['SafeInt']['output']; - name: Scalars['String']['output']; - storageQuota: Scalars['SafeInt']['output']; - usedSize: Scalars['SafeInt']['output']; +export interface QueryTooLongDataType { + __typename?: 'QueryTooLongDataType'; + max: Scalars['Int']['output']; } export interface RemoveAvatar { @@ -1241,6 +1236,12 @@ export interface UnsupportedSubscriptionPlanDataType { plan: Scalars['String']['output']; } +export interface UpdateChatSessionInput { + /** The prompt name to use for the session */ + promptName: Scalars['String']['input']; + sessionId: Scalars['String']['input']; +} + export interface UpdateUserInput { /** User name */ name?: InputMaybe; @@ -1258,27 +1259,32 @@ export interface UpdateWorkspaceInput { export type UserOrLimitedUser = LimitedUserType | UserType; -export interface UserQuota { - __typename?: 'UserQuota'; - blobLimit: Scalars['SafeInt']['output']; - historyPeriod: Scalars['SafeInt']['output']; - humanReadable: UserQuotaHumanReadable; - memberLimit: Scalars['Int']['output']; - name: Scalars['String']['output']; - storageQuota: Scalars['SafeInt']['output']; -} - -export interface UserQuotaHumanReadable { - __typename?: 'UserQuotaHumanReadable'; +export interface UserQuotaHumanReadableType { + __typename?: 'UserQuotaHumanReadableType'; blobLimit: Scalars['String']['output']; + copilotActionLimit: Scalars['String']['output']; historyPeriod: Scalars['String']['output']; memberLimit: Scalars['String']['output']; name: Scalars['String']['output']; storageQuota: Scalars['String']['output']; + usedStorageQuota: Scalars['String']['output']; } -export interface UserQuotaUsage { - __typename?: 'UserQuotaUsage'; +export interface UserQuotaType { + __typename?: 'UserQuotaType'; + blobLimit: Scalars['SafeInt']['output']; + copilotActionLimit: Maybe; + historyPeriod: Scalars['SafeInt']['output']; + humanReadable: UserQuotaHumanReadableType; + memberLimit: Scalars['Int']['output']; + name: Scalars['String']['output']; + storageQuota: Scalars['SafeInt']['output']; + usedStorageQuota: Scalars['SafeInt']['output']; +} + +export interface UserQuotaUsageType { + __typename?: 'UserQuotaUsageType'; + /** @deprecated use `UserQuotaType['usedStorageQuota']` instead */ storageQuota: Scalars['SafeInt']['output']; } @@ -1306,8 +1312,8 @@ export interface UserType { invoices: Array; /** User name */ name: Scalars['String']['output']; - quota: Maybe; - quotaUsage: UserQuotaUsage; + quota: UserQuotaType; + quotaUsage: UserQuotaUsageType; subscriptions: Array; /** @deprecated use [/api/auth/sign-in?native=true] instead */ token: TokenType; @@ -1371,10 +1377,33 @@ export interface WorkspacePageMeta { updatedBy: Maybe; } +export interface WorkspaceQuotaHumanReadableType { + __typename?: 'WorkspaceQuotaHumanReadableType'; + blobLimit: Scalars['String']['output']; + historyPeriod: Scalars['String']['output']; + memberCount: Scalars['String']['output']; + memberLimit: Scalars['String']['output']; + name: Scalars['String']['output']; + storageQuota: Scalars['String']['output']; + storageQuotaUsed: Scalars['String']['output']; +} + +export interface WorkspaceQuotaType { + __typename?: 'WorkspaceQuotaType'; + blobLimit: Scalars['SafeInt']['output']; + historyPeriod: Scalars['SafeInt']['output']; + humanReadable: WorkspaceQuotaHumanReadableType; + memberCount: Scalars['Int']['output']; + memberLimit: Scalars['Int']['output']; + name: Scalars['String']['output']; + storageQuota: Scalars['SafeInt']['output']; + /** @deprecated use `usedStorageQuota` instead */ + usedSize: Scalars['SafeInt']['output']; + usedStorageQuota: Scalars['SafeInt']['output']; +} + export interface WorkspaceType { __typename?: 'WorkspaceType'; - /** Available features of workspace */ - availableFeatures: Array; /** List blobs of workspace */ blobs: Array; /** Blobs size of workspace */ @@ -1385,8 +1414,6 @@ export interface WorkspaceType { enableAi: Scalars['Boolean']['output']; /** Enable url previous when sharing */ enableUrlPreview: Scalars['Boolean']['output']; - /** Enabled features of workspace */ - features: Array; histories: Array; id: Scalars['ID']['output']; /** is current workspace initialized */ @@ -1396,6 +1423,8 @@ export interface WorkspaceType { /** Get user invoice count */ invoiceCount: Scalars['Int']['output']; invoices: Array; + /** The selfhost license of the workspace */ + license: Maybe; /** member count of workspace */ memberCount: Scalars['Int']['output']; /** Members of workspace */ @@ -1413,7 +1442,7 @@ export interface WorkspaceType { /** Public pages of a workspace */ publicPages: Array; /** quota of workspace */ - quota: QuotaQueryType; + quota: WorkspaceQuotaType; /** * Shared pages of workspace * @deprecated use WorkspaceType.publicPages @@ -1437,6 +1466,7 @@ export interface WorkspaceTypeInvoicesArgs { } export interface WorkspaceTypeMembersArgs { + query?: InputMaybe; skip?: InputMaybe; take?: InputMaybe; } @@ -1630,15 +1660,6 @@ export type CreateCopilotSessionMutation = { createCopilotSession: string; }; -export type UpdateCopilotSessionMutationVariables = Exact<{ - options: UpdateChatSessionInput; -}>; - -export type UpdateCopilotSessionMutation = { - __typename?: 'Mutation'; - updateCopilotSession: string; -}; - export type CreateCustomerPortalMutationVariables = Exact<{ [key: string]: never; }>; @@ -1975,16 +1996,16 @@ export type GetUserByEmailQuery = { emailVerified: boolean; avatarUrl: string | null; quota: { - __typename?: 'UserQuota'; + __typename?: 'UserQuotaType'; humanReadable: { - __typename?: 'UserQuotaHumanReadable'; + __typename?: 'UserQuotaHumanReadableType'; blobLimit: string; historyPeriod: string; memberLimit: string; name: string; storageQuota: string; }; - } | null; + }; } | null; }; @@ -2026,15 +2047,6 @@ export type GetUsersCountQueryVariables = Exact<{ [key: string]: never }>; export type GetUsersCountQuery = { __typename?: 'Query'; usersCount: number }; -export type GetWorkspaceFeaturesQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type GetWorkspaceFeaturesQuery = { - __typename?: 'Query'; - workspace: { __typename?: 'WorkspaceType'; features: Array }; -}; - export type GetWorkspaceInfoQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; @@ -2281,22 +2293,22 @@ export type QuotaQuery = { __typename?: 'UserType'; id: string; quota: { - __typename?: 'UserQuota'; + __typename?: 'UserQuotaType'; name: string; blobLimit: number; storageQuota: number; historyPeriod: number; memberLimit: number; humanReadable: { - __typename?: 'UserQuotaHumanReadable'; + __typename?: 'UserQuotaHumanReadableType'; name: string; blobLimit: string; storageQuota: string; historyPeriod: string; memberLimit: string; }; - } | null; - quotaUsage: { __typename?: 'UserQuotaUsage'; storageQuota: number }; + }; + quotaUsage: { __typename?: 'UserQuotaUsageType'; storageQuota: number }; } | null; }; @@ -2487,6 +2499,15 @@ export type UpdateAccountMutation = { }; }; +export type UpdateCopilotSessionMutationVariables = Exact<{ + options: UpdateChatSessionInput; +}>; + +export type UpdateCopilotSessionMutation = { + __typename?: 'Mutation'; + updateCopilotSession: string; +}; + export type UpdatePromptMutationVariables = Exact<{ name: Scalars['String']['input']; messages: Array | CopilotPromptMessageInput; @@ -2617,75 +2638,6 @@ export type SetEnableUrlPreviewMutation = { updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; -export type EnabledFeaturesQueryVariables = Exact<{ - id: Scalars['String']['input']; -}>; - -export type EnabledFeaturesQuery = { - __typename?: 'Query'; - workspace: { __typename?: 'WorkspaceType'; features: Array }; -}; - -export type AvailableFeaturesQueryVariables = Exact<{ - id: Scalars['String']['input']; -}>; - -export type AvailableFeaturesQuery = { - __typename?: 'Query'; - workspace: { - __typename?: 'WorkspaceType'; - availableFeatures: Array; - }; -}; - -export type SetWorkspaceExperimentalFeatureMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; - feature: FeatureType; - enable: Scalars['Boolean']['input']; -}>; - -export type SetWorkspaceExperimentalFeatureMutation = { - __typename?: 'Mutation'; - setWorkspaceExperimentalFeature: boolean; -}; - -export type AddWorkspaceFeatureMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; - feature: FeatureType; -}>; - -export type AddWorkspaceFeatureMutation = { - __typename?: 'Mutation'; - addWorkspaceFeature: number; -}; - -export type ListWorkspaceFeaturesQueryVariables = Exact<{ - feature: FeatureType; -}>; - -export type ListWorkspaceFeaturesQuery = { - __typename?: 'Query'; - listWorkspaceFeatures: Array<{ - __typename?: 'WorkspaceType'; - id: string; - public: boolean; - createdAt: string; - memberCount: number; - features: Array; - owner: { __typename?: 'UserType'; id: string }; - }>; -}; - -export type RemoveWorkspaceFeatureMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; - feature: FeatureType; -}>; - -export type RemoveWorkspaceFeatureMutation = { - __typename?: 'Mutation'; - removeWorkspaceFeature: number; -}; - export type InviteByEmailMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; email: Scalars['String']['input']; @@ -2794,16 +2746,16 @@ export type WorkspaceQuotaQuery = { workspace: { __typename?: 'WorkspaceType'; quota: { - __typename?: 'QuotaQueryType'; + __typename?: 'WorkspaceQuotaType'; name: string; blobLimit: number; storageQuota: number; + usedStorageQuota: number; historyPeriod: number; memberLimit: number; memberCount: number; - usedSize: number; humanReadable: { - __typename?: 'HumanReadableQuotaType'; + __typename?: 'WorkspaceQuotaHumanReadableType'; name: string; blobLimit: string; storageQuota: string; @@ -2941,11 +2893,6 @@ export type Queries = variables: GetUsersCountQueryVariables; response: GetUsersCountQuery; } - | { - name: 'getWorkspaceFeaturesQuery'; - variables: GetWorkspaceFeaturesQueryVariables; - response: GetWorkspaceFeaturesQuery; - } | { name: 'getWorkspaceInfoQuery'; variables: GetWorkspaceInfoQueryVariables; @@ -3031,21 +2978,6 @@ export type Queries = variables: GetWorkspaceConfigQueryVariables; response: GetWorkspaceConfigQuery; } - | { - name: 'enabledFeaturesQuery'; - variables: EnabledFeaturesQueryVariables; - response: EnabledFeaturesQuery; - } - | { - name: 'availableFeaturesQuery'; - variables: AvailableFeaturesQueryVariables; - response: AvailableFeaturesQuery; - } - | { - name: 'listWorkspaceFeaturesQuery'; - variables: ListWorkspaceFeaturesQueryVariables; - response: ListWorkspaceFeaturesQuery; - } | { name: 'workspaceInvoicesQuery'; variables: WorkspaceInvoicesQueryVariables; @@ -3113,11 +3045,6 @@ export type Mutations = variables: CreateCopilotSessionMutationVariables; response: CreateCopilotSessionMutation; } - | { - name: 'updateCopilotSessionMutation'; - variables: UpdateCopilotSessionMutationVariables; - response: UpdateCopilotSessionMutation; - } | { name: 'createCustomerPortalMutation'; variables: CreateCustomerPortalMutationVariables; @@ -3228,6 +3155,11 @@ export type Mutations = variables: UpdateAccountMutationVariables; response: UpdateAccountMutation; } + | { + name: 'updateCopilotSessionMutation'; + variables: UpdateCopilotSessionMutationVariables; + response: UpdateCopilotSessionMutation; + } | { name: 'updatePromptMutation'; variables: UpdatePromptMutationVariables; @@ -3268,21 +3200,6 @@ export type Mutations = variables: SetEnableUrlPreviewMutationVariables; response: SetEnableUrlPreviewMutation; } - | { - name: 'setWorkspaceExperimentalFeatureMutation'; - variables: SetWorkspaceExperimentalFeatureMutationVariables; - response: SetWorkspaceExperimentalFeatureMutation; - } - | { - name: 'addWorkspaceFeatureMutation'; - variables: AddWorkspaceFeatureMutationVariables; - response: AddWorkspaceFeatureMutation; - } - | { - name: 'removeWorkspaceFeatureMutation'; - variables: RemoveWorkspaceFeatureMutationVariables; - response: RemoveWorkspaceFeatureMutation; - } | { name: 'inviteByEmailMutation'; variables: InviteByEmailMutationVariables; diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index f29a5f6df4..e84d7d7364 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -22,11 +22,8 @@ let user: { password: string; }; -test.beforeEach(async () => { - user = await createRandomUser(); -}); - test.beforeEach(async ({ page }) => { + user = await createRandomUser(); await loginUser(page, user); }); diff --git a/tests/kit/src/utils/cloud.ts b/tests/kit/src/utils/cloud.ts index fc4bcbbcd9..1e4658ef50 100644 --- a/tests/kit/src/utils/cloud.ts +++ b/tests/kit/src/utils/cloud.ts @@ -119,9 +119,8 @@ export async function createRandomUser(): Promise<{ const result = await runPrisma(async client => { const featureId = await client.feature .findFirst({ - where: { feature: 'free_plan_v1' }, + where: { name: 'free_plan_v1' }, select: { id: true }, - orderBy: { version: 'desc' }, }) .then(f => f!.id); @@ -135,6 +134,8 @@ export async function createRandomUser(): Promise<{ reason: 'created by test case', activated: true, featureId, + name: 'free_plan_v1', + type: 1, }, }, }, @@ -169,16 +170,14 @@ export async function createRandomAIUser(): Promise<{ const result = await runPrisma(async client => { const freeFeatureId = await client.feature .findFirst({ - where: { feature: 'free_plan_v1' }, + where: { name: 'free_plan_v1' }, select: { id: true }, - orderBy: { version: 'desc' }, }) .then(f => f!.id); const aiFeatureId = await client.feature .findFirst({ - where: { feature: 'unlimited_copilot' }, + where: { name: 'unlimited_copilot' }, select: { id: true }, - orderBy: { version: 'desc' }, }) .then(f => f!.id); @@ -193,11 +192,15 @@ export async function createRandomAIUser(): Promise<{ reason: 'created by test case', activated: true, featureId: freeFeatureId, + name: 'free_plan_v1', + type: 1, }, { reason: 'created by test case', activated: true, featureId: aiFeatureId, + name: 'unlimited_copilot', + type: 0, }, ], },