mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-09-11 19:47:35 +03:00
feat: add workspace level feature apis (#5503)
This commit is contained in:
parent
04ca554525
commit
f6ec786ef9
@ -124,7 +124,8 @@
|
||||
"node"
|
||||
],
|
||||
"files": [
|
||||
"tests/**/feature.spec.ts"
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
"require": [
|
||||
"./src/prelude.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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {}
|
||||
}
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 = {
|
||||
|
@ -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 {
|
||||
|
@ -292,9 +292,7 @@ export class FeatureService {
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(
|
||||
feature: FeatureType
|
||||
): Promise<Omit<WorkspaceType, 'members'>[]> {
|
||||
async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
|
||||
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) {
|
||||
|
@ -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',
|
||||
});
|
||||
|
@ -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({}),
|
||||
});
|
||||
|
@ -37,6 +37,12 @@ export const Features: Feature[] = [
|
||||
whitelist: ['@toeverything.info'],
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
version: 2,
|
||||
configs: {},
|
||||
},
|
||||
];
|
||||
|
||||
/// ======== schema infer ========
|
||||
|
@ -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],
|
||||
})
|
||||
|
91
packages/backend/server/src/modules/users/management.ts
Normal file
91
packages/backend/server/src/modules/users/management.ts
Normal file
@ -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<number> {
|
||||
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<number> {
|
||||
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<UserType[]> {
|
||||
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();
|
||||
}
|
||||
}
|
@ -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<number> {
|
||||
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<number> {
|
||||
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<UserType[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
87
packages/backend/server/src/modules/workspaces/management.ts
Normal file
87
packages/backend/server/src/modules/workspaces/management.ts
Normal file
@ -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<number> {
|
||||
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<boolean> {
|
||||
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<WorkspaceType[]> {
|
||||
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<FeatureType[]> {
|
||||
return this.feature.getWorkspaceFeatures(workspace.id);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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")
|
||||
|
@ -46,13 +46,6 @@ class FakePrisma {
|
||||
},
|
||||
};
|
||||
}
|
||||
get newFeaturesWaitingList() {
|
||||
return {
|
||||
async findUnique() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async t => {
|
||||
|
@ -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');
|
||||
|
@ -0,0 +1,5 @@
|
||||
query getWorkspaceFeatures($workspaceId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
features
|
||||
}
|
||||
}
|
@ -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',
|
||||
|
@ -0,0 +1,3 @@
|
||||
mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
|
||||
addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
query listWorkspaceFeatures($feature: FeatureType!) {
|
||||
listWorkspaceFeatures(feature: $feature) {
|
||||
id
|
||||
public
|
||||
createdAt
|
||||
memberCount
|
||||
owner {
|
||||
id
|
||||
}
|
||||
features
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
|
||||
removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
|
||||
}
|
@ -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<FeatureType> };
|
||||
};
|
||||
|
||||
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<FeatureType>;
|
||||
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;
|
||||
|
Loading…
Reference in New Issue
Block a user