From e516e0db233c444a9cba4174ee35ba8a53dd6f1d Mon Sep 17 00:00:00 2001 From: liuyi Date: Mon, 22 Jan 2024 07:40:28 +0000 Subject: [PATCH] refactor(server): plugin modules (#5630) - [x] separates modules into `fundamental`, `core`, `plugins` - [x] optional modules with `@OptionalModule` decorator to install modules with requirements met(`requires`, `if`) - [x] `module.contributesTo` defines optional features that will be enabled if module registered - [x] `AFFiNE.plugins.use('payment', {})` to enable a optional/plugin module - [x] `PaymentModule` is the first plugin module - [x] GraphQLSchema will not be generated for non-included modules - [x] Frontend can use `ServerConfigType` query to detect which features are enabled - [x] override existing provider globally --- .github/workflows/build-test.yml | 1 + packages/backend/server/src/app.module.ts | 170 ++++- packages/backend/server/src/app.ts | 20 +- .../backend/server/src/config/affine.env.ts | 15 +- packages/backend/server/src/config/affine.ts | 52 +- .../src/{modules => core}/auth/guard.ts | 0 .../src/{modules => core}/auth/index.ts | 0 .../auth/next-auth-options.ts | 0 .../auth/next-auth.controller.ts | 0 .../src/{modules => core}/auth/resolver.ts | 0 .../src/{modules => core}/auth/service.ts | 0 .../src/{modules => core}/auth/utils/index.ts | 0 .../src/{modules => core}/auth/utils/jwt.ts | 0 .../{modules => core}/auth/utils/send-mail.ts | 0 packages/backend/server/src/core/config.ts | 58 ++ .../src/{modules => core}/doc/history.ts | 0 .../server/src/{modules => core}/doc/index.ts | 0 .../src/{modules => core}/doc/manager.ts | 0 .../src/{modules => core}/features/feature.ts | 0 .../src/{modules => core}/features/index.ts | 0 .../{modules => core}/features/management.ts | 0 .../src/{modules => core}/features/service.ts | 0 .../features/types/common.ts | 0 .../features/types/copilot.ts | 0 .../features/types/early-access.ts | 0 .../{modules => core}/features/types/index.ts | 0 .../features/types/unlimited-workspace.ts | 0 .../src/{modules => core}/quota/constant.ts | 0 .../src/{modules => core}/quota/index.ts | 0 .../src/{modules => core}/quota/quota.ts | 0 .../src/{modules => core}/quota/schema.ts | 0 .../src/{modules => core}/quota/service.ts | 24 +- .../src/{modules => core}/quota/storage.ts | 0 .../src/{modules => core}/quota/types.ts | 0 .../src/{modules => core}/storage/index.ts | 0 .../storage/wrappers/avatar.ts | 0 .../storage/wrappers/blob.ts | 0 .../storage/wrappers/index.ts | 0 .../{modules => core}/sync/events/error.ts | 0 .../sync/events/events.gateway.ts | 0 .../sync/events/events.module.ts | 0 .../src/{modules => core}/sync/index.ts | 0 .../src/{modules => core}/users/controller.ts | 0 .../src/{modules => core}/users/index.ts | 0 .../src/{modules => core}/users/management.ts | 0 .../src/{modules => core}/users/resolver.ts | 0 .../src/{modules => core}/users/types.ts | 0 .../src/{modules => core}/users/users.ts | 0 .../utils/__tests__/doc.spec.ts | 0 .../server/src/{modules => core}/utils/doc.ts | 0 .../workspaces/controller.ts | 0 .../src/{modules => core}/workspaces/index.ts | 0 .../workspaces/management.ts | 0 .../workspaces/permission.ts | 0 .../workspaces/resolvers/blob.ts | 0 .../workspaces/resolvers/history.ts | 0 .../workspaces/resolvers/index.ts | 0 .../workspaces/resolvers/page.ts | 0 .../workspaces/resolvers/workspace.ts | 0 .../src/{modules => core}/workspaces/types.ts | 0 .../src/{modules => core}/workspaces/utils.ts | 0 packages/backend/server/src/data/app.ts | 3 + .../1605053000403-self-host-admin.ts | 2 +- .../src/data/migrations/1698398506533-guid.ts | 2 +- .../1698652531198-user-features-init.ts | 4 +- .../1702620653283-old-user-feature.ts | 2 +- .../1703756315970-unamed-account.ts | 6 +- .../1703828796699-workspace-blobs.ts | 2 +- .../1704352562369-refresh-user-features.ts | 2 +- .../migrations/1705395933447-new-free-plan.ts | 4 +- .../data/migrations/utils/user-features.ts | 2 +- .../server/src/fundamentals/cache/def.ts | 49 ++ .../server/src/fundamentals/cache/index.ts | 32 +- .../src/fundamentals/cache/instances.ts | 15 +- .../src/fundamentals/cache/interceptor.ts | 4 +- .../cache/{providers/cache.ts => local.ts} | 52 +- .../src/fundamentals/cache/redis/index.ts | 38 - .../server/src/fundamentals/config/def.ts | 87 +-- .../server/src/fundamentals/config/default.ts | 42 +- .../server/src/fundamentals/config/module.ts | 2 +- .../server/src/fundamentals/event/def.ts | 51 ++ .../server/src/fundamentals/event/events.ts | 33 - .../server/src/fundamentals/event/index.ts | 4 +- .../server/src/fundamentals/graphql/index.ts | 1 + .../backend/server/src/fundamentals/index.ts | 12 +- .../server/src/fundamentals/nestjs/index.ts | 1 + .../fundamentals/nestjs/optional-module.ts | 71 ++ .../src/fundamentals/throttler/index.ts | 24 +- .../server/src/fundamentals/utils/types.ts | 22 - .../src/fundamentals/websocket/index.ts | 18 +- .../fundamentals/websocket/redis-adapter.ts | 27 - packages/backend/server/src/global.d.ts | 26 + packages/backend/server/src/modules/config.ts | 32 - packages/backend/server/src/modules/index.ts | 70 -- .../backend/server/src/modules/self-hosted.ts | 38 - packages/backend/server/src/plugins/config.ts | 21 + packages/backend/server/src/plugins/index.ts | 8 + .../src/{modules => plugins}/payment/index.ts | 18 +- .../{modules => plugins}/payment/resolver.ts | 33 +- .../{modules => plugins}/payment/schedule.ts | 0 .../{modules => plugins}/payment/service.ts | 81 +-- .../{modules => plugins}/payment/stripe.ts | 10 +- .../server/src/plugins/payment/types.ts | 58 ++ .../{modules => plugins}/payment/webhook.ts | 15 +- .../redis.ts => plugins/redis/cache.ts} | 2 +- .../backend/server/src/plugins/redis/index.ts | 62 ++ .../server/src/plugins/redis/instances.ts | 56 ++ .../backend/server/src/plugins/redis/types.ts | 3 + .../server/src/plugins/redis/ws-adapter.ts | 28 + packages/backend/server/src/schema.gql | 661 +++++++++--------- packages/backend/server/tests/app.e2e.ts | 2 +- packages/backend/server/tests/auth.e2e.ts | 2 +- packages/backend/server/tests/auth.spec.ts | 4 +- packages/backend/server/tests/doc.spec.ts | 6 +- packages/backend/server/tests/feature.spec.ts | 12 +- packages/backend/server/tests/history.spec.ts | 6 +- packages/backend/server/tests/mailer.e2e.ts | 2 +- packages/backend/server/tests/mailer.spec.ts | 4 +- packages/backend/server/tests/quota.spec.ts | 6 +- packages/backend/server/tests/session.spec.ts | 11 +- packages/backend/server/tests/utils/invite.ts | 2 +- packages/backend/server/tests/utils/user.ts | 4 +- packages/backend/server/tests/utils/utils.ts | 8 +- .../backend/server/tests/utils/workspace.ts | 2 +- .../server/tests/workspace-blobs.spec.ts | 4 +- .../server/tests/workspace-invite.e2e.ts | 2 +- .../frontend/graphql/src/graphql/index.ts | 4 +- .../graphql/src/graphql/server-config.gql | 4 +- packages/frontend/graphql/src/schema.ts | 8 +- tests/affine-cloud/playwright.config.ts | 2 + 130 files changed, 1297 insertions(+), 974 deletions(-) rename packages/backend/server/src/{modules => core}/auth/guard.ts (100%) rename packages/backend/server/src/{modules => core}/auth/index.ts (100%) rename packages/backend/server/src/{modules => core}/auth/next-auth-options.ts (100%) rename packages/backend/server/src/{modules => core}/auth/next-auth.controller.ts (100%) rename packages/backend/server/src/{modules => core}/auth/resolver.ts (100%) rename packages/backend/server/src/{modules => core}/auth/service.ts (100%) rename packages/backend/server/src/{modules => core}/auth/utils/index.ts (100%) rename packages/backend/server/src/{modules => core}/auth/utils/jwt.ts (100%) rename packages/backend/server/src/{modules => core}/auth/utils/send-mail.ts (100%) create mode 100644 packages/backend/server/src/core/config.ts rename packages/backend/server/src/{modules => core}/doc/history.ts (100%) rename packages/backend/server/src/{modules => core}/doc/index.ts (100%) rename packages/backend/server/src/{modules => core}/doc/manager.ts (100%) rename packages/backend/server/src/{modules => core}/features/feature.ts (100%) rename packages/backend/server/src/{modules => core}/features/index.ts (100%) rename packages/backend/server/src/{modules => core}/features/management.ts (100%) rename packages/backend/server/src/{modules => core}/features/service.ts (100%) rename packages/backend/server/src/{modules => core}/features/types/common.ts (100%) rename packages/backend/server/src/{modules => core}/features/types/copilot.ts (100%) rename packages/backend/server/src/{modules => core}/features/types/early-access.ts (100%) rename packages/backend/server/src/{modules => core}/features/types/index.ts (100%) rename packages/backend/server/src/{modules => core}/features/types/unlimited-workspace.ts (100%) rename packages/backend/server/src/{modules => core}/quota/constant.ts (100%) rename packages/backend/server/src/{modules => core}/quota/index.ts (100%) rename packages/backend/server/src/{modules => core}/quota/quota.ts (100%) rename packages/backend/server/src/{modules => core}/quota/schema.ts (100%) rename packages/backend/server/src/{modules => core}/quota/service.ts (86%) rename packages/backend/server/src/{modules => core}/quota/storage.ts (100%) rename packages/backend/server/src/{modules => core}/quota/types.ts (100%) rename packages/backend/server/src/{modules => core}/storage/index.ts (100%) rename packages/backend/server/src/{modules => core}/storage/wrappers/avatar.ts (100%) rename packages/backend/server/src/{modules => core}/storage/wrappers/blob.ts (100%) rename packages/backend/server/src/{modules => core}/storage/wrappers/index.ts (100%) rename packages/backend/server/src/{modules => core}/sync/events/error.ts (100%) rename packages/backend/server/src/{modules => core}/sync/events/events.gateway.ts (100%) rename packages/backend/server/src/{modules => core}/sync/events/events.module.ts (100%) rename packages/backend/server/src/{modules => core}/sync/index.ts (100%) rename packages/backend/server/src/{modules => core}/users/controller.ts (100%) rename packages/backend/server/src/{modules => core}/users/index.ts (100%) rename packages/backend/server/src/{modules => core}/users/management.ts (100%) rename packages/backend/server/src/{modules => core}/users/resolver.ts (100%) rename packages/backend/server/src/{modules => core}/users/types.ts (100%) rename packages/backend/server/src/{modules => core}/users/users.ts (100%) rename packages/backend/server/src/{modules => core}/utils/__tests__/doc.spec.ts (100%) rename packages/backend/server/src/{modules => core}/utils/doc.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/controller.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/index.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/management.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/permission.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/resolvers/blob.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/resolvers/history.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/resolvers/index.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/resolvers/page.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/resolvers/workspace.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/types.ts (100%) rename packages/backend/server/src/{modules => core}/workspaces/utils.ts (100%) create mode 100644 packages/backend/server/src/fundamentals/cache/def.ts rename packages/backend/server/src/fundamentals/cache/{providers/cache.ts => local.ts} (80%) delete mode 100644 packages/backend/server/src/fundamentals/cache/redis/index.ts create mode 100644 packages/backend/server/src/fundamentals/event/def.ts delete mode 100644 packages/backend/server/src/fundamentals/event/events.ts create mode 100644 packages/backend/server/src/fundamentals/nestjs/index.ts create mode 100644 packages/backend/server/src/fundamentals/nestjs/optional-module.ts delete mode 100644 packages/backend/server/src/fundamentals/websocket/redis-adapter.ts delete mode 100644 packages/backend/server/src/modules/config.ts delete mode 100644 packages/backend/server/src/modules/index.ts delete mode 100644 packages/backend/server/src/modules/self-hosted.ts create mode 100644 packages/backend/server/src/plugins/config.ts create mode 100644 packages/backend/server/src/plugins/index.ts rename packages/backend/server/src/{modules => plugins}/payment/index.ts (54%) rename packages/backend/server/src/{modules => plugins}/payment/resolver.ts (89%) rename packages/backend/server/src/{modules => plugins}/payment/schedule.ts (100%) rename packages/backend/server/src/{modules => plugins}/payment/service.ts (89%) rename packages/backend/server/src/{modules => plugins}/payment/stripe.ts (58%) create mode 100644 packages/backend/server/src/plugins/payment/types.ts rename packages/backend/server/src/{modules => plugins}/payment/webhook.ts (83%) rename packages/backend/server/src/{fundamentals/cache/providers/redis.ts => plugins/redis/cache.ts} (98%) create mode 100644 packages/backend/server/src/plugins/redis/index.ts create mode 100644 packages/backend/server/src/plugins/redis/instances.ts create mode 100644 packages/backend/server/src/plugins/redis/types.ts create mode 100644 packages/backend/server/src/plugins/redis/ws-adapter.ts diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 8c81d96fc..1a2a14107 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -19,6 +19,7 @@ env: MACOSX_DEPLOYMENT_TARGET: '10.13' NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright + DISABLE_TELEMETRY: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index 3307d1485..f763a0928 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -1,39 +1,169 @@ -import { DynamicModule, Module, Type } from '@nestjs/common'; +import { join } from 'node:path'; + +import { Logger, Module } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { get } from 'lodash-es'; import { AppController } from './app.controller'; +import { AuthModule } from './core/auth'; +import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config'; +import { DocModule } from './core/doc'; +import { FeatureModule } from './core/features'; +import { QuotaModule } from './core/quota'; +import { StorageModule } from './core/storage'; +import { SyncModule } from './core/sync'; +import { UsersModule } from './core/users'; +import { WorkspaceModule } from './core/workspaces'; +import { getOptionalModuleMetadata } from './fundamentals'; import { CacheInterceptor, CacheModule } from './fundamentals/cache'; -import { ConfigModule } from './fundamentals/config'; +import { + type AvailablePlugins, + Config, + ConfigModule, +} from './fundamentals/config'; import { EventModule } from './fundamentals/event'; +import { GqlModule } from './fundamentals/graphql'; import { MailModule } from './fundamentals/mailer'; import { MetricsModule } from './fundamentals/metrics'; import { PrismaModule } from './fundamentals/prisma'; import { SessionModule } from './fundamentals/session'; import { RateLimiterModule } from './fundamentals/throttler'; -import { BusinessModules } from './modules'; -import { AuthModule } from './modules/auth'; +import { WebSocketModule } from './fundamentals/websocket'; +import { pluginsMap } from './plugins'; -export const FunctionalityModules: Array = [ +export const FunctionalityModules = [ ConfigModule.forRoot(), + ScheduleModule.forRoot(), + EventModule, CacheModule, PrismaModule, MetricsModule, - EventModule, - SessionModule, RateLimiterModule, - AuthModule, + SessionModule, MailModule, ]; -@Module({ - providers: [ - { - provide: APP_INTERCEPTOR, - useClass: CacheInterceptor, - }, - ], - imports: [...FunctionalityModules, ...BusinessModules], - controllers: - process.env.SERVER_FLAVOR === 'selfhosted' ? [] : [AppController], -}) -export class AppModule {} +export class AppModuleBuilder { + private readonly modules: AFFiNEModule[] = []; + constructor(private readonly config: Config) {} + + use(...modules: AFFiNEModule[]): this { + modules.forEach(m => { + const requirements = getOptionalModuleMetadata(m, 'requires'); + // if condition not set or condition met, include the module + if (requirements?.length) { + const nonMetRequirements = requirements.filter(c => { + const value = get(this.config, c); + return ( + value === undefined || + value === null || + (typeof value === 'string' && value.trim().length === 0) + ); + }); + + if (nonMetRequirements.length) { + const name = 'module' in m ? m.module.name : m.name; + new Logger(name).warn( + `${name} is not enabled because of the required configuration is not satisfied.`, + 'Unsatisfied configuration:', + ...nonMetRequirements.map(config => ` AFFiNE.${config}`) + ); + return; + } + } + + const predicator = getOptionalModuleMetadata(m, 'if'); + if (predicator && !predicator(this.config)) { + return; + } + + const contribution = getOptionalModuleMetadata(m, 'contributesTo'); + if (contribution) { + ADD_ENABLED_FEATURES(contribution); + } + this.modules.push(m); + }); + + return this; + } + + useIf( + predicator: (config: Config) => boolean, + ...modules: AFFiNEModule[] + ): this { + if (predicator(this.config)) { + this.use(...modules); + } + + return this; + } + + compile() { + @Module({ + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: CacheInterceptor, + }, + ], + imports: this.modules, + controllers: this.config.flavor.selfhosted ? [] : [AppController], + }) + class AppModule {} + + return AppModule; + } +} + +function buildAppModule() { + const factor = new AppModuleBuilder(AFFiNE); + + factor + // common fundamental modules + .use(...FunctionalityModules) + // auth + .use(AuthModule) + + // business modules + .use(DocModule) + + // sync server only + .useIf(config => config.flavor.sync, SyncModule) + + // main server only + .useIf( + config => config.flavor.main, + ServerConfigModule, + WebSocketModule, + GqlModule, + StorageModule, + UsersModule, + WorkspaceModule, + FeatureModule, + QuotaModule + ) + + // self hosted server only + .useIf( + config => config.flavor.selfhosted, + ServeStaticModule.forRoot({ + rootPath: join('/app', 'static'), + }) + ); + + // plugin modules + AFFiNE.plugins.enabled.forEach(name => { + const plugin = pluginsMap.get(name as AvailablePlugins); + if (!plugin) { + throw new Error(`Unknown plugin ${name}`); + } + + factor.use(plugin); + }); + + return factor.compile(); +} + +export const AppModule = buildAppModule(); diff --git a/packages/backend/server/src/app.ts b/packages/backend/server/src/app.ts index c3c8edbcd..418141c49 100644 --- a/packages/backend/server/src/app.ts +++ b/packages/backend/server/src/app.ts @@ -1,10 +1,11 @@ +import { Type } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; import cookieParser from 'cookie-parser'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; -import { CacheRedis } from './fundamentals/cache/redis'; -import { RedisIoAdapter } from './fundamentals/websocket'; +import { SocketIoAdapter } from './fundamentals'; +import { SocketIoAdapterImpl } from './fundamentals/websocket'; import { ExceptionLogger } from './middleware/exception-logger'; import { serverTimingAndCache } from './middleware/timing'; @@ -31,11 +32,16 @@ export async function createApp() { app.useGlobalFilters(new ExceptionLogger()); app.use(cookieParser()); - if (AFFiNE.redis.enabled) { - const redis = app.get(CacheRedis, { strict: false }); - const redisIoAdapter = new RedisIoAdapter(app); - await redisIoAdapter.connectToRedis(redis); - app.useWebSocketAdapter(redisIoAdapter); + if (AFFiNE.flavor.sync) { + const SocketIoAdapter = app.get>( + SocketIoAdapterImpl, + { + strict: false, + } + ); + + const adapter = new SocketIoAdapter(app); + app.useWebSocketAdapter(adapter); } return app; diff --git a/packages/backend/server/src/config/affine.env.ts b/packages/backend/server/src/config/affine.env.ts index be0b54f08..16d5521bf 100644 --- a/packages/backend/server/src/config/affine.env.ts +++ b/packages/backend/server/src/config/affine.env.ts @@ -21,20 +21,19 @@ AFFiNE.ENV_MAP = { OAUTH_EMAIL_PASSWORD: 'auth.email.password', THROTTLE_TTL: ['rateLimiter.ttl', 'int'], THROTTLE_LIMIT: ['rateLimiter.limit', 'int'], - REDIS_SERVER_ENABLED: ['redis.enabled', 'boolean'], - REDIS_SERVER_HOST: 'redis.host', - REDIS_SERVER_PORT: ['redis.port', 'int'], - REDIS_SERVER_USER: 'redis.username', - REDIS_SERVER_PASSWORD: 'redis.password', - REDIS_SERVER_DATABASE: ['redis.database', 'int'], + REDIS_SERVER_HOST: 'plugins.redis.host', + REDIS_SERVER_PORT: ['plugins.redis.port', 'int'], + REDIS_SERVER_USER: 'plugins.redis.username', + REDIS_SERVER_PASSWORD: 'plugins.redis.password', + REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'], DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'], DOC_MERGE_USE_JWST_CODEC: [ 'doc.manager.experimentalMergeWithJwstCodec', 'boolean', ], ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'], - STRIPE_API_KEY: 'payment.stripe.keys.APIKey', - STRIPE_WEBHOOK_KEY: 'payment.stripe.keys.webhookKey', + STRIPE_API_KEY: 'plugins.payment.stripe.keys.APIKey', + STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey', FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'], }; diff --git a/packages/backend/server/src/config/affine.ts b/packages/backend/server/src/config/affine.ts index fa3a2f4e9..9bc889951 100644 --- a/packages/backend/server/src/config/affine.ts +++ b/packages/backend/server/src/config/affine.ts @@ -1,32 +1,38 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ // Custom configurations const env = process.env; -const node = AFFiNE.node; // TODO(@forehalo): detail explained -if (node.prod) { - // Storage - if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) { - AFFiNE.storage.providers.r2 = { - accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID, - credentials: { - accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!, - secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!, - }, - }; - AFFiNE.storage.storages.avatar.provider = 'r2'; - AFFiNE.storage.storages.avatar.bucket = 'account-avatar'; - AFFiNE.storage.storages.avatar.publicLinkFactory = key => - `https://avatar.affineassets.com/${key}`; +// Storage +if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) { + AFFiNE.storage.providers.r2 = { + accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID, + credentials: { + accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!, + secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!, + }, + }; + AFFiNE.storage.storages.avatar.provider = 'r2'; + AFFiNE.storage.storages.avatar.bucket = 'account-avatar'; + AFFiNE.storage.storages.avatar.publicLinkFactory = key => + `https://avatar.affineassets.com/${key}`; - AFFiNE.storage.storages.blob.provider = 'r2'; - AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${ - AFFiNE.affine.canary ? 'canary' : 'prod' - }`; - } - - // Metrics - AFFiNE.metrics.enabled = true; + AFFiNE.storage.storages.blob.provider = 'r2'; + AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${ + AFFiNE.affine.canary ? 'canary' : 'prod' + }`; } +// Metrics +AFFiNE.metrics.enabled = true; + +// Plugins Section Start +AFFiNE.plugins.use('payment', { + stripe: { + apiVersion: '2023-10-16', + }, +}); +AFFiNE.plugins.use('redis'); +// Plugins Section end + export default AFFiNE; diff --git a/packages/backend/server/src/modules/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts similarity index 100% rename from packages/backend/server/src/modules/auth/guard.ts rename to packages/backend/server/src/core/auth/guard.ts diff --git a/packages/backend/server/src/modules/auth/index.ts b/packages/backend/server/src/core/auth/index.ts similarity index 100% rename from packages/backend/server/src/modules/auth/index.ts rename to packages/backend/server/src/core/auth/index.ts diff --git a/packages/backend/server/src/modules/auth/next-auth-options.ts b/packages/backend/server/src/core/auth/next-auth-options.ts similarity index 100% rename from packages/backend/server/src/modules/auth/next-auth-options.ts rename to packages/backend/server/src/core/auth/next-auth-options.ts diff --git a/packages/backend/server/src/modules/auth/next-auth.controller.ts b/packages/backend/server/src/core/auth/next-auth.controller.ts similarity index 100% rename from packages/backend/server/src/modules/auth/next-auth.controller.ts rename to packages/backend/server/src/core/auth/next-auth.controller.ts diff --git a/packages/backend/server/src/modules/auth/resolver.ts b/packages/backend/server/src/core/auth/resolver.ts similarity index 100% rename from packages/backend/server/src/modules/auth/resolver.ts rename to packages/backend/server/src/core/auth/resolver.ts diff --git a/packages/backend/server/src/modules/auth/service.ts b/packages/backend/server/src/core/auth/service.ts similarity index 100% rename from packages/backend/server/src/modules/auth/service.ts rename to packages/backend/server/src/core/auth/service.ts diff --git a/packages/backend/server/src/modules/auth/utils/index.ts b/packages/backend/server/src/core/auth/utils/index.ts similarity index 100% rename from packages/backend/server/src/modules/auth/utils/index.ts rename to packages/backend/server/src/core/auth/utils/index.ts diff --git a/packages/backend/server/src/modules/auth/utils/jwt.ts b/packages/backend/server/src/core/auth/utils/jwt.ts similarity index 100% rename from packages/backend/server/src/modules/auth/utils/jwt.ts rename to packages/backend/server/src/core/auth/utils/jwt.ts diff --git a/packages/backend/server/src/modules/auth/utils/send-mail.ts b/packages/backend/server/src/core/auth/utils/send-mail.ts similarity index 100% rename from packages/backend/server/src/modules/auth/utils/send-mail.ts rename to packages/backend/server/src/core/auth/utils/send-mail.ts diff --git a/packages/backend/server/src/core/config.ts b/packages/backend/server/src/core/config.ts new file mode 100644 index 000000000..45dfe2462 --- /dev/null +++ b/packages/backend/server/src/core/config.ts @@ -0,0 +1,58 @@ +import { Module } from '@nestjs/common'; +import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql'; + +export enum ServerFeature { + Payment = 'payment', +} + +registerEnumType(ServerFeature, { + name: 'ServerFeature', +}); + +const ENABLED_FEATURES: ServerFeature[] = []; +export function ADD_ENABLED_FEATURES(feature: ServerFeature) { + ENABLED_FEATURES.push(feature); +} + +@ObjectType() +export class ServerConfigType { + @Field({ + description: + 'server identical name could be shown as badge on user interface', + }) + name!: string; + + @Field({ description: 'server version' }) + version!: string; + + @Field({ description: 'server base url' }) + baseUrl!: string; + + /** + * @deprecated + */ + @Field({ description: 'server flavor', deprecationReason: 'use `features`' }) + flavor!: string; + + @Field(() => [ServerFeature], { description: 'enabled server features' }) + features!: ServerFeature[]; +} +export class ServerConfigResolver { + @Query(() => ServerConfigType, { + description: 'server config', + }) + serverConfig(): ServerConfigType { + return { + name: AFFiNE.serverName, + version: AFFiNE.version, + baseUrl: AFFiNE.baseUrl, + flavor: AFFiNE.flavor.type, + features: ENABLED_FEATURES, + }; + } +} + +@Module({ + providers: [ServerConfigResolver], +}) +export class ServerConfigModule {} diff --git a/packages/backend/server/src/modules/doc/history.ts b/packages/backend/server/src/core/doc/history.ts similarity index 100% rename from packages/backend/server/src/modules/doc/history.ts rename to packages/backend/server/src/core/doc/history.ts diff --git a/packages/backend/server/src/modules/doc/index.ts b/packages/backend/server/src/core/doc/index.ts similarity index 100% rename from packages/backend/server/src/modules/doc/index.ts rename to packages/backend/server/src/core/doc/index.ts diff --git a/packages/backend/server/src/modules/doc/manager.ts b/packages/backend/server/src/core/doc/manager.ts similarity index 100% rename from packages/backend/server/src/modules/doc/manager.ts rename to packages/backend/server/src/core/doc/manager.ts diff --git a/packages/backend/server/src/modules/features/feature.ts b/packages/backend/server/src/core/features/feature.ts similarity index 100% rename from packages/backend/server/src/modules/features/feature.ts rename to packages/backend/server/src/core/features/feature.ts diff --git a/packages/backend/server/src/modules/features/index.ts b/packages/backend/server/src/core/features/index.ts similarity index 100% rename from packages/backend/server/src/modules/features/index.ts rename to packages/backend/server/src/core/features/index.ts diff --git a/packages/backend/server/src/modules/features/management.ts b/packages/backend/server/src/core/features/management.ts similarity index 100% rename from packages/backend/server/src/modules/features/management.ts rename to packages/backend/server/src/core/features/management.ts diff --git a/packages/backend/server/src/modules/features/service.ts b/packages/backend/server/src/core/features/service.ts similarity index 100% rename from packages/backend/server/src/modules/features/service.ts rename to packages/backend/server/src/core/features/service.ts diff --git a/packages/backend/server/src/modules/features/types/common.ts b/packages/backend/server/src/core/features/types/common.ts similarity index 100% rename from packages/backend/server/src/modules/features/types/common.ts rename to packages/backend/server/src/core/features/types/common.ts diff --git a/packages/backend/server/src/modules/features/types/copilot.ts b/packages/backend/server/src/core/features/types/copilot.ts similarity index 100% rename from packages/backend/server/src/modules/features/types/copilot.ts rename to packages/backend/server/src/core/features/types/copilot.ts diff --git a/packages/backend/server/src/modules/features/types/early-access.ts b/packages/backend/server/src/core/features/types/early-access.ts similarity index 100% rename from packages/backend/server/src/modules/features/types/early-access.ts rename to packages/backend/server/src/core/features/types/early-access.ts diff --git a/packages/backend/server/src/modules/features/types/index.ts b/packages/backend/server/src/core/features/types/index.ts similarity index 100% rename from packages/backend/server/src/modules/features/types/index.ts rename to packages/backend/server/src/core/features/types/index.ts diff --git a/packages/backend/server/src/modules/features/types/unlimited-workspace.ts b/packages/backend/server/src/core/features/types/unlimited-workspace.ts similarity index 100% rename from packages/backend/server/src/modules/features/types/unlimited-workspace.ts rename to packages/backend/server/src/core/features/types/unlimited-workspace.ts diff --git a/packages/backend/server/src/modules/quota/constant.ts b/packages/backend/server/src/core/quota/constant.ts similarity index 100% rename from packages/backend/server/src/modules/quota/constant.ts rename to packages/backend/server/src/core/quota/constant.ts diff --git a/packages/backend/server/src/modules/quota/index.ts b/packages/backend/server/src/core/quota/index.ts similarity index 100% rename from packages/backend/server/src/modules/quota/index.ts rename to packages/backend/server/src/core/quota/index.ts diff --git a/packages/backend/server/src/modules/quota/quota.ts b/packages/backend/server/src/core/quota/quota.ts similarity index 100% rename from packages/backend/server/src/modules/quota/quota.ts rename to packages/backend/server/src/core/quota/quota.ts diff --git a/packages/backend/server/src/modules/quota/schema.ts b/packages/backend/server/src/core/quota/schema.ts similarity index 100% rename from packages/backend/server/src/modules/quota/schema.ts rename to packages/backend/server/src/core/quota/schema.ts diff --git a/packages/backend/server/src/modules/quota/service.ts b/packages/backend/server/src/core/quota/service.ts similarity index 86% rename from packages/backend/server/src/modules/quota/service.ts rename to packages/backend/server/src/core/quota/service.ts index d31637427..f4880eda9 100644 --- a/packages/backend/server/src/modules/quota/service.ts +++ b/packages/backend/server/src/core/quota/service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { PrismaService } from '../../fundamentals'; +import { type EventPayload, OnEvent, PrismaService } from '../../fundamentals'; import { FeatureKind } from '../features'; import { QuotaConfig } from './quota'; import { QuotaType } from './types'; @@ -155,4 +155,26 @@ export class QuotaService { }) .then(count => count > 0); } + + @OnEvent('user.subscription.activated') + async onSubscriptionUpdated({ + userId, + }: EventPayload<'user.subscription.activated'>) { + await this.switchUserQuota( + userId, + QuotaType.ProPlanV1, + 'subscription activated' + ); + } + + @OnEvent('user.subscription.canceled') + async onSubscriptionCanceled( + userId: EventPayload<'user.subscription.canceled'> + ) { + await this.switchUserQuota( + userId, + QuotaType.FreePlanV1, + 'subscription canceled' + ); + } } diff --git a/packages/backend/server/src/modules/quota/storage.ts b/packages/backend/server/src/core/quota/storage.ts similarity index 100% rename from packages/backend/server/src/modules/quota/storage.ts rename to packages/backend/server/src/core/quota/storage.ts diff --git a/packages/backend/server/src/modules/quota/types.ts b/packages/backend/server/src/core/quota/types.ts similarity index 100% rename from packages/backend/server/src/modules/quota/types.ts rename to packages/backend/server/src/core/quota/types.ts diff --git a/packages/backend/server/src/modules/storage/index.ts b/packages/backend/server/src/core/storage/index.ts similarity index 100% rename from packages/backend/server/src/modules/storage/index.ts rename to packages/backend/server/src/core/storage/index.ts diff --git a/packages/backend/server/src/modules/storage/wrappers/avatar.ts b/packages/backend/server/src/core/storage/wrappers/avatar.ts similarity index 100% rename from packages/backend/server/src/modules/storage/wrappers/avatar.ts rename to packages/backend/server/src/core/storage/wrappers/avatar.ts diff --git a/packages/backend/server/src/modules/storage/wrappers/blob.ts b/packages/backend/server/src/core/storage/wrappers/blob.ts similarity index 100% rename from packages/backend/server/src/modules/storage/wrappers/blob.ts rename to packages/backend/server/src/core/storage/wrappers/blob.ts diff --git a/packages/backend/server/src/modules/storage/wrappers/index.ts b/packages/backend/server/src/core/storage/wrappers/index.ts similarity index 100% rename from packages/backend/server/src/modules/storage/wrappers/index.ts rename to packages/backend/server/src/core/storage/wrappers/index.ts diff --git a/packages/backend/server/src/modules/sync/events/error.ts b/packages/backend/server/src/core/sync/events/error.ts similarity index 100% rename from packages/backend/server/src/modules/sync/events/error.ts rename to packages/backend/server/src/core/sync/events/error.ts diff --git a/packages/backend/server/src/modules/sync/events/events.gateway.ts b/packages/backend/server/src/core/sync/events/events.gateway.ts similarity index 100% rename from packages/backend/server/src/modules/sync/events/events.gateway.ts rename to packages/backend/server/src/core/sync/events/events.gateway.ts diff --git a/packages/backend/server/src/modules/sync/events/events.module.ts b/packages/backend/server/src/core/sync/events/events.module.ts similarity index 100% rename from packages/backend/server/src/modules/sync/events/events.module.ts rename to packages/backend/server/src/core/sync/events/events.module.ts diff --git a/packages/backend/server/src/modules/sync/index.ts b/packages/backend/server/src/core/sync/index.ts similarity index 100% rename from packages/backend/server/src/modules/sync/index.ts rename to packages/backend/server/src/core/sync/index.ts diff --git a/packages/backend/server/src/modules/users/controller.ts b/packages/backend/server/src/core/users/controller.ts similarity index 100% rename from packages/backend/server/src/modules/users/controller.ts rename to packages/backend/server/src/core/users/controller.ts diff --git a/packages/backend/server/src/modules/users/index.ts b/packages/backend/server/src/core/users/index.ts similarity index 100% rename from packages/backend/server/src/modules/users/index.ts rename to packages/backend/server/src/core/users/index.ts diff --git a/packages/backend/server/src/modules/users/management.ts b/packages/backend/server/src/core/users/management.ts similarity index 100% rename from packages/backend/server/src/modules/users/management.ts rename to packages/backend/server/src/core/users/management.ts diff --git a/packages/backend/server/src/modules/users/resolver.ts b/packages/backend/server/src/core/users/resolver.ts similarity index 100% rename from packages/backend/server/src/modules/users/resolver.ts rename to packages/backend/server/src/core/users/resolver.ts diff --git a/packages/backend/server/src/modules/users/types.ts b/packages/backend/server/src/core/users/types.ts similarity index 100% rename from packages/backend/server/src/modules/users/types.ts rename to packages/backend/server/src/core/users/types.ts diff --git a/packages/backend/server/src/modules/users/users.ts b/packages/backend/server/src/core/users/users.ts similarity index 100% rename from packages/backend/server/src/modules/users/users.ts rename to packages/backend/server/src/core/users/users.ts diff --git a/packages/backend/server/src/modules/utils/__tests__/doc.spec.ts b/packages/backend/server/src/core/utils/__tests__/doc.spec.ts similarity index 100% rename from packages/backend/server/src/modules/utils/__tests__/doc.spec.ts rename to packages/backend/server/src/core/utils/__tests__/doc.spec.ts diff --git a/packages/backend/server/src/modules/utils/doc.ts b/packages/backend/server/src/core/utils/doc.ts similarity index 100% rename from packages/backend/server/src/modules/utils/doc.ts rename to packages/backend/server/src/core/utils/doc.ts diff --git a/packages/backend/server/src/modules/workspaces/controller.ts b/packages/backend/server/src/core/workspaces/controller.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/controller.ts rename to packages/backend/server/src/core/workspaces/controller.ts diff --git a/packages/backend/server/src/modules/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/index.ts rename to packages/backend/server/src/core/workspaces/index.ts diff --git a/packages/backend/server/src/modules/workspaces/management.ts b/packages/backend/server/src/core/workspaces/management.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/management.ts rename to packages/backend/server/src/core/workspaces/management.ts diff --git a/packages/backend/server/src/modules/workspaces/permission.ts b/packages/backend/server/src/core/workspaces/permission.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/permission.ts rename to packages/backend/server/src/core/workspaces/permission.ts diff --git a/packages/backend/server/src/modules/workspaces/resolvers/blob.ts b/packages/backend/server/src/core/workspaces/resolvers/blob.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/resolvers/blob.ts rename to packages/backend/server/src/core/workspaces/resolvers/blob.ts diff --git a/packages/backend/server/src/modules/workspaces/resolvers/history.ts b/packages/backend/server/src/core/workspaces/resolvers/history.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/resolvers/history.ts rename to packages/backend/server/src/core/workspaces/resolvers/history.ts diff --git a/packages/backend/server/src/modules/workspaces/resolvers/index.ts b/packages/backend/server/src/core/workspaces/resolvers/index.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/resolvers/index.ts rename to packages/backend/server/src/core/workspaces/resolvers/index.ts diff --git a/packages/backend/server/src/modules/workspaces/resolvers/page.ts b/packages/backend/server/src/core/workspaces/resolvers/page.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/resolvers/page.ts rename to packages/backend/server/src/core/workspaces/resolvers/page.ts diff --git a/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/resolvers/workspace.ts rename to packages/backend/server/src/core/workspaces/resolvers/workspace.ts diff --git a/packages/backend/server/src/modules/workspaces/types.ts b/packages/backend/server/src/core/workspaces/types.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/types.ts rename to packages/backend/server/src/core/workspaces/types.ts diff --git a/packages/backend/server/src/modules/workspaces/utils.ts b/packages/backend/server/src/core/workspaces/utils.ts similarity index 100% rename from packages/backend/server/src/modules/workspaces/utils.ts rename to packages/backend/server/src/core/workspaces/utils.ts diff --git a/packages/backend/server/src/data/app.ts b/packages/backend/server/src/data/app.ts index 9fd4c5886..7e594e8b9 100644 --- a/packages/backend/server/src/data/app.ts +++ b/packages/backend/server/src/data/app.ts @@ -13,6 +13,9 @@ import { RevertCommand, RunCommand } from './commands/run'; enableUpdateAutoMerging: false, }, }, + metrics: { + enabled: false, + }, }), BusinessAppModule, ], diff --git a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts index 0acd6c1c8..17b8ebb72 100644 --- a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts +++ b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts @@ -8,7 +8,7 @@ export class SelfHostAdmin1605053000403 { // do the migration static async up(db: PrismaClient, ref: ModuleRef) { const config = ref.get(Config, { strict: false }); - if (config.flavor === 'selfhosted') { + if (config.flavor.selfhosted) { if ( !process.env.AFFINE_ADMIN_EMAIL || !process.env.AFFINE_ADMIN_PASSWORD diff --git a/packages/backend/server/src/data/migrations/1698398506533-guid.ts b/packages/backend/server/src/data/migrations/1698398506533-guid.ts index 4a6393e28..095358ba9 100644 --- a/packages/backend/server/src/data/migrations/1698398506533-guid.ts +++ b/packages/backend/server/src/data/migrations/1698398506533-guid.ts @@ -1,7 +1,7 @@ import { PrismaClient } from '@prisma/client'; import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'; -import { DocID } from '../../modules/utils/doc'; +import { DocID } from '../../core/utils/doc'; export class Guid1698398506533 { // do the migration 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 index 7d28a18d4..5b86d4cd1 100644 --- a/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts +++ b/packages/backend/server/src/data/migrations/1698652531198-user-features-init.ts @@ -1,7 +1,7 @@ import { PrismaClient } from '@prisma/client'; -import { Features } from '../../modules/features'; -import { Quotas } from '../../modules/quota/schema'; +import { Features } from '../../core/features'; +import { Quotas } from '../../core/quota/schema'; import { migrateNewFeatureTable, upsertFeature } from './utils/user-features'; export class UserFeaturesInit1698652531198 { 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 index 9cbce5a0a..049c8bf99 100644 --- a/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts +++ b/packages/backend/server/src/data/migrations/1702620653283-old-user-feature.ts @@ -1,6 +1,6 @@ import { PrismaClient } from '@prisma/client'; -import { QuotaType } from '../../modules/quota/types'; +import { QuotaType } from '../../core/quota/types'; export class OldUserFeature1702620653283 { // do the migration static async up(db: PrismaClient) { diff --git a/packages/backend/server/src/data/migrations/1703756315970-unamed-account.ts b/packages/backend/server/src/data/migrations/1703756315970-unamed-account.ts index 1a4bfae49..0524c5d8f 100644 --- a/packages/backend/server/src/data/migrations/1703756315970-unamed-account.ts +++ b/packages/backend/server/src/data/migrations/1703756315970-unamed-account.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from '@prisma/client'; - -import type { UserType } from '../../modules/users'; +import { PrismaClient, type User } from '@prisma/client'; export class UnamedAccount1703756315970 { // do the migration @@ -8,7 +6,7 @@ export class UnamedAccount1703756315970 { await db.$transaction(async tx => { // only find users with empty names const users = await db.$queryRaw< - UserType[] + User[] >`SELECT * FROM users WHERE name ~ E'^[\\s\\u2000-\\u200F]*$';`; console.log( `renaming ${users.map(({ email }) => email).join('|')} users` diff --git a/packages/backend/server/src/data/migrations/1703828796699-workspace-blobs.ts b/packages/backend/server/src/data/migrations/1703828796699-workspace-blobs.ts index 0e505cc1d..40cd5fdd0 100644 --- a/packages/backend/server/src/data/migrations/1703828796699-workspace-blobs.ts +++ b/packages/backend/server/src/data/migrations/1703828796699-workspace-blobs.ts @@ -1,7 +1,7 @@ import { ModuleRef } from '@nestjs/core'; import { PrismaClient } from '@prisma/client'; -import { WorkspaceBlobStorage } from '../../modules/storage'; +import { WorkspaceBlobStorage } from '../../core/storage'; export class WorkspaceBlobs1703828796699 { // do the migration 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 index 1e903344a..69555563c 100644 --- a/packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts +++ b/packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts @@ -1,6 +1,6 @@ import { PrismaClient } from '@prisma/client'; -import { Features } from '../../modules/features'; +import { Features } from '../../core/features'; import { upsertFeature } from './utils/user-features'; export class RefreshUserFeatures1704352562369 { 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 index 744991c7d..0bb5c2dcb 100644 --- a/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts +++ b/packages/backend/server/src/data/migrations/1705395933447-new-free-plan.ts @@ -1,7 +1,7 @@ import { PrismaClient } from '@prisma/client'; -import { FeatureKind } from '../../modules/features'; -import { Quotas } from '../../modules/quota'; +import { FeatureKind } from '../../core/features'; +import { Quotas } from '../../core/quota'; import { upsertFeature } from './utils/user-features'; export class NewFreePlan1705395933447 { diff --git a/packages/backend/server/src/data/migrations/utils/user-features.ts b/packages/backend/server/src/data/migrations/utils/user-features.ts index 348342077..919d2b219 100644 --- a/packages/backend/server/src/data/migrations/utils/user-features.ts +++ b/packages/backend/server/src/data/migrations/utils/user-features.ts @@ -4,7 +4,7 @@ import { CommonFeature, FeatureKind, FeatureType, -} from '../../../modules/features'; +} from '../../../core/features'; // upgrade features from lower version to higher version export async function upsertFeature( diff --git a/packages/backend/server/src/fundamentals/cache/def.ts b/packages/backend/server/src/fundamentals/cache/def.ts new file mode 100644 index 000000000..a3d2d3b06 --- /dev/null +++ b/packages/backend/server/src/fundamentals/cache/def.ts @@ -0,0 +1,49 @@ +export interface CacheSetOptions { + // in milliseconds + ttl?: number; +} + +// extends if needed +export interface Cache { + // standard operation + get(key: string): Promise; + set( + key: string, + value: T, + opts?: CacheSetOptions + ): Promise; + setnx( + key: string, + value: T, + opts?: CacheSetOptions + ): Promise; + increase(key: string, count?: number): Promise; + decrease(key: string, count?: number): Promise; + delete(key: string): Promise; + has(key: string): Promise; + ttl(key: string): Promise; + expire(key: string, ttl: number): Promise; + + // list operations + pushBack(key: string, ...values: T[]): Promise; + pushFront(key: string, ...values: T[]): Promise; + len(key: string): Promise; + list(key: string, start: number, end: number): Promise; + popFront(key: string, count?: number): Promise; + popBack(key: string, count?: number): Promise; + + // map operations + mapSet( + map: string, + key: string, + value: T, + opts: CacheSetOptions + ): Promise; + mapIncrease(map: string, key: string, count?: number): Promise; + mapDecrease(map: string, key: string, count?: number): Promise; + mapGet(map: string, key: string): Promise; + mapDelete(map: string, key: string): Promise; + mapKeys(map: string): Promise; + mapRandomKey(map: string): Promise; + mapLen(map: string): Promise; +} diff --git a/packages/backend/server/src/fundamentals/cache/index.ts b/packages/backend/server/src/fundamentals/cache/index.ts index 2137f84cb..7c325d64a 100644 --- a/packages/backend/server/src/fundamentals/cache/index.ts +++ b/packages/backend/server/src/fundamentals/cache/index.ts @@ -1,35 +1,13 @@ -import { Global, Module, Provider, Type } from '@nestjs/common'; -import { Redis } from 'ioredis'; +import { Global, Module } from '@nestjs/common'; -import { SessionCache, ThrottlerCache } from './instances'; -import { LocalCache } from './providers/cache'; -import { RedisCache } from './providers/redis'; -import { CacheRedis, RedisModule, SessionRedis, ThrottlerRedis } from './redis'; - -function makeCacheProvider(CacheToken: Type, RedisToken: Type): Provider { - return { - provide: CacheToken, - useFactory: (redis?: Redis) => { - return redis ? new RedisCache(redis) : new LocalCache(); - }, - inject: [{ token: RedisToken, optional: true }], - }; -} - -const CacheProvider = makeCacheProvider(LocalCache, CacheRedis); -const SessionCacheProvider = makeCacheProvider(SessionCache, SessionRedis); -const ThrottlerCacheProvider = makeCacheProvider( - ThrottlerCache, - ThrottlerRedis -); +import { Cache, SessionCache } from './instances'; @Global() @Module({ - imports: AFFiNE.redis.enabled ? [RedisModule] : [], - providers: [CacheProvider, SessionCacheProvider, ThrottlerCacheProvider], - exports: [CacheProvider, SessionCacheProvider, ThrottlerCacheProvider], + providers: [Cache, SessionCache], + exports: [Cache, SessionCache], }) export class CacheModule {} -export { LocalCache as Cache, SessionCache, ThrottlerCache }; +export { Cache, SessionCache }; export { CacheInterceptor, MakeCache, PreventCache } from './interceptor'; diff --git a/packages/backend/server/src/fundamentals/cache/instances.ts b/packages/backend/server/src/fundamentals/cache/instances.ts index 0062aee8c..da57e1fed 100644 --- a/packages/backend/server/src/fundamentals/cache/instances.ts +++ b/packages/backend/server/src/fundamentals/cache/instances.ts @@ -1,4 +1,13 @@ -import { LocalCache } from './providers/cache'; +import { Injectable } from '@nestjs/common'; -export class SessionCache extends LocalCache {} -export class ThrottlerCache extends LocalCache {} +import { LocalCache } from './local'; + +@Injectable() +export class Cache extends LocalCache {} + +@Injectable() +export class SessionCache extends LocalCache { + constructor() { + super({ namespace: 'session' }); + } +} diff --git a/packages/backend/server/src/fundamentals/cache/interceptor.ts b/packages/backend/server/src/fundamentals/cache/interceptor.ts index b865264b5..c9d326aaf 100644 --- a/packages/backend/server/src/fundamentals/cache/interceptor.ts +++ b/packages/backend/server/src/fundamentals/cache/interceptor.ts @@ -10,7 +10,7 @@ import { Reflector } from '@nestjs/core'; import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; import { mergeMap, Observable, of } from 'rxjs'; -import { LocalCache } from './providers/cache'; +import { Cache } from './instances'; export const MakeCache = (key: string[], args?: string[]) => SetMetadata('cacheKey', [key, args]); @@ -24,7 +24,7 @@ export class CacheInterceptor implements NestInterceptor { private readonly logger = new Logger(CacheInterceptor.name); constructor( private readonly reflector: Reflector, - private readonly cache: LocalCache + private readonly cache: Cache ) {} async intercept( ctx: ExecutionContext, diff --git a/packages/backend/server/src/fundamentals/cache/providers/cache.ts b/packages/backend/server/src/fundamentals/cache/local.ts similarity index 80% rename from packages/backend/server/src/fundamentals/cache/providers/cache.ts rename to packages/backend/server/src/fundamentals/cache/local.ts index b770473bf..6e6ac148a 100644 --- a/packages/backend/server/src/fundamentals/cache/providers/cache.ts +++ b/packages/backend/server/src/fundamentals/cache/local.ts @@ -1,57 +1,7 @@ -import { Injectable } from '@nestjs/common'; import Keyv from 'keyv'; -export interface CacheSetOptions { - // in milliseconds - ttl?: number; -} +import type { Cache, CacheSetOptions } from './def'; -// extends if needed -export interface Cache { - // standard operation - get(key: string): Promise; - set( - key: string, - value: T, - opts?: CacheSetOptions - ): Promise; - setnx( - key: string, - value: T, - opts?: CacheSetOptions - ): Promise; - increase(key: string, count?: number): Promise; - decrease(key: string, count?: number): Promise; - delete(key: string): Promise; - has(key: string): Promise; - ttl(key: string): Promise; - expire(key: string, ttl: number): Promise; - - // list operations - pushBack(key: string, ...values: T[]): Promise; - pushFront(key: string, ...values: T[]): Promise; - len(key: string): Promise; - list(key: string, start: number, end: number): Promise; - popFront(key: string, count?: number): Promise; - popBack(key: string, count?: number): Promise; - - // map operations - mapSet( - map: string, - key: string, - value: T, - opts: CacheSetOptions - ): Promise; - mapIncrease(map: string, key: string, count?: number): Promise; - mapDecrease(map: string, key: string, count?: number): Promise; - mapGet(map: string, key: string): Promise; - mapDelete(map: string, key: string): Promise; - mapKeys(map: string): Promise; - mapRandomKey(map: string): Promise; - mapLen(map: string): Promise; -} - -@Injectable() export class LocalCache implements Cache { private readonly kv: Keyv; diff --git a/packages/backend/server/src/fundamentals/cache/redis/index.ts b/packages/backend/server/src/fundamentals/cache/redis/index.ts deleted file mode 100644 index d2807540c..000000000 --- a/packages/backend/server/src/fundamentals/cache/redis/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Global, Injectable, Module, OnModuleDestroy } from '@nestjs/common'; -import { Redis as IORedis } from 'ioredis'; - -import { Config } from '../../config'; - -class Redis extends IORedis implements OnModuleDestroy { - onModuleDestroy() { - this.disconnect(); - } -} - -@Injectable() -export class CacheRedis extends Redis { - constructor(config: Config) { - super({ ...config.redis, db: config.redis.database }); - } -} - -@Injectable() -export class ThrottlerRedis extends Redis { - constructor(config: Config) { - super({ ...config.redis, db: config.redis.database + 1 }); - } -} - -@Injectable() -export class SessionRedis extends Redis { - constructor(config: Config) { - super({ ...config.redis, db: config.redis.database + 2 }); - } -} - -@Global() -@Module({ - providers: [CacheRedis, ThrottlerRedis, SessionRedis], - exports: [CacheRedis, ThrottlerRedis, SessionRedis], -}) -export class RedisModule {} diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index 55b95e1fa..21e1c7e33 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -10,13 +10,6 @@ declare global { // eslint-disable-next-line no-var var AFFiNE: AFFiNEConfig; } - - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace NodeJS { - interface ProcessEnv { - SERVER_FLAVOR: ServerFlavor | ''; - } - } } export enum ExternalAccount { @@ -25,22 +18,28 @@ export enum ExternalAccount { firebase = 'firebase', } -export type ServerFlavor = 'allinone' | 'graphql' | 'sync' | 'selfhosted'; -type ConfigPaths = LeafPaths< +export type ServerFlavor = + | 'allinone' + | 'main' + // @deprecated + | 'graphql' + | 'sync' + | 'selfhosted'; +export type ConfigPaths = LeafPaths< Omit< AFFiNEConfig, | 'ENV_MAP' | 'version' - | 'baseUrl' - | 'origin' - | 'prod' - | 'dev' - | 'test' + | 'flavor' + | 'env' + | 'affine' | 'deploy' | 'node' + | 'baseUrl' + | 'origin' >, '', - '....' + '.....' >; /** @@ -52,15 +51,28 @@ export interface AFFiNEConfig { /** * Server Identity */ - readonly serverId: string; + serverId: string; + + /** + * Name may show on the UI + */ + serverName: string; + /** * System version */ readonly version: string; + /** * Server flavor */ - readonly flavor: ServerFlavor; + get flavor(): { + type: string; + main: boolean; + sync: boolean; + selfhosted: boolean; + }; + /** * Deployment environment */ @@ -172,38 +184,6 @@ export interface AFFiNEConfig { limit: number; }; - /** - * Redis Config - * - * whether to use redis as Socket.IO adapter - */ - redis: { - /** - * if not enabled, use in-memory adapter by default - */ - enabled: boolean; - /** - * url of redis host - */ - host: string; - /** - * port of redis - */ - port: number; - username: string; - password: string; - /** - * redis database index - * - * Rate Limiter scope: database + 1 - * - * Session scope: database + 2 - * - * @default 0 - */ - database: number; - }; - /** * authentication config */ @@ -341,15 +321,6 @@ export interface AFFiNEConfig { metrics: { enabled: boolean; }; - - payment: { - stripe: { - keys: { - APIKey: string; - webhookKey: string; - }; - } & import('stripe').Stripe.StripeConfig; - }; } export * from './storage'; diff --git a/packages/backend/server/src/fundamentals/config/default.ts b/packages/backend/server/src/fundamentals/config/default.ts index ae194f112..c5b90c229 100644 --- a/packages/backend/server/src/fundamentals/config/default.ts +++ b/packages/backend/server/src/fundamentals/config/default.ts @@ -2,6 +2,7 @@ import { createPrivateKey, createPublicKey } from 'node:crypto'; +import { merge } from 'lodash-es'; import parse from 'parse-duration'; import pkg from '../../../package.json' assert { type: 'json' }; @@ -46,11 +47,22 @@ const jwtKeyPair = (function () { export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { let isHttps: boolean | null = null; - const flavor = (process.env.SERVER_FLAVOR ?? 'allinone') as ServerFlavor; + let flavor = (process.env.SERVER_FLAVOR ?? 'allinone') as ServerFlavor; const defaultConfig = { serverId: 'affine-nestjs-server', + serverName: flavor === 'selfhosted' ? 'Self-Host Cloud' : 'AFFiNE Cloud', version: pkg.version, - flavor, + get flavor() { + if (flavor === 'graphql') { + flavor = 'main'; + } + return { + type: flavor, + main: flavor === 'main' || flavor === 'allinone', + sync: flavor === 'sync' || flavor === 'allinone', + selfhosted: flavor === 'selfhosted', + }; + }, ENV_MAP: {}, affineEnv: 'dev', get affine() { @@ -142,15 +154,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { storage: getDefaultAFFiNEStorageConfig(), rateLimiter: { ttl: 60, - limit: 60, - }, - redis: { - enabled: false, - host: '127.0.0.1', - port: 6379, - username: '', - password: '', - database: 0, + limit: 120, }, doc: { manager: { @@ -162,18 +166,16 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { interval: 1000 * 60 * 10 /* 10 mins */, }, }, - payment: { - stripe: { - keys: { - APIKey: '', - webhookKey: '', - }, - apiVersion: '2023-10-16', - }, - }, metrics: { enabled: false, }, + plugins: { + enabled: [], + use(plugin, config) { + this[plugin] = merge(this[plugin], config || {}); + this.enabled.push(plugin); + }, + }, } satisfies AFFiNEConfig; return defaultConfig; diff --git a/packages/backend/server/src/fundamentals/config/module.ts b/packages/backend/server/src/fundamentals/config/module.ts index 8af40f23e..0a0559716 100644 --- a/packages/backend/server/src/fundamentals/config/module.ts +++ b/packages/backend/server/src/fundamentals/config/module.ts @@ -1,7 +1,7 @@ import { DynamicModule, FactoryProvider } from '@nestjs/common'; import { merge } from 'lodash-es'; -import { ApplyType, DeepPartial } from '../utils/types'; +import { ApplyType } from '../utils/types'; import { AFFiNEConfig } from './def'; /** diff --git a/packages/backend/server/src/fundamentals/event/def.ts b/packages/backend/server/src/fundamentals/event/def.ts new file mode 100644 index 000000000..018516562 --- /dev/null +++ b/packages/backend/server/src/fundamentals/event/def.ts @@ -0,0 +1,51 @@ +import type { Snapshot, User, Workspace } from '@prisma/client'; + +import { Flatten, Payload } from './types'; + +export interface WorkspaceEvents { + deleted: Payload; + blob: { + deleted: Payload<{ + workspaceId: Workspace['id']; + name: string; + }>; + }; +} + +export interface DocEvents { + updated: Payload< + Pick & { + previous: Pick; + } + >; + deleted: Payload>; +} + +export interface UserEvents { + deleted: Payload; +} + +/** + * Event definitions can be extended by + * + * @example + * + * declare module './event/def' { + * interface UserEvents { + * created: Payload; + * } + * } + * + * assert() + */ +export interface EventDefinitions { + workspace: WorkspaceEvents; + snapshot: DocEvents; + user: UserEvents; +} + +export type EventKV = Flatten; + +export type Event = keyof EventKV; +export type EventPayload = EventKV[E]; +export type { Payload }; diff --git a/packages/backend/server/src/fundamentals/event/events.ts b/packages/backend/server/src/fundamentals/event/events.ts deleted file mode 100644 index f1e4f1e46..000000000 --- a/packages/backend/server/src/fundamentals/event/events.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Snapshot, User, Workspace } from '@prisma/client'; - -import { Flatten, Payload } from './types'; - -interface EventDefinitions { - workspace: { - deleted: Payload; - blob: { - deleted: Payload<{ - workspaceId: Workspace['id']; - name: string; - }>; - }; - }; - - snapshot: { - updated: Payload< - Pick & { - previous: Pick; - } - >; - deleted: Payload>; - }; - - user: { - deleted: Payload; - }; -} - -export type EventKV = Flatten; - -export type Event = keyof EventKV; -export type EventPayload = EventKV[E]; diff --git a/packages/backend/server/src/fundamentals/event/index.ts b/packages/backend/server/src/fundamentals/event/index.ts index 21db47d2b..768d9b60a 100644 --- a/packages/backend/server/src/fundamentals/event/index.ts +++ b/packages/backend/server/src/fundamentals/event/index.ts @@ -5,7 +5,7 @@ import { OnEvent as RawOnEvent, } from '@nestjs/event-emitter'; -import type { Event, EventPayload } from './events'; +import type { Event, EventPayload } from './def'; @Injectable() export class EventEmitter { @@ -40,4 +40,4 @@ export const OnEvent = RawOnEvent as ( exports: [EventEmitter], }) export class EventModule {} -export { EventPayload }; +export { Event, EventPayload }; diff --git a/packages/backend/server/src/fundamentals/graphql/index.ts b/packages/backend/server/src/fundamentals/graphql/index.ts index dc168e939..6ca45356b 100644 --- a/packages/backend/server/src/fundamentals/graphql/index.ts +++ b/packages/backend/server/src/fundamentals/graphql/index.ts @@ -28,6 +28,7 @@ import { GQLLoggerPlugin } from './logger-plugin'; ? '../../../../node_modules/.cache/schema.gql' : '../../../schema.gql' ), + sortSchema: true, context: ({ req, res }: { req: Request; res: Response }) => ({ req, res, diff --git a/packages/backend/server/src/fundamentals/index.ts b/packages/backend/server/src/fundamentals/index.ts index 35b18decc..166902359 100644 --- a/packages/backend/server/src/fundamentals/index.ts +++ b/packages/backend/server/src/fundamentals/index.ts @@ -1,12 +1,20 @@ -export { Cache, CacheInterceptor, MakeCache, PreventCache } from './cache'; +export { + Cache, + CacheInterceptor, + MakeCache, + PreventCache, + SessionCache, +} from './cache'; export { applyEnvToConfig, Config, + type ConfigPaths, getDefaultAFFiNEStorageConfig, } from './config'; export { EventEmitter, type EventPayload, OnEvent } from './event'; export { MailService } from './mailer'; export { CallCounter, CallTimer, metrics } from './metrics'; +export { getOptionalModuleMetadata, OptionalModule } from './nestjs'; export { PrismaService } from './prisma'; export { SessionService } from './session'; export * from './storage'; @@ -17,4 +25,4 @@ export { getRequestResponseFromHost, } from './utils/request'; export type * from './utils/types'; -export { RedisIoAdapter } from './websocket'; +export { SocketIoAdapter } from './websocket'; diff --git a/packages/backend/server/src/fundamentals/nestjs/index.ts b/packages/backend/server/src/fundamentals/nestjs/index.ts new file mode 100644 index 000000000..d1ab9aeef --- /dev/null +++ b/packages/backend/server/src/fundamentals/nestjs/index.ts @@ -0,0 +1 @@ +export * from './optional-module'; diff --git a/packages/backend/server/src/fundamentals/nestjs/optional-module.ts b/packages/backend/server/src/fundamentals/nestjs/optional-module.ts new file mode 100644 index 000000000..bbcec4094 --- /dev/null +++ b/packages/backend/server/src/fundamentals/nestjs/optional-module.ts @@ -0,0 +1,71 @@ +import { + DynamicModule, + Module, + ModuleMetadata, + Provider, + Type, +} from '@nestjs/common'; +import { omit } from 'lodash-es'; + +import { Config, ConfigPaths } from '../config'; + +interface OptionalModuleMetadata extends ModuleMetadata { + /** + * Only install module if given config paths are defined in AFFiNE config. + */ + requires?: ConfigPaths[]; + + /** + * Only install module if the predication returns true. + */ + if?: (config: Config) => boolean; + + /** + * Defines which feature will be enabled if the module installed. + */ + contributesTo?: import('../../core/config').ServerFeature; // avoid circlar dependency + + /** + * Defines which providers provided by other modules will be overridden if the module installed. + */ + overrides?: Provider[]; +} + +const additionalOptions = [ + 'contributesTo', + 'requires', + 'if', + 'overrides', +] as const satisfies Array; + +type OptionalDynamicModule = DynamicModule & OptionalModuleMetadata; + +export function OptionalModule(metadata: OptionalModuleMetadata) { + return (target: Type) => { + additionalOptions.forEach(option => { + if (Object.hasOwn(metadata, option)) { + Reflect.defineMetadata(option, metadata[option], target); + } + }); + + if (metadata.overrides) { + metadata.providers = (metadata.providers ?? []).concat( + metadata.overrides + ); + metadata.exports = (metadata.exports ?? []).concat(metadata.overrides); + } + + const nestMetadata = omit(metadata, additionalOptions); + Module(nestMetadata)(target); + }; +} + +export function getOptionalModuleMetadata< + T extends keyof OptionalModuleMetadata, +>(target: Type | OptionalDynamicModule, key: T): OptionalModuleMetadata[T] { + if ('module' in target) { + return target[key]; + } else { + return Reflect.getMetadata(key, target); + } +} diff --git a/packages/backend/server/src/fundamentals/throttler/index.ts b/packages/backend/server/src/fundamentals/throttler/index.ts index a7c4ee4ee..7462c5ec9 100644 --- a/packages/backend/server/src/fundamentals/throttler/index.ts +++ b/packages/backend/server/src/fundamentals/throttler/index.ts @@ -1,4 +1,4 @@ -import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { ExecutionContext, Injectable } from '@nestjs/common'; import { Global, Module } from '@nestjs/common'; import { Throttle, @@ -6,40 +6,36 @@ import { ThrottlerModule, ThrottlerModuleOptions, ThrottlerOptionsFactory, + ThrottlerStorageService, } from '@nestjs/throttler'; -import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; -import { ThrottlerCache } from '../cache'; import { Config } from '../config'; import { getRequestResponseFromContext } from '../utils/request'; +@Injectable() +export class ThrottlerStorage extends ThrottlerStorageService {} + @Injectable() class CustomOptionsFactory implements ThrottlerOptionsFactory { constructor( private readonly config: Config, - private readonly cache: ThrottlerCache + private readonly storage: ThrottlerStorage ) {} + createThrottlerOptions() { const options: ThrottlerModuleOptions = { throttlers: [ { - ttl: this.config.rateLimiter.ttl, + ttl: this.config.rateLimiter.ttl * 1000, limit: this.config.rateLimiter.limit, }, ], skipIf: () => { return !this.config.node.prod || this.config.affine.canary; }, + storage: this.storage, }; - if (this.config.redis.enabled) { - new Logger(RateLimiterModule.name).log('Use Redis'); - options.storage = new ThrottlerStorageRedisService( - // @ts-expect-error hidden field - this.cache.redis - ); - } - return options; } } @@ -51,6 +47,8 @@ class CustomOptionsFactory implements ThrottlerOptionsFactory { useClass: CustomOptionsFactory, }), ], + providers: [ThrottlerStorage], + exports: [ThrottlerStorage], }) export class RateLimiterModule {} diff --git a/packages/backend/server/src/fundamentals/utils/types.ts b/packages/backend/server/src/fundamentals/utils/types.ts index 09c96b9d7..d2435e513 100644 --- a/packages/backend/server/src/fundamentals/utils/types.ts +++ b/packages/backend/server/src/fundamentals/utils/types.ts @@ -1,9 +1,5 @@ import { Readable } from 'node:stream'; -export type ConstructorOf = { - new (): T; -}; - export function ApplyType(): ConstructorOf { // @ts-expect-error used to fake the type of config return class Inner implements T { @@ -11,16 +7,6 @@ export function ApplyType(): ConstructorOf { }; } -export type DeepPartial = T extends Array - ? DeepPartial[] - : T extends ReadonlyArray - ? ReadonlyArray> - : T extends object - ? { - [K in keyof T]?: DeepPartial; - } - : T; - type Join = Prefix extends string | number ? Suffixes extends string | number ? Prefix extends '' @@ -29,14 +15,6 @@ type Join = Prefix extends string | number : never : never; -export type PrimitiveType = - | string - | number - | boolean - | symbol - | null - | undefined; - export type LeafPaths< T, Path extends string = '', diff --git a/packages/backend/server/src/fundamentals/websocket/index.ts b/packages/backend/server/src/fundamentals/websocket/index.ts index f772e0ea2..f216a05e0 100644 --- a/packages/backend/server/src/fundamentals/websocket/index.ts +++ b/packages/backend/server/src/fundamentals/websocket/index.ts @@ -1 +1,17 @@ -export { RedisIoAdapter } from './redis-adapter'; +import { Module, Provider } from '@nestjs/common'; +import { IoAdapter } from '@nestjs/platform-socket.io'; + +export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl'); + +export class SocketIoAdapter extends IoAdapter {} + +const SocketIoAdapterImplProvider: Provider = { + provide: SocketIoAdapterImpl, + useValue: SocketIoAdapter, +}; + +@Module({ + providers: [SocketIoAdapterImplProvider], + exports: [SocketIoAdapterImplProvider], +}) +export class WebSocketModule {} diff --git a/packages/backend/server/src/fundamentals/websocket/redis-adapter.ts b/packages/backend/server/src/fundamentals/websocket/redis-adapter.ts deleted file mode 100644 index 46d159197..000000000 --- a/packages/backend/server/src/fundamentals/websocket/redis-adapter.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { IoAdapter } from '@nestjs/platform-socket.io'; -import { createAdapter } from '@socket.io/redis-adapter'; -import { Redis } from 'ioredis'; -import { ServerOptions } from 'socket.io'; - -export class RedisIoAdapter extends IoAdapter { - private adapterConstructor: ReturnType | undefined; - - async connectToRedis(redis: Redis): Promise { - const pubClient = redis; - pubClient.on('error', err => { - console.error(err); - }); - const subClient = pubClient.duplicate(); - subClient.on('error', err => { - console.error(err); - }); - - this.adapterConstructor = createAdapter(pubClient, subClient); - } - - override createIOServer(port: number, options?: ServerOptions): any { - const server = super.createIOServer(port, options); - server.adapter(this.adapterConstructor); - return server; - } -} diff --git a/packages/backend/server/src/global.d.ts b/packages/backend/server/src/global.d.ts index 0d2d7c791..67c930bbb 100644 --- a/packages/backend/server/src/global.d.ts +++ b/packages/backend/server/src/global.d.ts @@ -3,3 +3,29 @@ declare namespace Express { user?: import('@prisma/client').User | null; } } + +declare type PrimitiveType = + | string + | number + | boolean + | symbol + | null + | undefined; + +declare type ConstructorOf = { + new (): T; +}; + +declare type DeepPartial = T extends Array + ? DeepPartial[] + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { + [K in keyof T]?: DeepPartial; + } + : T; + +declare type AFFiNEModule = + | import('@nestjs/common').Type + | import('@nestjs/common').DynamicModule; diff --git a/packages/backend/server/src/modules/config.ts b/packages/backend/server/src/modules/config.ts deleted file mode 100644 index 7286e496d..000000000 --- a/packages/backend/server/src/modules/config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Module } from '@nestjs/common'; -import { Field, ObjectType, Query } from '@nestjs/graphql'; - -@ObjectType() -export class ServerConfigType { - @Field({ description: 'server version' }) - version!: string; - - @Field({ description: 'server flavor' }) - flavor!: string; - - @Field({ description: 'server base url' }) - baseUrl!: string; -} - -export class ServerConfigResolver { - @Query(() => ServerConfigType, { - description: 'server config', - }) - serverConfig(): ServerConfigType { - return { - version: AFFiNE.version, - flavor: AFFiNE.flavor, - baseUrl: AFFiNE.baseUrl, - }; - } -} - -@Module({ - providers: [ServerConfigResolver], -}) -export class ServerConfigModule {} diff --git a/packages/backend/server/src/modules/index.ts b/packages/backend/server/src/modules/index.ts deleted file mode 100644 index fca3f4e9d..000000000 --- a/packages/backend/server/src/modules/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { join } from 'node:path'; - -import { DynamicModule, Type } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ServeStaticModule } from '@nestjs/serve-static'; - -import { GqlModule } from '../fundamentals/graphql'; -import { ServerConfigModule } from './config'; -import { DocModule } from './doc'; -import { PaymentModule } from './payment'; -import { QuotaModule } from './quota'; -import { SelfHostedModule } from './self-hosted'; -import { StorageModule } from './storage'; -import { SyncModule } from './sync'; -import { UsersModule } from './users'; -import { WorkspaceModule } from './workspaces'; - -const BusinessModules: (Type | DynamicModule)[] = []; - -switch (AFFiNE.flavor) { - case 'sync': - BusinessModules.push(SyncModule, DocModule); - break; - case 'selfhosted': - BusinessModules.push( - ServerConfigModule, - SelfHostedModule, - ScheduleModule.forRoot(), - GqlModule, - WorkspaceModule, - UsersModule, - SyncModule, - DocModule, - StorageModule, - ServeStaticModule.forRoot({ - rootPath: join('/app', 'static'), - }) - ); - break; - case 'graphql': - BusinessModules.push( - ServerConfigModule, - ScheduleModule.forRoot(), - GqlModule, - WorkspaceModule, - UsersModule, - DocModule, - PaymentModule, - QuotaModule, - StorageModule - ); - break; - case 'allinone': - default: - BusinessModules.push( - ServerConfigModule, - ScheduleModule.forRoot(), - GqlModule, - WorkspaceModule, - UsersModule, - QuotaModule, - SyncModule, - DocModule, - PaymentModule, - StorageModule - ); - break; -} - -export { BusinessModules }; diff --git a/packages/backend/server/src/modules/self-hosted.ts b/packages/backend/server/src/modules/self-hosted.ts deleted file mode 100644 index 5bd684b98..000000000 --- a/packages/backend/server/src/modules/self-hosted.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ResolveField, Resolver } from '@nestjs/graphql'; - -import { UserSubscriptionType } from './payment/resolver'; -import { - SubscriptionPlan, - SubscriptionRecurring, - SubscriptionStatus, -} from './payment/service'; -import { UserType } from './users'; - -const YEAR = 1000 * 60 * 60 * 24 * 30 * 12; - -@Resolver(() => UserType) -export class SelfHostedDummyResolver { - private readonly start = new Date(); - private readonly end = new Date(Number(this.start) + YEAR); - constructor() {} - - @ResolveField(() => UserSubscriptionType) - async subscription() { - return { - stripeSubscriptionId: 'dummy', - plan: SubscriptionPlan.SelfHosted, - recurring: SubscriptionRecurring.Yearly, - status: SubscriptionStatus.Active, - start: this.start, - end: this.end, - createdAt: this.start, - updatedAt: this.start, - }; - } -} - -@Module({ - providers: [SelfHostedDummyResolver], -}) -export class SelfHostedModule {} diff --git a/packages/backend/server/src/plugins/config.ts b/packages/backend/server/src/plugins/config.ts new file mode 100644 index 000000000..9d077ebff --- /dev/null +++ b/packages/backend/server/src/plugins/config.ts @@ -0,0 +1,21 @@ +import { PaymentConfig } from './payment'; +import { RedisOptions } from './redis'; + +declare module '../fundamentals/config' { + interface PluginsConfig { + readonly payment: PaymentConfig; + readonly redis: RedisOptions; + } + + export type AvailablePlugins = keyof PluginsConfig; + + interface AFFiNEConfig { + readonly plugins: { + enabled: AvailablePlugins[]; + use( + plugin: Plugin, + config?: DeepPartial + ): void; + } & Partial; + } +} diff --git a/packages/backend/server/src/plugins/index.ts b/packages/backend/server/src/plugins/index.ts new file mode 100644 index 000000000..291e2f06c --- /dev/null +++ b/packages/backend/server/src/plugins/index.ts @@ -0,0 +1,8 @@ +import type { AvailablePlugins } from '../fundamentals/config'; +import { PaymentModule } from './payment'; +import { RedisModule } from './redis'; + +export const pluginsMap = new Map([ + ['payment', PaymentModule], + ['redis', RedisModule], +]); diff --git a/packages/backend/server/src/modules/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts similarity index 54% rename from packages/backend/server/src/modules/payment/index.ts rename to packages/backend/server/src/plugins/payment/index.ts index 86cea35d4..e54d3fa1f 100644 --- a/packages/backend/server/src/modules/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -1,15 +1,14 @@ -import { Module } from '@nestjs/common'; - -import { FeatureModule } from '../features'; -import { QuotaModule } from '../quota'; +import { ServerFeature } from '../../core/config'; +import { FeatureModule } from '../../core/features'; +import { OptionalModule } from '../../fundamentals'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; import { ScheduleManager } from './schedule'; import { SubscriptionService } from './service'; import { StripeProvider } from './stripe'; import { StripeWebhook } from './webhook'; -@Module({ - imports: [FeatureModule, QuotaModule], +@OptionalModule({ + imports: [FeatureModule], providers: [ ScheduleManager, StripeProvider, @@ -18,5 +17,12 @@ import { StripeWebhook } from './webhook'; UserSubscriptionResolver, ], controllers: [StripeWebhook], + requires: [ + 'plugins.payment.stripe.keys.APIKey', + 'plugins.payment.stripe.keys.webhookKey', + ], + contributesTo: ServerFeature.Payment, }) export class PaymentModule {} + +export type { PaymentConfig } from './types'; diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts similarity index 89% rename from packages/backend/server/src/modules/payment/resolver.ts rename to packages/backend/server/src/plugins/payment/resolver.ts index be95d24d1..c65bde980 100644 --- a/packages/backend/server/src/modules/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -16,17 +16,16 @@ import type { User, UserInvoice, UserSubscription } from '@prisma/client'; import { GraphQLError } from 'graphql'; import { groupBy } from 'lodash-es'; +import { Auth, CurrentUser, Public } from '../../core/auth'; +import { UserType } from '../../core/users'; import { Config, PrismaService } from '../../fundamentals'; -import { Auth, CurrentUser, Public } from '../auth'; -import { UserType } from '../users'; +import { decodeLookupKey, SubscriptionService } from './service'; import { - decodeLookupKey, InvoiceStatus, SubscriptionPlan, SubscriptionRecurring, - SubscriptionService, SubscriptionStatus, -} from './service'; +} from './types'; registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' }); registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' }); @@ -251,7 +250,10 @@ export class SubscriptionResolver { @Resolver(() => UserType) export class UserSubscriptionResolver { - constructor(private readonly db: PrismaService) {} + constructor( + private readonly config: Config, + private readonly db: PrismaService + ) {} @ResolveField(() => UserSubscriptionType, { nullable: true }) async subscription( @@ -272,6 +274,25 @@ export class UserSubscriptionResolver { ); } + // @FIXME(@forehalo): should not mock any api for selfhosted server + // the frontend should avoid calling such api if feature is not enabled + if (this.config.flavor.selfhosted) { + const start = new Date(); + const end = new Date(); + end.setFullYear(start.getFullYear() + 1); + + return { + stripeSubscriptionId: 'dummy', + plan: SubscriptionPlan.SelfHosted, + recurring: SubscriptionRecurring.Yearly, + status: SubscriptionStatus.Active, + start, + end, + createdAt: start, + updatedAt: start, + }; + } + return this.db.userSubscription.findUnique({ where: { userId: user.id, diff --git a/packages/backend/server/src/modules/payment/schedule.ts b/packages/backend/server/src/plugins/payment/schedule.ts similarity index 100% rename from packages/backend/server/src/modules/payment/schedule.ts rename to packages/backend/server/src/plugins/payment/schedule.ts diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/plugins/payment/service.ts similarity index 89% rename from packages/backend/server/src/modules/payment/service.ts rename to packages/backend/server/src/plugins/payment/service.ts index 8c40c360d..b7b6e6299 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/plugins/payment/service.ts @@ -9,10 +9,15 @@ import type { } from '@prisma/client'; import Stripe from 'stripe'; -import { Config, PrismaService } from '../../fundamentals'; -import { FeatureManagementService } from '../features'; -import { QuotaService, QuotaType } from '../quota'; +import { FeatureManagementService } from '../../core/features'; +import { EventEmitter, PrismaService } from '../../fundamentals'; import { ScheduleManager } from './schedule'; +import { + InvoiceStatus, + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionStatus, +} from './types'; const OnEvent = ( event: Stripe.Event.Type, @@ -20,19 +25,6 @@ const OnEvent = ( ) => RawOnEvent(event, opts); // Plan x Recurring make a stripe price lookup key -export enum SubscriptionRecurring { - Monthly = 'monthly', - Yearly = 'yearly', -} - -export enum SubscriptionPlan { - Free = 'free', - Pro = 'pro', - Team = 'team', - Enterprise = 'enterprise', - SelfHosted = 'selfhosted', -} - export function encodeLookupKey( plan: SubscriptionPlan, recurring: SubscriptionRecurring @@ -48,31 +40,11 @@ export function decodeLookupKey( return [plan as SubscriptionPlan, recurring as SubscriptionRecurring]; } -// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status -export enum SubscriptionStatus { - Active = 'active', - PastDue = 'past_due', - Unpaid = 'unpaid', - Canceled = 'canceled', - Incomplete = 'incomplete', - Paused = 'paused', - IncompleteExpired = 'incomplete_expired', - Trialing = 'trialing', -} - const SubscriptionActivated: Stripe.Subscription.Status[] = [ SubscriptionStatus.Active, SubscriptionStatus.Trialing, ]; -export enum InvoiceStatus { - Draft = 'draft', - Open = 'open', - Void = 'void', - Paid = 'paid', - Uncollectible = 'uncollectible', -} - export enum CouponType { EarlyAccess = 'earlyaccess', EarlyAccessRenew = 'earlyaccessrenew', @@ -80,27 +52,15 @@ export enum CouponType { @Injectable() export class SubscriptionService { - private readonly paymentConfig: Config['payment']; private readonly logger = new Logger(SubscriptionService.name); constructor( - config: Config, private readonly stripe: Stripe, private readonly db: PrismaService, private readonly scheduleManager: ScheduleManager, - private readonly features: FeatureManagementService, - private readonly quota: QuotaService - ) { - this.paymentConfig = config.payment; - - if ( - !this.paymentConfig.stripe.keys.APIKey || - !this.paymentConfig.stripe.keys.webhookKey /* default empty string */ - ) { - this.logger.warn('Stripe API key not set, Stripe will be disabled'); - this.logger.warn('Set STRIPE_API_KEY to enable Stripe'); - } - } + private readonly event: EventEmitter, + private readonly features: FeatureManagementService + ) {} async listPrices() { return this.stripe.prices.list(); @@ -477,16 +437,6 @@ export class SubscriptionService { } } - private getPlanQuota(plan: SubscriptionPlan) { - if (plan === SubscriptionPlan.Free) { - return QuotaType.FreePlanV1; - } else if (plan === SubscriptionPlan.Pro) { - return QuotaType.ProPlanV1; - } else { - throw new Error(`Unknown plan: ${plan}`); - } - } - private async saveSubscription( user: User, subscription: Stripe.Subscription, @@ -509,8 +459,10 @@ export class SubscriptionService { let nextBillAt: Date | null = null; if (planActivated) { - // update user's quota if plan activated - await this.quota.switchUserQuota(user.id, this.getPlanQuota(plan)); + this.event.emit('user.subscription.activated', { + userId: user.id, + plan, + }); // get next bill date from upcoming invoice // see https://stripe.com/docs/api/invoices/upcoming @@ -518,8 +470,7 @@ export class SubscriptionService { nextBillAt = new Date(subscription.current_period_end * 1000); } } else { - // switch to free plan if subscription is canceled - await this.quota.switchUserQuota(user.id, QuotaType.FreePlanV1); + this.event.emit('user.subscription.canceled', user.id); } const commonData = { diff --git a/packages/backend/server/src/modules/payment/stripe.ts b/packages/backend/server/src/plugins/payment/stripe.ts similarity index 58% rename from packages/backend/server/src/modules/payment/stripe.ts rename to packages/backend/server/src/plugins/payment/stripe.ts index 4f17d8ded..6d2aed7ce 100644 --- a/packages/backend/server/src/modules/payment/stripe.ts +++ b/packages/backend/server/src/plugins/payment/stripe.ts @@ -1,3 +1,5 @@ +import assert from 'node:assert'; + import { FactoryProvider } from '@nestjs/common'; import { omit } from 'lodash-es'; import Stripe from 'stripe'; @@ -7,12 +9,10 @@ import { Config } from '../../fundamentals'; export const StripeProvider: FactoryProvider = { provide: Stripe, useFactory: (config: Config) => { - const stripeConfig = config.payment.stripe; + assert(config.plugins.payment); + const stripeConfig = config.plugins.payment.stripe; - return new Stripe( - stripeConfig.keys.APIKey, - omit(config.payment.stripe, 'keys', 'prices') - ); + return new Stripe(stripeConfig.keys.APIKey, omit(stripeConfig, 'keys')); }, inject: [Config], }; diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts new file mode 100644 index 000000000..7954ad7f4 --- /dev/null +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -0,0 +1,58 @@ +import { type User } from '@prisma/client'; +import { type Stripe } from 'stripe'; + +import type { Payload } from '../../fundamentals/event/def'; + +export interface PaymentConfig { + stripe: { + keys: { + APIKey: string; + webhookKey: string; + }; + } & Stripe.StripeConfig; +} + +export enum SubscriptionRecurring { + Monthly = 'monthly', + Yearly = 'yearly', +} + +export enum SubscriptionPlan { + Free = 'free', + Pro = 'pro', + Team = 'team', + Enterprise = 'enterprise', + SelfHosted = 'selfhosted', +} + +// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status +export enum SubscriptionStatus { + Active = 'active', + PastDue = 'past_due', + Unpaid = 'unpaid', + Canceled = 'canceled', + Incomplete = 'incomplete', + Paused = 'paused', + IncompleteExpired = 'incomplete_expired', + Trialing = 'trialing', +} + +export enum InvoiceStatus { + Draft = 'draft', + Open = 'open', + Void = 'void', + Paid = 'paid', + Uncollectible = 'uncollectible', +} + +declare module '../../fundamentals/event/def' { + interface UserEvents { + subscription: { + activated: Payload<{ + userId: User['id']; + plan: SubscriptionPlan; + }>; + canceled: Payload; + }; + } +} diff --git a/packages/backend/server/src/modules/payment/webhook.ts b/packages/backend/server/src/plugins/payment/webhook.ts similarity index 83% rename from packages/backend/server/src/modules/payment/webhook.ts rename to packages/backend/server/src/plugins/payment/webhook.ts index fa428e87e..4b98e2de7 100644 --- a/packages/backend/server/src/modules/payment/webhook.ts +++ b/packages/backend/server/src/plugins/payment/webhook.ts @@ -1,3 +1,5 @@ +import assert from 'node:assert'; + import type { RawBodyRequest } from '@nestjs/common'; import { Controller, @@ -14,7 +16,7 @@ import { Config } from '../../fundamentals'; @Controller('/api/stripe') export class StripeWebhook { - private readonly config: Config['payment']; + private readonly webhookKey: string; private readonly logger = new Logger(StripeWebhook.name); constructor( @@ -22,18 +24,13 @@ export class StripeWebhook { private readonly stripe: Stripe, private readonly event: EventEmitter2 ) { - this.config = config.payment; + assert(config.plugins.payment); + this.webhookKey = config.plugins.payment.stripe.keys.webhookKey; } @Post('/webhook') async handleWebhook(@Req() req: RawBodyRequest) { // Check if webhook signing is configured. - if (!this.config.stripe.keys.webhookKey) { - this.logger.error( - 'Stripe Webhook key is not set, but a webhook was received.' - ); - throw new NotAcceptableException(); - } // Retrieve the event by verifying the signature using the raw body and secret. const signature = req.headers['stripe-signature']; @@ -41,7 +38,7 @@ export class StripeWebhook { const event = this.stripe.webhooks.constructEvent( req.rawBody ?? '', signature ?? '', - this.config.stripe.keys.webhookKey + this.webhookKey ); this.logger.debug( diff --git a/packages/backend/server/src/fundamentals/cache/providers/redis.ts b/packages/backend/server/src/plugins/redis/cache.ts similarity index 98% rename from packages/backend/server/src/fundamentals/cache/providers/redis.ts rename to packages/backend/server/src/plugins/redis/cache.ts index 83cedf2f7..802a64ca1 100644 --- a/packages/backend/server/src/fundamentals/cache/providers/redis.ts +++ b/packages/backend/server/src/plugins/redis/cache.ts @@ -1,6 +1,6 @@ import { Redis } from 'ioredis'; -import { Cache, CacheSetOptions } from './cache'; +import type { Cache, CacheSetOptions } from '../../fundamentals/cache/def'; export class RedisCache implements Cache { constructor(private readonly redis: Redis) {} diff --git a/packages/backend/server/src/plugins/redis/index.ts b/packages/backend/server/src/plugins/redis/index.ts new file mode 100644 index 000000000..46b44fe7f --- /dev/null +++ b/packages/backend/server/src/plugins/redis/index.ts @@ -0,0 +1,62 @@ +import { Global, Provider, Type } from '@nestjs/common'; +import { Redis, type RedisOptions } from 'ioredis'; +import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; + +import { Cache, OptionalModule, SessionCache } from '../../fundamentals'; +import { ThrottlerStorage } from '../../fundamentals/throttler'; +import { SocketIoAdapterImpl } from '../../fundamentals/websocket'; +import { RedisCache } from './cache'; +import { + CacheRedis, + SessionRedis, + SocketIoRedis, + ThrottlerRedis, +} from './instances'; +import { createSockerIoAdapterImpl } from './ws-adapter'; + +function makeProvider(token: Type, impl: Type): Provider { + return { + provide: token, + useFactory: (redis: Redis) => { + return new RedisCache(redis); + }, + inject: [impl], + }; +} + +// cache +const cacheProvider = makeProvider(Cache, CacheRedis); +const sessionCacheProvider = makeProvider(SessionCache, SessionRedis); + +// throttler +const throttlerStorageProvider: Provider = { + provide: ThrottlerStorage, + useFactory: (redis: Redis) => { + return new ThrottlerStorageRedisService(redis); + }, + inject: [ThrottlerRedis], +}; + +// socket io +const socketIoRedisAdapterProvider: Provider = { + provide: SocketIoAdapterImpl, + useFactory: (redis: Redis) => { + return createSockerIoAdapterImpl(redis); + }, + inject: [SocketIoRedis], +}; + +@Global() +@OptionalModule({ + providers: [CacheRedis, SessionRedis, ThrottlerRedis, SocketIoRedis], + overrides: [ + cacheProvider, + sessionCacheProvider, + socketIoRedisAdapterProvider, + throttlerStorageProvider, + ], + requires: ['plugins.redis.host'], +}) +export class RedisModule {} + +export { RedisOptions }; diff --git a/packages/backend/server/src/plugins/redis/instances.ts b/packages/backend/server/src/plugins/redis/instances.ts new file mode 100644 index 000000000..1e85dec62 --- /dev/null +++ b/packages/backend/server/src/plugins/redis/instances.ts @@ -0,0 +1,56 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { Redis as IORedis, RedisOptions } from 'ioredis'; + +import { Config } from '../../fundamentals/config'; + +class Redis extends IORedis implements OnModuleDestroy, OnModuleInit { + logger = new Logger(Redis.name); + constructor(opts: RedisOptions) { + super({ + ...opts, + lazyConnect: true, + }); + } + + async onModuleInit() { + await this.connect().catch(() => { + this.logger.error('Failed to connect to Redis server.'); + }); + } + onModuleDestroy() { + this.disconnect(); + } +} + +@Injectable() +export class CacheRedis extends Redis { + constructor(config: Config) { + super(config.plugins.redis ?? {}); + } +} + +@Injectable() +export class ThrottlerRedis extends Redis { + constructor(config: Config) { + super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 1 }); + } +} + +@Injectable() +export class SessionRedis extends Redis { + constructor(config: Config) { + super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 2 }); + } +} + +@Injectable() +export class SocketIoRedis extends Redis { + constructor(config: Config) { + super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 3 }); + } +} diff --git a/packages/backend/server/src/plugins/redis/types.ts b/packages/backend/server/src/plugins/redis/types.ts new file mode 100644 index 000000000..e7340fab8 --- /dev/null +++ b/packages/backend/server/src/plugins/redis/types.ts @@ -0,0 +1,3 @@ +import { RedisOptions } from 'ioredis'; + +export type { RedisOptions }; diff --git a/packages/backend/server/src/plugins/redis/ws-adapter.ts b/packages/backend/server/src/plugins/redis/ws-adapter.ts new file mode 100644 index 000000000..528e0b447 --- /dev/null +++ b/packages/backend/server/src/plugins/redis/ws-adapter.ts @@ -0,0 +1,28 @@ +import { createAdapter } from '@socket.io/redis-adapter'; +import { Redis } from 'ioredis'; +import { Server, ServerOptions } from 'socket.io'; + +import { SocketIoAdapter } from '../../fundamentals'; + +export function createSockerIoAdapterImpl( + redis: Redis +): typeof SocketIoAdapter { + class RedisIoAdapter extends SocketIoAdapter { + override createIOServer(port: number, options?: ServerOptions): Server { + const pubClient = redis; + pubClient.on('error', err => { + console.error(err); + }); + const subClient = pubClient.duplicate(); + subClient.on('error', err => { + console.error(err); + }); + + const server = super.createIOServer(port, options) as Server; + server.adapter(createAdapter(pubClient, subClient)); + return server; + } + } + + return RedisIoAdapter; +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index b6af02d7b..844f24efa 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -2,68 +2,6 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ -type ServerConfigType { - """server version""" - version: String! - - """server flavor""" - flavor: String! - - """server base url""" - baseUrl: String! -} - -type UserQuotaHumanReadable { - name: String! - blobLimit: String! - storageQuota: String! - historyPeriod: String! - memberLimit: String! -} - -type UserQuota { - name: String! - blobLimit: SafeInt! - storageQuota: SafeInt! - historyPeriod: SafeInt! - memberLimit: Int! - humanReadable: UserQuotaHumanReadable! -} - -""" -The `SafeInt` scalar type represents non-fractional signed whole numeric values that are considered safe as defined by the ECMAScript specification. -""" -scalar SafeInt @specifiedBy(url: "https://www.ecma-international.org/ecma-262/#sec-number.issafeinteger") - -type UserType { - id: ID! - - """User name""" - name: String! - - """User email""" - email: String! - - """User avatar url""" - avatarUrl: String - - """User email verified""" - emailVerified: DateTime - - """User created date""" - createdAt: DateTime - - """User password has been set""" - hasPassword: Boolean - token: TokenType! - quota: UserQuota - - """Get user invoice count""" - invoiceCount: Int! - subscription: UserSubscription - invoices(take: Int = 8, skip: Int): [UserInvoice!]! -} - """ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. """ @@ -73,90 +11,10 @@ type DeleteAccount { success: Boolean! } -type RemoveAvatar { - success: Boolean! -} - -type InviteUserType { - """User name""" - name: String - - """User email""" - email: String - - """User avatar url""" - avatarUrl: String - - """User email verified""" - emailVerified: DateTime - - """User created date""" - createdAt: DateTime - - """User password has been set""" - hasPassword: Boolean - id: ID! - - """User permission in workspace""" - permission: Permission! - - """Invite id""" - inviteId: String! - - """User accepted""" - accepted: Boolean! -} - -"""User permission in workspace""" -enum Permission { - Read - Write - Admin - Owner -} - -type WorkspaceType { - id: ID! - - """is Public workspace""" - public: Boolean! - - """Workspace created date""" - createdAt: DateTime! - - """Members of workspace""" - members(skip: Int, take: Int): [InviteUserType!]! - - """Permission of current signed in user in workspace""" - permission: Permission! - - """member count of workspace""" - memberCount: Int! - - """Owner of workspace""" - owner: UserType! - - """quota of workspace""" - quota: QuotaQueryType! - - """Available features of workspace""" - availableFeatures: [FeatureType!]! - - """Enabled features of workspace""" - features: [FeatureType!]! - - """Shared pages of workspace""" - sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") - - """Public pages of a workspace""" - publicPages: [WorkspacePage!]! - histories(guid: String!, before: DateTime, take: Int): [DocHistoryType!]! - - """List blobs of workspace""" - blobs: [String!]! - - """Blobs size of workspace""" - blobsSize: Int! +type DocHistoryType { + id: String! + timestamp: DateTime! + workspaceId: String! } """The type of workspace feature""" @@ -166,167 +24,64 @@ enum FeatureType { UnlimitedWorkspace } -type InvitationWorkspaceType { - id: ID! - - """Workspace name""" - name: String! - - """Base64 encoded avatar""" - avatar: String! -} - -type WorkspaceBlobSizes { - size: SafeInt! -} - type InvitationType { - """Workspace information""" - workspace: InvitationWorkspaceType! + """Invitee information""" + invitee: UserType! """User information""" user: UserType! - """Invitee information""" - invitee: UserType! + """Workspace information""" + workspace: InvitationWorkspaceType! } -type QuotaQueryType { - storageQuota: Int! - usedSize: Int! - blobLimit: Int! +type InvitationWorkspaceType { + """Base64 encoded avatar""" + avatar: String! + id: ID! + + """Workspace name""" + name: String! } -type TokenType { - token: String! - refresh: String! - sessionToken: String -} +type InviteUserType { + """User accepted""" + accepted: Boolean! -type SubscriptionPrice { - type: String! - plan: SubscriptionPlan! - currency: String! - amount: Int! - yearlyAmount: Int! -} + """User avatar url""" + avatarUrl: String -enum SubscriptionPlan { - Free - Pro - Team - Enterprise - SelfHosted -} + """User created date""" + createdAt: DateTime -type UserSubscription { - id: String! - plan: SubscriptionPlan! - recurring: SubscriptionRecurring! - status: SubscriptionStatus! - start: DateTime! - end: DateTime! - trialStart: DateTime - trialEnd: DateTime - nextBillAt: DateTime - canceledAt: DateTime - createdAt: DateTime! - updatedAt: DateTime! -} + """User email""" + email: String -enum SubscriptionRecurring { - Monthly - Yearly -} + """User email verified""" + emailVerified: DateTime -enum SubscriptionStatus { - Active - PastDue - Unpaid - Canceled - Incomplete - Paused - IncompleteExpired - Trialing -} + """User password has been set""" + hasPassword: Boolean + id: ID! -type UserInvoice { - id: String! - plan: SubscriptionPlan! - recurring: SubscriptionRecurring! - currency: String! - amount: Int! - status: InvoiceStatus! - reason: String! - lastPaymentError: String - link: String - createdAt: DateTime! - updatedAt: DateTime! + """Invite id""" + inviteId: String! + + """User name""" + name: String + + """User permission in workspace""" + permission: Permission! } enum InvoiceStatus { Draft Open - Void Paid Uncollectible + Void } -type DocHistoryType { - workspaceId: String! - id: String! - timestamp: DateTime! -} - -type WorkspacePage { - id: String! - workspaceId: String! - mode: PublicPageMode! - public: Boolean! -} - -"""The mode which the public page default in""" -enum PublicPageMode { - Page - Edgeless -} - -type Query { - """server config""" - serverConfig: ServerConfigType! - - """Get is owner of workspace""" - isOwner(workspaceId: String!): Boolean! - - """Get all accessible workspaces for current user""" - workspaces: [WorkspaceType!]! - - """Get public workspace by id""" - publicWorkspace(id: String!): WorkspaceType! - - """Get workspace by id""" - workspace(id: String!): WorkspaceType! - - """Update workspace""" - getInviteInfo(inviteId: String!): InvitationType! - listWorkspaceFeatures(feature: FeatureType!): [WorkspaceType!]! - - """List blobs of workspace""" - listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead") - collectAllBlobSizes: WorkspaceBlobSizes! @deprecated(reason: "use `user.storageUsage` instead") - checkBlobSize(workspaceId: String!, size: SafeInt!): WorkspaceBlobSizes! @deprecated(reason: "no more needed") - - """Get current user""" - currentUser: UserType - - """Get user by email""" - user(email: String!): UserOrLimitedUser - earlyAccessUsers: [UserType!]! - prices: [SubscriptionPrice!]! -} - -union UserOrLimitedUser = UserType | LimitedUserType - type LimitedUserType { """User email""" email: String! @@ -336,60 +91,314 @@ type LimitedUserType { } type Mutation { - signUp(name: String!, email: String!, password: String!): UserType! - signIn(email: String!, password: String!): UserType! - changePassword(token: String!, newPassword: String!): UserType! - changeEmail(token: String!): UserType! - sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean! - sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean! - sendChangeEmail(email: String!, callbackUrl: String!): Boolean! - sendVerifyChangeEmail(token: String!, email: String!, callbackUrl: String!): Boolean! - - """Create a new workspace""" - createWorkspace(init: Upload): WorkspaceType! - - """Update workspace""" - updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType! - deleteWorkspace(id: String!): Boolean! - invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String! - revoke(workspaceId: String!, userId: String!): Boolean! - acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean! - leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: Boolean): Boolean! - addWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int! - removeWorkspaceFeature(workspaceId: String!, feature: FeatureType!): Int! - setWorkspaceExperimentalFeature(workspaceId: String!, feature: FeatureType!, enable: Boolean!): Boolean! - sharePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "renamed to publicPage") - publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage! - revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage") - revokePublicPage(workspaceId: String!, pageId: String!): WorkspacePage! - recoverDoc(workspaceId: String!, guid: String!, timestamp: DateTime!): DateTime! - setBlob(workspaceId: String!, blob: Upload!): String! - deleteBlob(workspaceId: String!, hash: String!): Boolean! - - """Upload user avatar""" - uploadAvatar(avatar: Upload!): UserType! - - """Remove user avatar""" - removeAvatar: RemoveAvatar! - deleteAccount: DeleteAccount! + acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! addToEarlyAccess(email: String!): Int! - removeEarlyAccess(email: String!): Int! + addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! + cancelSubscription(idempotencyKey: String!): UserSubscription! + changeEmail(token: String!): UserType! + changePassword(newPassword: String!, token: String!): UserType! """Create a subscription checkout link of stripe""" - checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String! + checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! """Create a stripe customer portal to manage payment methods""" createCustomerPortal: String! - cancelSubscription(idempotencyKey: String!): UserSubscription! + + """Create a new workspace""" + createWorkspace(init: Upload): WorkspaceType! + deleteAccount: DeleteAccount! + deleteBlob(hash: String!, workspaceId: String!): Boolean! + deleteWorkspace(id: String!): Boolean! + invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String! + leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean! + publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage! + recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime! + + """Remove user avatar""" + removeAvatar: RemoveAvatar! + removeEarlyAccess(email: String!): Int! + removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! resumeSubscription(idempotencyKey: String!): UserSubscription! - updateSubscriptionRecurring(recurring: SubscriptionRecurring!, idempotencyKey: String!): UserSubscription! + revoke(userId: String!, workspaceId: String!): Boolean! + revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") + revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! + sendChangeEmail(callbackUrl: String!, email: String!): Boolean! + sendChangePasswordEmail(callbackUrl: String!, email: String!): Boolean! + sendSetPasswordEmail(callbackUrl: String!, email: String!): Boolean! + sendVerifyChangeEmail(callbackUrl: String!, email: String!, token: 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 publicPage") + signIn(email: String!, password: String!): UserType! + signUp(email: String!, name: String!, password: String!): UserType! + updateSubscriptionRecurring(idempotencyKey: String!, recurring: SubscriptionRecurring!): UserSubscription! + + """Update workspace""" + updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType! + + """Upload user avatar""" + uploadAvatar(avatar: Upload!): UserType! +} + +"""User permission in workspace""" +enum Permission { + Admin + Owner + Read + Write +} + +"""The mode which the public page default in""" +enum PublicPageMode { + Edgeless + Page +} + +type Query { + checkBlobSize(size: SafeInt!, workspaceId: String!): WorkspaceBlobSizes! @deprecated(reason: "no more needed") + collectAllBlobSizes: WorkspaceBlobSizes! @deprecated(reason: "use `user.storageUsage` instead") + + """Get current user""" + currentUser: UserType + earlyAccessUsers: [UserType!]! + + """Update workspace""" + getInviteInfo(inviteId: String!): InvitationType! + + """Get is owner of workspace""" + isOwner(workspaceId: String!): Boolean! + + """List blobs of workspace""" + listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead") + listWorkspaceFeatures(feature: FeatureType!): [WorkspaceType!]! + prices: [SubscriptionPrice!]! + + """Get public workspace by id""" + publicWorkspace(id: String!): WorkspaceType! + + """server config""" + serverConfig: ServerConfigType! + + """Get user by email""" + user(email: String!): UserOrLimitedUser + + """Get workspace by id""" + workspace(id: String!): WorkspaceType! + + """Get all accessible workspaces for current user""" + workspaces: [WorkspaceType!]! +} + +type QuotaQueryType { + blobLimit: Int! + storageQuota: Int! + usedSize: Int! +} + +type RemoveAvatar { + success: Boolean! +} + +""" +The `SafeInt` scalar type represents non-fractional signed whole numeric values that are considered safe as defined by the ECMAScript specification. +""" +scalar SafeInt @specifiedBy(url: "https://www.ecma-international.org/ecma-262/#sec-number.issafeinteger") + +type ServerConfigType { + """server base url""" + baseUrl: String! + + """enabled server features""" + features: [ServerFeature!]! + + """server flavor""" + flavor: String! @deprecated(reason: "use `features`") + + """server identical name could be shown as badge on user interface""" + name: String! + + """server version""" + version: String! +} + +enum ServerFeature { + Payment +} + +enum SubscriptionPlan { + Enterprise + Free + Pro + SelfHosted + Team +} + +type SubscriptionPrice { + amount: Int! + currency: String! + plan: SubscriptionPlan! + type: String! + yearlyAmount: Int! +} + +enum SubscriptionRecurring { + Monthly + Yearly +} + +enum SubscriptionStatus { + Active + Canceled + Incomplete + IncompleteExpired + PastDue + Paused + Trialing + Unpaid +} + +type TokenType { + refresh: String! + sessionToken: String + token: String! +} + +input UpdateWorkspaceInput { + id: ID! + + """is Public workspace""" + public: Boolean } """The `Upload` scalar type represents a file upload.""" scalar Upload -input UpdateWorkspaceInput { - """is Public workspace""" - public: Boolean +type UserInvoice { + amount: Int! + createdAt: DateTime! + currency: String! + id: String! + lastPaymentError: String + link: String + plan: SubscriptionPlan! + reason: String! + recurring: SubscriptionRecurring! + status: InvoiceStatus! + updatedAt: DateTime! +} + +union UserOrLimitedUser = LimitedUserType | UserType + +type UserQuota { + blobLimit: SafeInt! + historyPeriod: SafeInt! + humanReadable: UserQuotaHumanReadable! + memberLimit: Int! + name: String! + storageQuota: SafeInt! +} + +type UserQuotaHumanReadable { + blobLimit: String! + historyPeriod: String! + memberLimit: String! + name: String! + storageQuota: String! +} + +type UserSubscription { + canceledAt: DateTime + createdAt: DateTime! + end: DateTime! + id: String! + nextBillAt: DateTime + plan: SubscriptionPlan! + recurring: SubscriptionRecurring! + start: DateTime! + status: SubscriptionStatus! + trialEnd: DateTime + trialStart: DateTime + updatedAt: DateTime! +} + +type UserType { + """User avatar url""" + avatarUrl: String + + """User created date""" + createdAt: DateTime + + """User email""" + email: String! + + """User email verified""" + emailVerified: DateTime + + """User password has been set""" + hasPassword: Boolean id: ID! + + """Get user invoice count""" + invoiceCount: Int! + invoices(skip: Int, take: Int = 8): [UserInvoice!]! + + """User name""" + name: String! + quota: UserQuota + subscription: UserSubscription + token: TokenType! +} + +type WorkspaceBlobSizes { + size: SafeInt! +} + +type WorkspacePage { + id: String! + mode: PublicPageMode! + public: Boolean! + workspaceId: String! +} + +type WorkspaceType { + """Available features of workspace""" + availableFeatures: [FeatureType!]! + + """List blobs of workspace""" + blobs: [String!]! + + """Blobs size of workspace""" + blobsSize: Int! + + """Workspace created date""" + createdAt: DateTime! + + """Enabled features of workspace""" + features: [FeatureType!]! + histories(before: DateTime, guid: String!, take: Int): [DocHistoryType!]! + id: ID! + + """member count of workspace""" + memberCount: Int! + + """Members of workspace""" + members(skip: Int, take: Int): [InviteUserType!]! + + """Owner of workspace""" + owner: UserType! + + """Permission of current signed in user in workspace""" + permission: Permission! + + """is Public workspace""" + public: Boolean! + + """Public pages of a workspace""" + publicPages: [WorkspacePage!]! + + """quota of workspace""" + quota: QuotaQueryType! + + """Shared pages of workspace""" + sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") } \ No newline at end of file diff --git a/packages/backend/server/tests/app.e2e.ts b/packages/backend/server/tests/app.e2e.ts index 8a85bff34..6fd112927 100644 --- a/packages/backend/server/tests/app.e2e.ts +++ b/packages/backend/server/tests/app.e2e.ts @@ -10,7 +10,7 @@ import type { Express } from 'express'; import request from 'supertest'; import { AppModule } from '../src/app.module'; -import { FeatureManagementService } from '../src/modules/features'; +import { FeatureManagementService } from '../src/core/features'; import { createTestingApp } from './utils'; const gql = '/graphql'; diff --git a/packages/backend/server/tests/auth.e2e.ts b/packages/backend/server/tests/auth.e2e.ts index faa2de65e..4300928b6 100644 --- a/packages/backend/server/tests/auth.e2e.ts +++ b/packages/backend/server/tests/auth.e2e.ts @@ -5,8 +5,8 @@ import { import type { INestApplication } from '@nestjs/common'; import ava, { type TestFn } from 'ava'; +import { AuthService } from '../src/core/auth/service'; import { MailService } from '../src/fundamentals/mailer'; -import { AuthService } from '../src/modules/auth/service'; import { changeEmail, createTestingApp, diff --git a/packages/backend/server/tests/auth.spec.ts b/packages/backend/server/tests/auth.spec.ts index 20d02ed66..f5719ba5d 100644 --- a/packages/backend/server/tests/auth.spec.ts +++ b/packages/backend/server/tests/auth.spec.ts @@ -2,13 +2,13 @@ import { TestingModule } from '@nestjs/testing'; import test from 'ava'; +import { AuthResolver } from '../src/core/auth/resolver'; +import { AuthService } from '../src/core/auth/service'; import { ConfigModule } from '../src/fundamentals/config'; import { mintChallengeResponse, verifyChallengeResponse, } from '../src/fundamentals/storage'; -import { AuthResolver } from '../src/modules/auth/resolver'; -import { AuthService } from '../src/modules/auth/service'; import { createTestingModule } from './utils'; let authService: AuthService; diff --git a/packages/backend/server/tests/doc.spec.ts b/packages/backend/server/tests/doc.spec.ts index 08b39223e..b8b20a085 100644 --- a/packages/backend/server/tests/doc.spec.ts +++ b/packages/backend/server/tests/doc.spec.ts @@ -11,10 +11,10 @@ import { encodeStateAsUpdate, } from 'yjs'; +import { DocManager, DocModule } from '../src/core/doc'; +import { QuotaModule } from '../src/core/quota'; +import { StorageModule } from '../src/core/storage'; import { Config } from '../src/fundamentals/config'; -import { DocManager, DocModule } from '../src/modules/doc'; -import { QuotaModule } from '../src/modules/quota'; -import { StorageModule } from '../src/modules/storage'; import { createTestingModule, initTestingDB } from './utils'; const createModule = () => { diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts index 267097615..60be45e8b 100644 --- a/packages/backend/server/tests/feature.spec.ts +++ b/packages/backend/server/tests/feature.spec.ts @@ -4,17 +4,17 @@ import { INestApplication, Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import ava, { type TestFn } from 'ava'; -import { ConfigModule } from '../src/fundamentals/config'; -import { AuthService } from '../src/modules/auth/service'; +import { AuthService } from '../src/core/auth/service'; import { FeatureManagementService, FeatureModule, FeatureService, FeatureType, -} from '../src/modules/features'; -import { UserType } from '../src/modules/users/types'; -import { WorkspaceResolver } from '../src/modules/workspaces/resolvers'; -import { Permission } from '../src/modules/workspaces/types'; +} from '../src/core/features'; +import { UserType } from '../src/core/users/types'; +import { WorkspaceResolver } from '../src/core/workspaces/resolvers'; +import { Permission } from '../src/core/workspaces/types'; +import { ConfigModule } from '../src/fundamentals/config'; import { createTestingApp } from './utils'; @Injectable() diff --git a/packages/backend/server/tests/history.spec.ts b/packages/backend/server/tests/history.spec.ts index da87c4578..a706c3883 100644 --- a/packages/backend/server/tests/history.spec.ts +++ b/packages/backend/server/tests/history.spec.ts @@ -4,10 +4,10 @@ import { PrismaClient } from '@prisma/client'; import test from 'ava'; import * as Sinon from 'sinon'; +import { DocHistoryManager } from '../src/core/doc'; +import { QuotaModule } from '../src/core/quota'; +import { StorageModule } from '../src/core/storage'; import { type EventPayload } from '../src/fundamentals/event'; -import { DocHistoryManager } from '../src/modules/doc'; -import { QuotaModule } from '../src/modules/quota'; -import { StorageModule } from '../src/modules/storage'; import { createTestingModule } from './utils'; let m: TestingModule; diff --git a/packages/backend/server/tests/mailer.e2e.ts b/packages/backend/server/tests/mailer.e2e.ts index 21039981b..ee8ac1ec3 100644 --- a/packages/backend/server/tests/mailer.e2e.ts +++ b/packages/backend/server/tests/mailer.e2e.ts @@ -10,8 +10,8 @@ import { TestingModule } from '@nestjs/testing'; import { PrismaClient } from '@prisma/client'; import ava, { type TestFn } from 'ava'; +import { AuthService } from '../src/core/auth/service'; import { ConfigModule } from '../src/fundamentals/config'; -import { AuthService } from '../src/modules/auth/service'; import { createTestingModule } from './utils'; const test = ava as TestFn<{ diff --git a/packages/backend/server/tests/mailer.spec.ts b/packages/backend/server/tests/mailer.spec.ts index 6afa234e5..cb4c4353b 100644 --- a/packages/backend/server/tests/mailer.spec.ts +++ b/packages/backend/server/tests/mailer.spec.ts @@ -7,9 +7,9 @@ import { PrismaClient } from '@prisma/client'; import ava, { type TestFn } from 'ava'; import { AppModule } from '../src/app.module'; +import { FeatureKind, FeatureManagementService } from '../src/core/features'; +import { Quotas } from '../src/core/quota'; import { MailService } from '../src/fundamentals/mailer'; -import { FeatureKind, FeatureManagementService } from '../src/modules/features'; -import { Quotas } from '../src/modules/quota'; import { createTestingApp, createWorkspace, diff --git a/packages/backend/server/tests/quota.spec.ts b/packages/backend/server/tests/quota.spec.ts index d1d23a731..d82663e0c 100644 --- a/packages/backend/server/tests/quota.spec.ts +++ b/packages/backend/server/tests/quota.spec.ts @@ -3,15 +3,15 @@ import { TestingModule } from '@nestjs/testing'; import ava, { type TestFn } from 'ava'; -import { AuthService } from '../src/modules/auth'; +import { AuthService } from '../src/core/auth'; import { QuotaManagementService, QuotaModule, Quotas, QuotaService, QuotaType, -} from '../src/modules/quota'; -import { StorageModule } from '../src/modules/storage'; +} from '../src/core/quota'; +import { StorageModule } from '../src/core/storage'; import { createTestingModule } from './utils'; const test = ava as TestFn<{ diff --git a/packages/backend/server/tests/session.spec.ts b/packages/backend/server/tests/session.spec.ts index 6c5852a82..7e668317b 100644 --- a/packages/backend/server/tests/session.spec.ts +++ b/packages/backend/server/tests/session.spec.ts @@ -4,7 +4,6 @@ import { TestingModule } from '@nestjs/testing'; import ava, { type TestFn } from 'ava'; import { CacheModule } from '../src/fundamentals/cache'; -import { ConfigModule } from '../src/fundamentals/config'; import { SessionModule, SessionService } from '../src/fundamentals/session'; import { createTestingModule } from './utils'; @@ -15,15 +14,7 @@ const test = ava as TestFn<{ test.beforeEach(async t => { const module = await createTestingModule({ - imports: [ - ConfigModule.forRoot({ - redis: { - enabled: false, - }, - }), - CacheModule, - SessionModule, - ], + imports: [CacheModule, SessionModule], }); const session = module.get(SessionService); t.context.module = module; diff --git a/packages/backend/server/tests/utils/invite.ts b/packages/backend/server/tests/utils/invite.ts index aeaacb2ce..3bf0a3853 100644 --- a/packages/backend/server/tests/utils/invite.ts +++ b/packages/backend/server/tests/utils/invite.ts @@ -1,7 +1,7 @@ import type { INestApplication } from '@nestjs/common'; import request from 'supertest'; -import type { InvitationType } from '../../src/modules/workspaces'; +import type { InvitationType } from '../../src/core/workspaces'; import { gql } from './common'; export async function inviteUser( diff --git a/packages/backend/server/tests/utils/user.ts b/packages/backend/server/tests/utils/user.ts index 64a374e81..3ead722ff 100644 --- a/packages/backend/server/tests/utils/user.ts +++ b/packages/backend/server/tests/utils/user.ts @@ -1,8 +1,8 @@ import type { INestApplication } from '@nestjs/common'; import request from 'supertest'; -import type { TokenType } from '../../src/modules/auth'; -import type { UserType } from '../../src/modules/users'; +import type { TokenType } from '../../src/core/auth'; +import type { UserType } from '../../src/core/users'; import { gql } from './common'; export async function signUp( diff --git a/packages/backend/server/tests/utils/utils.ts b/packages/backend/server/tests/utils/utils.ts index 55d3d23e9..00cec6b3b 100644 --- a/packages/backend/server/tests/utils/utils.ts +++ b/packages/backend/server/tests/utils/utils.ts @@ -5,6 +5,7 @@ import { PrismaClient } from '@prisma/client'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule, FunctionalityModules } from '../../src/app.module'; +import { AuthModule } from '../../src/core/auth'; import { UserFeaturesInit1698652531198 } from '../../src/data/migrations/1698652531198-user-features-init'; import { GqlModule } from '../../src/fundamentals/graphql'; @@ -68,7 +69,12 @@ export async function createTestingModule( imports = imports[0] === AppModule ? [AppModule] - : dedupeModules([...FunctionalityModules, GqlModule, ...imports]); + : dedupeModules([ + ...FunctionalityModules, + AuthModule, + GqlModule, + ...imports, + ]); const builder = Test.createTestingModule({ imports, diff --git a/packages/backend/server/tests/utils/workspace.ts b/packages/backend/server/tests/utils/workspace.ts index c8d5aa478..c90c08c53 100644 --- a/packages/backend/server/tests/utils/workspace.ts +++ b/packages/backend/server/tests/utils/workspace.ts @@ -1,7 +1,7 @@ import type { INestApplication } from '@nestjs/common'; import request from 'supertest'; -import type { WorkspaceType } from '../../src/modules/workspaces'; +import type { WorkspaceType } from '../../src/core/workspaces'; import { gql } from './common'; export async function createWorkspace( diff --git a/packages/backend/server/tests/workspace-blobs.spec.ts b/packages/backend/server/tests/workspace-blobs.spec.ts index 5d5b3dc93..54ebc2bb7 100644 --- a/packages/backend/server/tests/workspace-blobs.spec.ts +++ b/packages/backend/server/tests/workspace-blobs.spec.ts @@ -3,8 +3,8 @@ import test from 'ava'; import request from 'supertest'; import { AppModule } from '../src/app.module'; -import { FeatureManagementService, FeatureType } from '../src/modules/features'; -import { QuotaService, QuotaType } from '../src/modules/quota'; +import { FeatureManagementService, FeatureType } from '../src/core/features'; +import { QuotaService, QuotaType } from '../src/core/quota'; import { checkBlobSize, collectAllBlobSizes, diff --git a/packages/backend/server/tests/workspace-invite.e2e.ts b/packages/backend/server/tests/workspace-invite.e2e.ts index 2e9a9e78c..767e518e2 100644 --- a/packages/backend/server/tests/workspace-invite.e2e.ts +++ b/packages/backend/server/tests/workspace-invite.e2e.ts @@ -7,8 +7,8 @@ import { PrismaClient } from '@prisma/client'; import ava, { type TestFn } from 'ava'; import { AppModule } from '../src/app.module'; +import { AuthService } from '../src/core/auth/service'; import { MailService } from '../src/fundamentals/mailer'; -import { AuthService } from '../src/modules/auth/service'; import { acceptInviteById, createTestingApp, diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 19d3ff334..928d5cd8f 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -674,8 +674,10 @@ export const serverConfigQuery = { query serverConfig { serverConfig { version - flavor baseUrl + name + features + flavor } }`, }; diff --git a/packages/frontend/graphql/src/graphql/server-config.gql b/packages/frontend/graphql/src/graphql/server-config.gql index b18c3a8e9..2d42c3812 100644 --- a/packages/frontend/graphql/src/graphql/server-config.gql +++ b/packages/frontend/graphql/src/graphql/server-config.gql @@ -1,7 +1,9 @@ query serverConfig { serverConfig { version - flavor baseUrl + name + features + flavor } } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 40975525f..48fd0af10 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -63,6 +63,10 @@ export enum PublicPageMode { Page = 'Page', } +export enum ServerFeature { + Payment = 'Payment', +} + export enum SubscriptionPlan { Enterprise = 'Enterprise', Free = 'Free', @@ -656,8 +660,10 @@ export type ServerConfigQuery = { serverConfig: { __typename?: 'ServerConfigType'; version: string; - flavor: string; baseUrl: string; + name: string; + features: Array; + flavor: string; }; }; diff --git a/tests/affine-cloud/playwright.config.ts b/tests/affine-cloud/playwright.config.ts index fcd64a0b0..743b44e01 100644 --- a/tests/affine-cloud/playwright.config.ts +++ b/tests/affine-cloud/playwright.config.ts @@ -55,6 +55,8 @@ const config: PlaywrightTestConfig = { OAUTH_EMAIL_SENDER: 'noreply@toeverything.info', OAUTH_EMAIL_LOGIN: 'noreply@toeverything.info', OAUTH_EMAIL_PASSWORD: 'affine', + STRIPE_API_KEY: '1', + STRIPE_WEBHOOK_KEY: '1', }, }, ],