feat: add workspace level feature apis (#5503)

This commit is contained in:
DarkSky 2024-01-05 04:13:49 +00:00
parent 04ca554525
commit f6ec786ef9
No known key found for this signature in database
GPG Key ID: 97B7D036B1566E9D
25 changed files with 497 additions and 244 deletions

View File

@ -124,7 +124,8 @@
"node" "node"
], ],
"files": [ "files": [
"tests/**/feature.spec.ts" "tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
], ],
"require": [ "require": [
"./src/prelude.ts" "./src/prelude.ts"

View File

@ -1,13 +1,7 @@
import { Prisma } from '@prisma/client'; import { Features } from '../../modules/features';
import {
CommonFeature,
FeatureKind,
Features,
FeatureType,
} from '../../modules/features';
import { Quotas } from '../../modules/quota/schema'; import { Quotas } from '../../modules/quota/schema';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { migrateNewFeatureTable, upsertFeature } from './utils/user-features';
export class UserFeaturesInit1698652531198 { export class UserFeaturesInit1698652531198 {
// do the migration // do the migration
@ -28,95 +22,3 @@ export class UserFeaturesInit1698652531198 {
// TODO: revert the migration // 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);
}
});
}
}
}
}

View File

@ -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) {}
}

View File

@ -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);
}
});
}
}
}
}

View File

@ -40,15 +40,6 @@ export class EarlyAccessFeatureConfig extends FeatureConfig {
throw new Error('Invalid feature config: type is not EarlyAccess'); 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 = { const FeatureConfigMap = {

View File

@ -1,35 +1,32 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Config } from '../../config'; import { Config } from '../../config';
import { PrismaService } from '../../prisma'; import { PrismaService } from '../../prisma';
import { EarlyAccessFeatureConfig } from './feature';
import { FeatureService } from './service'; import { FeatureService } from './service';
import { FeatureType } from './types'; import { FeatureType } from './types';
enum NewFeaturesKind { const STAFF = ['@toeverything.info'];
EarlyAccess,
}
@Injectable() @Injectable()
export class FeatureManagementService implements OnModuleInit { export class FeatureManagementService {
protected logger = new Logger(FeatureManagementService.name); protected logger = new Logger(FeatureManagementService.name);
private earlyAccessFeature?: EarlyAccessFeatureConfig;
constructor( constructor(
private readonly feature: FeatureService, private readonly feature: FeatureService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly config: Config private readonly config: Config
) {} ) {}
async onModuleInit() {
this.earlyAccessFeature = await this.feature.getFeature(
FeatureType.EarlyAccess
);
}
// ======== Admin ======== // ======== Admin ========
// todo(@darkskygit): replace this with abac // todo(@darkskygit): replace this with abac
isStaff(email: string) { isStaff(email: string) {
return this.earlyAccessFeature?.checkWhiteList(email) ?? false; for (const domain of STAFF) {
if (email.endsWith(domain)) {
return true;
}
}
return false;
} }
// ======== Early Access ======== // ======== Early Access ========
@ -38,7 +35,7 @@ export class FeatureManagementService implements OnModuleInit {
return this.feature.addUserFeature( return this.feature.addUserFeature(
userId, userId,
FeatureType.EarlyAccess, FeatureType.EarlyAccess,
1, 2,
'Early access user' 'Early access user'
); );
} }
@ -63,23 +60,8 @@ export class FeatureManagementService implements OnModuleInit {
const canEarlyAccess = await this.feature const canEarlyAccess = await this.feature
.hasUserFeature(user.id, FeatureType.EarlyAccess) .hasUserFeature(user.id, FeatureType.EarlyAccess)
.catch(() => false); .catch(() => false);
if (canEarlyAccess) {
return true;
}
// TODO: Outdated, switch to feature gates return canEarlyAccess;
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 false; return false;
} else { } else {

View File

@ -292,9 +292,7 @@ export class FeatureService {
return configs.filter(feature => !!feature.feature); return configs.filter(feature => !!feature.feature);
} }
async listFeatureWorkspaces( async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
feature: FeatureType
): Promise<Omit<WorkspaceType, 'members'>[]> {
return this.prisma.workspaceFeatures return this.prisma.workspaceFeatures
.findMany({ .findMany({
where: { 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) { async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {

View File

@ -1,4 +1,11 @@
import { registerEnumType } from '@nestjs/graphql';
export enum FeatureType { export enum FeatureType {
Copilot = 'copilot', Copilot = 'copilot',
EarlyAccess = 'early_access', EarlyAccess = 'early_access',
} }
registerEnumType(FeatureType, {
name: 'FeatureType',
description: 'The type of workspace feature',
});

View File

@ -1,24 +1,8 @@
import { URL } from 'node:url';
import { z } from 'zod'; import { z } from 'zod';
import { FeatureType } from './common'; import { FeatureType } from './common';
function checkHostname(host: string) {
try {
return new URL(`https://${host}`).hostname === host;
} catch (_) {
return false;
}
}
export const featureEarlyAccess = z.object({ export const featureEarlyAccess = z.object({
feature: z.literal(FeatureType.EarlyAccess), feature: z.literal(FeatureType.EarlyAccess),
configs: z.object({ configs: z.object({}),
whitelist: z
.string()
.startsWith('@')
.refine(domain => checkHostname(domain.slice(1)))
.array(),
}),
}); });

View File

@ -37,6 +37,12 @@ export const Features: Feature[] = [
whitelist: ['@toeverything.info'], whitelist: ['@toeverything.info'],
}, },
}, },
{
feature: FeatureType.EarlyAccess,
type: FeatureKind.Feature,
version: 2,
configs: {},
},
]; ];
/// ======== schema infer ======== /// ======== schema infer ========

View File

@ -4,12 +4,13 @@ import { FeatureModule } from '../features';
import { QuotaModule } from '../quota'; import { QuotaModule } from '../quota';
import { StorageModule } from '../storage'; import { StorageModule } from '../storage';
import { UserAvatarController } from './controller'; import { UserAvatarController } from './controller';
import { UserManagementResolver } from './management';
import { UserResolver } from './resolver'; import { UserResolver } from './resolver';
import { UsersService } from './users'; import { UsersService } from './users';
@Module({ @Module({
imports: [StorageModule, FeatureModule, QuotaModule], imports: [StorageModule, FeatureModule, QuotaModule],
providers: [UserResolver, UsersService], providers: [UserResolver, UserManagementResolver, UsersService],
controllers: [UserAvatarController], controllers: [UserAvatarController],
exports: [UsersService], exports: [UsersService],
}) })

View 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();
}
}

View File

@ -1,12 +1,6 @@
import { import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
BadRequestException,
ForbiddenException,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { import {
Args, Args,
Context,
Int, Int,
Mutation, Mutation,
Query, Query,
@ -22,7 +16,6 @@ import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types'; import type { FileUpload } from '../../types';
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { AuthService } from '../auth/service';
import { FeatureManagementService } from '../features'; import { FeatureManagementService } from '../features';
import { QuotaService } from '../quota'; import { QuotaService } from '../quota';
import { AvatarStorage } from '../storage'; import { AvatarStorage } from '../storage';
@ -38,7 +31,6 @@ import { UsersService } from './users';
@Resolver(() => UserType) @Resolver(() => UserType)
export class UserResolver { export class UserResolver {
constructor( constructor(
private readonly auth: AuthService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly storage: AvatarStorage, private readonly storage: AvatarStorage,
private readonly users: UsersService, private readonly users: UsersService,
@ -199,67 +191,4 @@ export class UserResolver {
this.event.emit('user.deleted', deletedUser); this.event.emit('user.deleted', deletedUser);
return { success: true }; 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();
}
} }

View File

@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DocModule } from '../doc'; import { DocModule } from '../doc';
import { FeatureModule } from '../features';
import { QuotaModule } from '../quota'; import { QuotaModule } from '../quota';
import { StorageModule } from '../storage'; import { StorageModule } from '../storage';
import { UsersService } from '../users'; import { UsersService } from '../users';
import { WorkspacesController } from './controller'; import { WorkspacesController } from './controller';
import { WorkspaceManagementResolver } from './management';
import { PermissionService } from './permission'; import { PermissionService } from './permission';
import { import {
DocHistoryResolver, DocHistoryResolver,
@ -14,10 +16,11 @@ import {
} from './resolvers'; } from './resolvers';
@Module({ @Module({
imports: [DocModule, QuotaModule, StorageModule], imports: [DocModule, FeatureModule, QuotaModule, StorageModule],
controllers: [WorkspacesController], controllers: [WorkspacesController],
providers: [ providers: [
WorkspaceResolver, WorkspaceResolver,
WorkspaceManagementResolver,
PermissionService, PermissionService,
UsersService, UsersService,
PagePermissionResolver, PagePermissionResolver,

View 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);
}
}

View File

@ -47,7 +47,7 @@ import { defaultWorkspaceAvatar } from '../utils';
@Auth() @Auth()
@Resolver(() => WorkspaceType) @Resolver(() => WorkspaceType)
export class WorkspaceResolver { export class WorkspaceResolver {
private readonly logger = new Logger('WorkspaceResolver'); private readonly logger = new Logger(WorkspaceResolver.name);
constructor( constructor(
private readonly auth: AuthService, private readonly auth: AuthService,

View File

@ -131,6 +131,9 @@ type WorkspaceType {
"""Owner of workspace""" """Owner of workspace"""
owner: UserType! owner: UserType!
"""Enabled features of workspace"""
features: [FeatureType!]!
"""Shared pages of workspace""" """Shared pages of workspace"""
sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages")
@ -142,6 +145,12 @@ type WorkspaceType {
blobsSize: Int! blobsSize: Int!
} }
"""The type of workspace feature"""
enum FeatureType {
Copilot
EarlyAccess
}
type InvitationWorkspaceType { type InvitationWorkspaceType {
id: ID! id: ID!
@ -279,6 +288,7 @@ type Query {
"""Update workspace""" """Update workspace"""
getInviteInfo(inviteId: String!): InvitationType! getInviteInfo(inviteId: String!): InvitationType!
listWorkspaceFeatures(feature: FeatureType!): [WorkspaceType!]!
"""List blobs of workspace""" """List blobs of workspace"""
listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead") listBlobs(workspaceId: String!): [String!]! @deprecated(reason: "use `workspace.blobs` instead")
@ -314,6 +324,8 @@ type Mutation {
revoke(workspaceId: String!, userId: String!): Boolean! revoke(workspaceId: String!, userId: String!): Boolean!
acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean! acceptInviteById(workspaceId: String!, inviteId: String!, sendAcceptMail: Boolean): Boolean!
leaveWorkspace(workspaceId: String!, workspaceName: String!, sendLeaveMail: 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") sharePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "renamed to publicPage")
publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage! publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage!
revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage")

View File

@ -46,13 +46,6 @@ class FakePrisma {
}, },
}; };
} }
get newFeaturesWaitingList() {
return {
async findUnique() {
return null;
},
};
}
} }
test.beforeEach(async t => { test.beforeEach(async t => {

View File

@ -114,7 +114,7 @@ test('should be able to set user feature', async t => {
const f1 = await feature.getUserFeatures(u1.id); const f1 = await feature.getUserFeatures(u1.id);
t.is(f1.length, 0, 'should be empty'); 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); const f2 = await feature.getUserFeatures(u1.id);
t.is(f2.length, 1, 'should have 1 feature'); t.is(f2.length, 1, 'should have 1 feature');

View File

@ -0,0 +1,5 @@
query getWorkspaceFeatures($workspaceId: String!) {
workspace(id: $workspaceId) {
features
}
}

View File

@ -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 = { export const getWorkspaceQuery = {
id: 'getWorkspaceQuery' as const, id: 'getWorkspaceQuery' as const,
operationName: 'getWorkspace', 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 = { export const inviteByEmailMutation = {
id: 'inviteByEmailMutation' as const, id: 'inviteByEmailMutation' as const,
operationName: 'inviteByEmail', operationName: 'inviteByEmail',

View File

@ -0,0 +1,3 @@
mutation addWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
addWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
}

View File

@ -0,0 +1,12 @@
query listWorkspaceFeatures($feature: FeatureType!) {
listWorkspaceFeatures(feature: $feature) {
id
public
createdAt
memberCount
owner {
id
}
features
}
}

View File

@ -0,0 +1,3 @@
mutation removeWorkspaceFeature($workspaceId: String!, $feature: FeatureType!) {
removeWorkspaceFeature(workspaceId: $workspaceId, feature: $feature)
}

View File

@ -32,6 +32,12 @@ export interface Scalars {
Upload: { input: File; output: File }; Upload: { input: File; output: File };
} }
/** The type of workspace feature */
export enum FeatureType {
Copilot = 'Copilot',
EarlyAccess = 'EarlyAccess',
}
export enum InvoiceStatus { export enum InvoiceStatus {
Draft = 'Draft', Draft = 'Draft',
Open = 'Open', 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<{ export type GetWorkspaceQueryVariables = Exact<{
id: Scalars['String']['input']; 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<{ export type InviteByEmailMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
email: Scalars['String']['input']; email: Scalars['String']['input'];
@ -815,6 +867,11 @@ export type Queries =
variables: GetWorkspacePublicPagesQueryVariables; variables: GetWorkspacePublicPagesQueryVariables;
response: GetWorkspacePublicPagesQuery; response: GetWorkspacePublicPagesQuery;
} }
| {
name: 'getWorkspaceFeaturesQuery';
variables: GetWorkspaceFeaturesQueryVariables;
response: GetWorkspaceFeaturesQuery;
}
| { | {
name: 'getWorkspaceQuery'; name: 'getWorkspaceQuery';
variables: GetWorkspaceQueryVariables; variables: GetWorkspaceQueryVariables;
@ -859,6 +916,11 @@ export type Queries =
name: 'subscriptionQuery'; name: 'subscriptionQuery';
variables: SubscriptionQueryVariables; variables: SubscriptionQueryVariables;
response: SubscriptionQuery; response: SubscriptionQuery;
}
| {
name: 'listWorkspaceFeaturesQuery';
variables: ListWorkspaceFeaturesQueryVariables;
response: ListWorkspaceFeaturesQuery;
}; };
export type Mutations = export type Mutations =
@ -1002,6 +1064,16 @@ export type Mutations =
variables: UploadAvatarMutationVariables; variables: UploadAvatarMutationVariables;
response: UploadAvatarMutation; response: UploadAvatarMutation;
} }
| {
name: 'addWorkspaceFeatureMutation';
variables: AddWorkspaceFeatureMutationVariables;
response: AddWorkspaceFeatureMutation;
}
| {
name: 'removeWorkspaceFeatureMutation';
variables: RemoveWorkspaceFeatureMutationVariables;
response: RemoveWorkspaceFeatureMutation;
}
| { | {
name: 'inviteByEmailMutation'; name: 'inviteByEmailMutation';
variables: InviteByEmailMutationVariables; variables: InviteByEmailMutationVariables;