From f6ec786ef9484d6984b2eb84fe53af5f99d46c74 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Fri, 5 Jan 2024 04:13:49 +0000 Subject: [PATCH] feat: add workspace level feature apis (#5503) --- packages/backend/server/package.json | 3 +- .../1698652531198-user-features-init.ts | 102 +----------------- .../1704352562369-refresh-user-features.ts | 16 +++ .../data/migrations/utils/user-features.ts | 100 +++++++++++++++++ .../server/src/modules/features/feature.ts | 9 -- .../server/src/modules/features/management.ts | 42 +++----- .../server/src/modules/features/service.ts | 6 +- .../src/modules/features/types/common.ts | 7 ++ .../modules/features/types/early-access.ts | 18 +--- .../src/modules/features/types/index.ts | 6 ++ .../backend/server/src/modules/users/index.ts | 3 +- .../server/src/modules/users/management.ts | 91 ++++++++++++++++ .../server/src/modules/users/resolver.ts | 73 +------------ .../server/src/modules/workspaces/index.ts | 5 +- .../src/modules/workspaces/management.ts | 87 +++++++++++++++ .../modules/workspaces/resolvers/workspace.ts | 2 +- packages/backend/server/src/schema.gql | 12 +++ packages/backend/server/tests/app.e2e.ts | 7 -- packages/backend/server/tests/feature.spec.ts | 2 +- .../src/graphql/get-workspace-features.gql | 5 + .../frontend/graphql/src/graphql/index.ts | 55 ++++++++++ .../src/graphql/workspace-feature-add.gql | 3 + .../src/graphql/workspace-feature-list.gql | 12 +++ .../src/graphql/workspace-feature-remove.gql | 3 + packages/frontend/graphql/src/schema.ts | 72 +++++++++++++ 25 files changed, 497 insertions(+), 244 deletions(-) create mode 100644 packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts create mode 100644 packages/backend/server/src/data/migrations/utils/user-features.ts create mode 100644 packages/backend/server/src/modules/users/management.ts create mode 100644 packages/backend/server/src/modules/workspaces/management.ts create mode 100644 packages/frontend/graphql/src/graphql/get-workspace-features.gql create mode 100644 packages/frontend/graphql/src/graphql/workspace-feature-add.gql create mode 100644 packages/frontend/graphql/src/graphql/workspace-feature-list.gql create mode 100644 packages/frontend/graphql/src/graphql/workspace-feature-remove.gql diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 3dc1b7eeb9..19ced215fb 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -124,7 +124,8 @@ "node" ], "files": [ - "tests/**/feature.spec.ts" + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" ], "require": [ "./src/prelude.ts" 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 b877eb18b3..786d467718 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,13 +1,7 @@ -import { Prisma } from '@prisma/client'; - -import { - CommonFeature, - FeatureKind, - Features, - FeatureType, -} from '../../modules/features'; +import { Features } from '../../modules/features'; import { Quotas } from '../../modules/quota/schema'; import { PrismaService } from '../../prisma'; +import { migrateNewFeatureTable, upsertFeature } from './utils/user-features'; export class UserFeaturesInit1698652531198 { // do the migration @@ -28,95 +22,3 @@ export class UserFeaturesInit1698652531198 { // TODO: revert the migration } } - -// upgrade features from lower version to higher version -async function upsertFeature( - db: PrismaService, - feature: CommonFeature -): Promise { - const hasEqualOrGreaterVersion = - (await db.features.count({ - where: { - feature: feature.feature, - version: { - gte: feature.version, - }, - }, - })) > 0; - // will not update exists version - if (!hasEqualOrGreaterVersion) { - await db.features.create({ - data: { - feature: feature.feature, - type: feature.type, - version: feature.version, - configs: feature.configs as Prisma.InputJsonValue, - }, - }); - } -} - -async function migrateNewFeatureTable(prisma: PrismaService) { - const waitingList = await prisma.newFeaturesWaitingList.findMany(); - for (const oldUser of waitingList) { - const user = await prisma.user.findFirst({ - where: { - email: oldUser.email, - }, - }); - if (user) { - const hasEarlyAccess = await prisma.userFeatures.count({ - where: { - userId: user.id, - feature: { - feature: FeatureType.EarlyAccess, - }, - activated: true, - }, - }); - if (hasEarlyAccess === 0) { - await prisma.$transaction(async tx => { - const latestFlag = await tx.userFeatures.findFirst({ - where: { - userId: user.id, - feature: { - feature: FeatureType.EarlyAccess, - type: FeatureKind.Feature, - }, - activated: true, - }, - orderBy: { - createdAt: 'desc', - }, - }); - if (latestFlag) { - return latestFlag.id; - } else { - return tx.userFeatures - .create({ - data: { - reason: 'Early access user', - activated: true, - user: { - connect: { - id: user.id, - }, - }, - feature: { - connect: { - feature_version: { - feature: FeatureType.EarlyAccess, - version: 1, - }, - type: FeatureKind.Feature, - }, - }, - }, - }) - .then(r => r.id); - } - }); - } - } - } -} 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 new file mode 100644 index 0000000000..407aa22986 --- /dev/null +++ b/packages/backend/server/src/data/migrations/1704352562369-refresh-user-features.ts @@ -0,0 +1,16 @@ +import { Features } from '../../modules/features'; +import { PrismaService } from '../../prisma'; +import { upsertFeature } from './utils/user-features'; + +export class RefreshUserFeatures1704352562369 { + // do the migration + static async up(db: PrismaService) { + // add early access v2 & copilot feature + for (const feature of Features) { + await upsertFeature(db, feature); + } + } + + // revert the migration + static async down(_db: PrismaService) {} +} diff --git a/packages/backend/server/src/data/migrations/utils/user-features.ts b/packages/backend/server/src/data/migrations/utils/user-features.ts new file mode 100644 index 0000000000..0e93ce2328 --- /dev/null +++ b/packages/backend/server/src/data/migrations/utils/user-features.ts @@ -0,0 +1,100 @@ +import { Prisma } from '@prisma/client'; + +import { + CommonFeature, + FeatureKind, + FeatureType, +} from '../../../modules/features'; +import { PrismaService } from '../../../prisma'; + +// upgrade features from lower version to higher version +export async function upsertFeature( + db: PrismaService, + feature: CommonFeature +): Promise { + const hasEqualOrGreaterVersion = + (await db.features.count({ + where: { + feature: feature.feature, + version: { + gte: feature.version, + }, + }, + })) > 0; + // will not update exists version + if (!hasEqualOrGreaterVersion) { + await db.features.create({ + data: { + feature: feature.feature, + type: feature.type, + version: feature.version, + configs: feature.configs as Prisma.InputJsonValue, + }, + }); + } +} + +export async function migrateNewFeatureTable(prisma: PrismaService) { + const waitingList = await prisma.newFeaturesWaitingList.findMany(); + for (const oldUser of waitingList) { + const user = await prisma.user.findFirst({ + where: { + email: oldUser.email, + }, + }); + if (user) { + const hasEarlyAccess = await prisma.userFeatures.count({ + where: { + userId: user.id, + feature: { + feature: FeatureType.EarlyAccess, + }, + activated: true, + }, + }); + if (hasEarlyAccess === 0) { + await prisma.$transaction(async tx => { + const latestFlag = await tx.userFeatures.findFirst({ + where: { + userId: user.id, + feature: { + feature: FeatureType.EarlyAccess, + type: FeatureKind.Feature, + }, + activated: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + if (latestFlag) { + return latestFlag.id; + } else { + return tx.userFeatures + .create({ + data: { + reason: 'Early access user', + activated: true, + user: { + connect: { + id: user.id, + }, + }, + feature: { + connect: { + feature_version: { + feature: FeatureType.EarlyAccess, + version: 1, + }, + type: FeatureKind.Feature, + }, + }, + }, + }) + .then(r => r.id); + } + }); + } + } + } +} diff --git a/packages/backend/server/src/modules/features/feature.ts b/packages/backend/server/src/modules/features/feature.ts index b04654f8d6..9f585ed99d 100644 --- a/packages/backend/server/src/modules/features/feature.ts +++ b/packages/backend/server/src/modules/features/feature.ts @@ -40,15 +40,6 @@ export class EarlyAccessFeatureConfig extends FeatureConfig { throw new Error('Invalid feature config: type is not EarlyAccess'); } } - - checkWhiteList(email: string) { - for (const domain in this.config.configs.whitelist) { - if (email.endsWith(domain)) { - return true; - } - } - return false; - } } const FeatureConfigMap = { diff --git a/packages/backend/server/src/modules/features/management.ts b/packages/backend/server/src/modules/features/management.ts index 81067841f2..0b36df4fad 100644 --- a/packages/backend/server/src/modules/features/management.ts +++ b/packages/backend/server/src/modules/features/management.ts @@ -1,35 +1,32 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; -import { EarlyAccessFeatureConfig } from './feature'; import { FeatureService } from './service'; import { FeatureType } from './types'; -enum NewFeaturesKind { - EarlyAccess, -} +const STAFF = ['@toeverything.info']; @Injectable() -export class FeatureManagementService implements OnModuleInit { +export class FeatureManagementService { protected logger = new Logger(FeatureManagementService.name); - private earlyAccessFeature?: EarlyAccessFeatureConfig; + constructor( private readonly feature: FeatureService, private readonly prisma: PrismaService, private readonly config: Config ) {} - async onModuleInit() { - this.earlyAccessFeature = await this.feature.getFeature( - FeatureType.EarlyAccess - ); - } // ======== Admin ======== // todo(@darkskygit): replace this with abac isStaff(email: string) { - return this.earlyAccessFeature?.checkWhiteList(email) ?? false; + for (const domain of STAFF) { + if (email.endsWith(domain)) { + return true; + } + } + return false; } // ======== Early Access ======== @@ -38,7 +35,7 @@ export class FeatureManagementService implements OnModuleInit { return this.feature.addUserFeature( userId, FeatureType.EarlyAccess, - 1, + 2, 'Early access user' ); } @@ -63,23 +60,8 @@ export class FeatureManagementService implements OnModuleInit { const canEarlyAccess = await this.feature .hasUserFeature(user.id, FeatureType.EarlyAccess) .catch(() => false); - if (canEarlyAccess) { - return true; - } - // TODO: Outdated, switch to feature gates - const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList - .findUnique({ - where: { email, type: NewFeaturesKind.EarlyAccess }, - }) - .then(x => !!x) - .catch(() => false); - if (oldCanEarlyAccess) { - this.logger.warn( - `User ${email} has early access in old table but not in new table` - ); - } - return oldCanEarlyAccess; + return canEarlyAccess; } return false; } else { diff --git a/packages/backend/server/src/modules/features/service.ts b/packages/backend/server/src/modules/features/service.ts index 2b653f45aa..afed8a1b84 100644 --- a/packages/backend/server/src/modules/features/service.ts +++ b/packages/backend/server/src/modules/features/service.ts @@ -292,9 +292,7 @@ export class FeatureService { return configs.filter(feature => !!feature.feature); } - async listFeatureWorkspaces( - feature: FeatureType - ): Promise[]> { + async listFeatureWorkspaces(feature: FeatureType): Promise { return this.prisma.workspaceFeatures .findMany({ where: { @@ -314,7 +312,7 @@ export class FeatureService { }, }, }) - .then(wss => wss.map(ws => ws.workspace)); + .then(wss => wss.map(ws => ws.workspace as WorkspaceType)); } async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) { diff --git a/packages/backend/server/src/modules/features/types/common.ts b/packages/backend/server/src/modules/features/types/common.ts index 448ff156af..913705567b 100644 --- a/packages/backend/server/src/modules/features/types/common.ts +++ b/packages/backend/server/src/modules/features/types/common.ts @@ -1,4 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + export enum FeatureType { Copilot = 'copilot', EarlyAccess = 'early_access', } + +registerEnumType(FeatureType, { + name: 'FeatureType', + description: 'The type of workspace feature', +}); diff --git a/packages/backend/server/src/modules/features/types/early-access.ts b/packages/backend/server/src/modules/features/types/early-access.ts index a3fb3a19fb..abf3b238f5 100644 --- a/packages/backend/server/src/modules/features/types/early-access.ts +++ b/packages/backend/server/src/modules/features/types/early-access.ts @@ -1,24 +1,8 @@ -import { URL } from 'node:url'; - import { z } from 'zod'; import { FeatureType } from './common'; -function checkHostname(host: string) { - try { - return new URL(`https://${host}`).hostname === host; - } catch (_) { - return false; - } -} - export const featureEarlyAccess = z.object({ feature: z.literal(FeatureType.EarlyAccess), - configs: z.object({ - whitelist: z - .string() - .startsWith('@') - .refine(domain => checkHostname(domain.slice(1))) - .array(), - }), + configs: z.object({}), }); diff --git a/packages/backend/server/src/modules/features/types/index.ts b/packages/backend/server/src/modules/features/types/index.ts index 2f445b43ec..0fd8b62323 100644 --- a/packages/backend/server/src/modules/features/types/index.ts +++ b/packages/backend/server/src/modules/features/types/index.ts @@ -37,6 +37,12 @@ export const Features: Feature[] = [ whitelist: ['@toeverything.info'], }, }, + { + feature: FeatureType.EarlyAccess, + type: FeatureKind.Feature, + version: 2, + configs: {}, + }, ]; /// ======== schema infer ======== diff --git a/packages/backend/server/src/modules/users/index.ts b/packages/backend/server/src/modules/users/index.ts index ee9dda42d8..f25792b026 100644 --- a/packages/backend/server/src/modules/users/index.ts +++ b/packages/backend/server/src/modules/users/index.ts @@ -4,12 +4,13 @@ import { FeatureModule } from '../features'; import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; import { UserAvatarController } from './controller'; +import { UserManagementResolver } from './management'; import { UserResolver } from './resolver'; import { UsersService } from './users'; @Module({ imports: [StorageModule, FeatureModule, QuotaModule], - providers: [UserResolver, UsersService], + providers: [UserResolver, UserManagementResolver, UsersService], controllers: [UserAvatarController], exports: [UsersService], }) diff --git a/packages/backend/server/src/modules/users/management.ts b/packages/backend/server/src/modules/users/management.ts new file mode 100644 index 0000000000..a740466b98 --- /dev/null +++ b/packages/backend/server/src/modules/users/management.ts @@ -0,0 +1,91 @@ +import { + BadRequestException, + ForbiddenException, + UseGuards, +} from '@nestjs/common'; +import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { CloudThrottlerGuard, Throttle } from '../../throttler'; +import { Auth, CurrentUser } from '../auth/guard'; +import { AuthService } from '../auth/service'; +import { FeatureManagementService } from '../features'; +import { UserType } from './types'; +import { UsersService } from './users'; + +/** + * User resolver + * All op rate limit: 10 req/m + */ +@UseGuards(CloudThrottlerGuard) +@Auth() +@Resolver(() => UserType) +export class UserManagementResolver { + constructor( + private readonly auth: AuthService, + private readonly users: UsersService, + private readonly feature: FeatureManagementService + ) {} + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Mutation(() => Int) + async addToEarlyAccess( + @CurrentUser() currentUser: UserType, + @Args('email') email: string + ): Promise { + if (!this.feature.isStaff(currentUser.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + const user = await this.users.findUserByEmail(email); + if (user) { + return this.feature.addEarlyAccess(user.id); + } else { + const user = await this.auth.createAnonymousUser(email); + return this.feature.addEarlyAccess(user.id); + } + } + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Mutation(() => Int) + async removeEarlyAccess( + @CurrentUser() currentUser: UserType, + @Args('email') email: string + ): Promise { + if (!this.feature.isStaff(currentUser.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + const user = await this.users.findUserByEmail(email); + if (!user) { + throw new BadRequestException(`User ${email} not found`); + } + return this.feature.removeEarlyAccess(user.id); + } + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Query(() => [UserType]) + async earlyAccessUsers( + @Context() ctx: { isAdminQuery: boolean }, + @CurrentUser() user: UserType + ): Promise { + if (!this.feature.isStaff(user.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + // allow query other user's subscription + ctx.isAdminQuery = true; + return this.feature.listEarlyAccess(); + } +} diff --git a/packages/backend/server/src/modules/users/resolver.ts b/packages/backend/server/src/modules/users/resolver.ts index 22a54e44fc..6f605f9899 100644 --- a/packages/backend/server/src/modules/users/resolver.ts +++ b/packages/backend/server/src/modules/users/resolver.ts @@ -1,12 +1,6 @@ -import { - BadRequestException, - ForbiddenException, - HttpStatus, - UseGuards, -} from '@nestjs/common'; +import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common'; import { Args, - Context, Int, Mutation, Query, @@ -22,7 +16,6 @@ import { PrismaService } from '../../prisma/service'; import { CloudThrottlerGuard, Throttle } from '../../throttler'; import type { FileUpload } from '../../types'; import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; -import { AuthService } from '../auth/service'; import { FeatureManagementService } from '../features'; import { QuotaService } from '../quota'; import { AvatarStorage } from '../storage'; @@ -38,7 +31,6 @@ import { UsersService } from './users'; @Resolver(() => UserType) export class UserResolver { constructor( - private readonly auth: AuthService, private readonly prisma: PrismaService, private readonly storage: AvatarStorage, private readonly users: UsersService, @@ -199,67 +191,4 @@ export class UserResolver { this.event.emit('user.deleted', deletedUser); return { success: true }; } - - @Throttle({ - default: { - limit: 10, - ttl: 60, - }, - }) - @Mutation(() => Int) - async addToEarlyAccess( - @CurrentUser() currentUser: UserType, - @Args('email') email: string - ): Promise { - if (!this.feature.isStaff(currentUser.email)) { - throw new ForbiddenException('You are not allowed to do this'); - } - const user = await this.users.findUserByEmail(email); - if (user) { - return this.feature.addEarlyAccess(user.id); - } else { - const user = await this.auth.createAnonymousUser(email); - return this.feature.addEarlyAccess(user.id); - } - } - - @Throttle({ - default: { - limit: 10, - ttl: 60, - }, - }) - @Mutation(() => Int) - async removeEarlyAccess( - @CurrentUser() currentUser: UserType, - @Args('email') email: string - ): Promise { - if (!this.feature.isStaff(currentUser.email)) { - throw new ForbiddenException('You are not allowed to do this'); - } - const user = await this.users.findUserByEmail(email); - if (!user) { - throw new BadRequestException(`User ${email} not found`); - } - return this.feature.removeEarlyAccess(user.id); - } - - @Throttle({ - default: { - limit: 10, - ttl: 60, - }, - }) - @Query(() => [UserType]) - async earlyAccessUsers( - @Context() ctx: { isAdminQuery: boolean }, - @CurrentUser() user: UserType - ): Promise { - if (!this.feature.isStaff(user.email)) { - throw new ForbiddenException('You are not allowed to do this'); - } - // allow query other user's subscription - ctx.isAdminQuery = true; - return this.feature.listEarlyAccess(); - } } diff --git a/packages/backend/server/src/modules/workspaces/index.ts b/packages/backend/server/src/modules/workspaces/index.ts index 6585c6e9d0..36a822e7b3 100644 --- a/packages/backend/server/src/modules/workspaces/index.ts +++ b/packages/backend/server/src/modules/workspaces/index.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { DocModule } from '../doc'; +import { FeatureModule } from '../features'; import { QuotaModule } from '../quota'; import { StorageModule } from '../storage'; import { UsersService } from '../users'; import { WorkspacesController } from './controller'; +import { WorkspaceManagementResolver } from './management'; import { PermissionService } from './permission'; import { DocHistoryResolver, @@ -14,10 +16,11 @@ import { } from './resolvers'; @Module({ - imports: [DocModule, QuotaModule, StorageModule], + imports: [DocModule, FeatureModule, QuotaModule, StorageModule], controllers: [WorkspacesController], providers: [ WorkspaceResolver, + WorkspaceManagementResolver, PermissionService, UsersService, PagePermissionResolver, diff --git a/packages/backend/server/src/modules/workspaces/management.ts b/packages/backend/server/src/modules/workspaces/management.ts new file mode 100644 index 0000000000..1981c6859c --- /dev/null +++ b/packages/backend/server/src/modules/workspaces/management.ts @@ -0,0 +1,87 @@ +import { ForbiddenException, UseGuards } from '@nestjs/common'; +import { + Args, + Int, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; + +import { CloudThrottlerGuard, Throttle } from '../../throttler'; +import { Auth, CurrentUser } from '../auth'; +import { FeatureManagementService, FeatureType } from '../features'; +import { UserType } from '../users'; +import { WorkspaceType } from './types'; + +@UseGuards(CloudThrottlerGuard) +@Auth() +@Resolver(() => WorkspaceType) +export class WorkspaceManagementResolver { + constructor(private readonly feature: FeatureManagementService) {} + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Mutation(() => Int) + async addWorkspaceFeature( + @CurrentUser() currentUser: UserType, + @Args('workspaceId') workspaceId: string, + @Args('feature', { type: () => FeatureType }) feature: FeatureType + ): Promise { + if (!this.feature.isStaff(currentUser.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + + return this.feature.addWorkspaceFeatures(workspaceId, feature); + } + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Mutation(() => Int) + async removeWorkspaceFeature( + @CurrentUser() currentUser: UserType, + @Args('workspaceId') workspaceId: string, + @Args('feature', { type: () => FeatureType }) feature: FeatureType + ): Promise { + if (!this.feature.isStaff(currentUser.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + + return this.feature.removeWorkspaceFeature(workspaceId, feature); + } + + @Throttle({ + default: { + limit: 10, + ttl: 60, + }, + }) + @Query(() => [WorkspaceType]) + async listWorkspaceFeatures( + @CurrentUser() user: UserType, + @Args('feature', { type: () => FeatureType }) feature: FeatureType + ): Promise { + if (!this.feature.isStaff(user.email)) { + throw new ForbiddenException('You are not allowed to do this'); + } + + return this.feature.listFeatureWorkspaces(feature); + } + + @ResolveField(() => [FeatureType], { + description: 'Enabled features of workspace', + complexity: 2, + }) + async features(@Parent() workspace: WorkspaceType): Promise { + return this.feature.getWorkspaceFeatures(workspace.id); + } +} diff --git a/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts b/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts index eb772a66b3..ad3208b555 100644 --- a/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/modules/workspaces/resolvers/workspace.ts @@ -47,7 +47,7 @@ import { defaultWorkspaceAvatar } from '../utils'; @Auth() @Resolver(() => WorkspaceType) export class WorkspaceResolver { - private readonly logger = new Logger('WorkspaceResolver'); + private readonly logger = new Logger(WorkspaceResolver.name); constructor( private readonly auth: AuthService, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 3ae63243fd..acbf3f2399 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -131,6 +131,9 @@ type WorkspaceType { """Owner of workspace""" owner: UserType! + """Enabled features of workspace""" + features: [FeatureType!]! + """Shared pages of workspace""" sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") @@ -142,6 +145,12 @@ type WorkspaceType { blobsSize: Int! } +"""The type of workspace feature""" +enum FeatureType { + Copilot + EarlyAccess +} + type InvitationWorkspaceType { id: ID! @@ -279,6 +288,7 @@ type Query { """Update workspace""" getInviteInfo(inviteId: String!): InvitationType! + listWorkspaceFeatures(feature: FeatureType!): [WorkspaceType!]! """List blobs of workspace""" listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead") @@ -314,6 +324,8 @@ type Mutation { 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! 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") diff --git a/packages/backend/server/tests/app.e2e.ts b/packages/backend/server/tests/app.e2e.ts index 1578fe5f87..04ee701e4b 100644 --- a/packages/backend/server/tests/app.e2e.ts +++ b/packages/backend/server/tests/app.e2e.ts @@ -46,13 +46,6 @@ class FakePrisma { }, }; } - get newFeaturesWaitingList() { - return { - async findUnique() { - return null; - }, - }; - } } test.beforeEach(async t => { diff --git a/packages/backend/server/tests/feature.spec.ts b/packages/backend/server/tests/feature.spec.ts index c1755b2e7e..50896d21d1 100644 --- a/packages/backend/server/tests/feature.spec.ts +++ b/packages/backend/server/tests/feature.spec.ts @@ -114,7 +114,7 @@ test('should be able to set user feature', async t => { const f1 = await feature.getUserFeatures(u1.id); t.is(f1.length, 0, 'should be empty'); - await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 1, 'test'); + await feature.addUserFeature(u1.id, FeatureType.EarlyAccess, 2, 'test'); const f2 = await feature.getUserFeatures(u1.id); t.is(f2.length, 1, 'should have 1 feature'); diff --git a/packages/frontend/graphql/src/graphql/get-workspace-features.gql b/packages/frontend/graphql/src/graphql/get-workspace-features.gql new file mode 100644 index 0000000000..8644712835 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-workspace-features.gql @@ -0,0 +1,5 @@ +query getWorkspaceFeatures($workspaceId: String!) { + workspace(id: $workspaceId) { + features + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 9178e0f355..23e56cd600 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -383,6 +383,19 @@ query getWorkspacePublicPages($workspaceId: String!) { }`, }; +export const getWorkspaceFeaturesQuery = { + id: 'getWorkspaceFeaturesQuery' as const, + operationName: 'getWorkspaceFeatures', + definitionName: 'workspace', + containsFile: false, + query: ` +query getWorkspaceFeatures($workspaceId: String!) { + workspace(id: $workspaceId) { + features + } +}`, +}; + export const getWorkspaceQuery = { id: 'getWorkspaceQuery' as const, operationName: 'getWorkspace', @@ -760,6 +773,48 @@ mutation uploadAvatar($avatar: Upload!) { }`, }; +export const addWorkspaceFeatureMutation = { + id: 'addWorkspaceFeatureMutation' as const, + operationName: 'addWorkspaceFeature', + definitionName: 'addWorkspaceFeature', + containsFile: false, + query: ` +mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { + addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) +}`, +}; + +export const listWorkspaceFeaturesQuery = { + id: 'listWorkspaceFeaturesQuery' as const, + operationName: 'listWorkspaceFeatures', + definitionName: 'listWorkspaceFeatures', + containsFile: false, + query: ` +query listWorkspaceFeatures($feature: FeatureType!) { + listWorkspaceFeatures(feature: $feature) { + id + public + createdAt + memberCount + owner { + id + } + features + } +}`, +}; + +export const removeWorkspaceFeatureMutation = { + id: 'removeWorkspaceFeatureMutation' as const, + operationName: 'removeWorkspaceFeature', + definitionName: 'removeWorkspaceFeature', + containsFile: false, + query: ` +mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { + removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) +}`, +}; + export const inviteByEmailMutation = { id: 'inviteByEmailMutation' as const, operationName: 'inviteByEmail', diff --git a/packages/frontend/graphql/src/graphql/workspace-feature-add.gql b/packages/frontend/graphql/src/graphql/workspace-feature-add.gql new file mode 100644 index 0000000000..d77eafbd16 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-feature-add.gql @@ -0,0 +1,3 @@ +mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { + addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) +} diff --git a/packages/frontend/graphql/src/graphql/workspace-feature-list.gql b/packages/frontend/graphql/src/graphql/workspace-feature-list.gql new file mode 100644 index 0000000000..4bd21fbe45 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-feature-list.gql @@ -0,0 +1,12 @@ +query listWorkspaceFeatures($feature: FeatureType!) { + listWorkspaceFeatures(feature: $feature) { + id + public + createdAt + memberCount + owner { + id + } + features + } +} diff --git a/packages/frontend/graphql/src/graphql/workspace-feature-remove.gql b/packages/frontend/graphql/src/graphql/workspace-feature-remove.gql new file mode 100644 index 0000000000..e856885b56 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-feature-remove.gql @@ -0,0 +1,3 @@ +mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) { + removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature) +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 7f55ef17ff..e430f2edd3 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -32,6 +32,12 @@ export interface Scalars { Upload: { input: File; output: File }; } +/** The type of workspace feature */ +export enum FeatureType { + Copilot = 'Copilot', + EarlyAccess = 'EarlyAccess', +} + export enum InvoiceStatus { Draft = 'Draft', Open = 'Open', @@ -392,6 +398,15 @@ export type GetWorkspacePublicPagesQuery = { }; }; +export type GetWorkspaceFeaturesQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type GetWorkspaceFeaturesQuery = { + __typename?: 'Query'; + workspace: { __typename?: 'WorkspaceType'; features: Array }; +}; + export type GetWorkspaceQueryVariables = Exact<{ id: Scalars['String']['input']; }>; @@ -724,6 +739,43 @@ export type UploadAvatarMutation = { }; }; +export type AddWorkspaceFeatureMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + feature: FeatureType; +}>; + +export type AddWorkspaceFeatureMutation = { + __typename?: 'Mutation'; + addWorkspaceFeature: number; +}; + +export type ListWorkspaceFeaturesQueryVariables = Exact<{ + feature: FeatureType; +}>; + +export type ListWorkspaceFeaturesQuery = { + __typename?: 'Query'; + listWorkspaceFeatures: Array<{ + __typename?: 'WorkspaceType'; + id: string; + public: boolean; + createdAt: string; + memberCount: number; + features: Array; + owner: { __typename?: 'UserType'; id: string }; + }>; +}; + +export type RemoveWorkspaceFeatureMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + feature: FeatureType; +}>; + +export type RemoveWorkspaceFeatureMutation = { + __typename?: 'Mutation'; + removeWorkspaceFeature: number; +}; + export type InviteByEmailMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; email: Scalars['String']['input']; @@ -815,6 +867,11 @@ export type Queries = variables: GetWorkspacePublicPagesQueryVariables; response: GetWorkspacePublicPagesQuery; } + | { + name: 'getWorkspaceFeaturesQuery'; + variables: GetWorkspaceFeaturesQueryVariables; + response: GetWorkspaceFeaturesQuery; + } | { name: 'getWorkspaceQuery'; variables: GetWorkspaceQueryVariables; @@ -859,6 +916,11 @@ export type Queries = name: 'subscriptionQuery'; variables: SubscriptionQueryVariables; response: SubscriptionQuery; + } + | { + name: 'listWorkspaceFeaturesQuery'; + variables: ListWorkspaceFeaturesQueryVariables; + response: ListWorkspaceFeaturesQuery; }; export type Mutations = @@ -1002,6 +1064,16 @@ export type Mutations = variables: UploadAvatarMutationVariables; response: UploadAvatarMutation; } + | { + name: 'addWorkspaceFeatureMutation'; + variables: AddWorkspaceFeatureMutationVariables; + response: AddWorkspaceFeatureMutation; + } + | { + name: 'removeWorkspaceFeatureMutation'; + variables: RemoveWorkspaceFeatureMutationVariables; + response: RemoveWorkspaceFeatureMutation; + } | { name: 'inviteByEmailMutation'; variables: InviteByEmailMutationVariables;