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"
],
"files": [
"tests/**/feature.spec.ts"
"tests/**/*.spec.ts",
"tests/**/*.e2e.ts"
],
"require": [
"./src/prelude.ts"

View File

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

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');
}
}
checkWhiteList(email: string) {
for (const domain in this.config.configs.whitelist) {
if (email.endsWith(domain)) {
return true;
}
}
return false;
}
}
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 { 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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -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],
})

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

View File

@ -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,

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()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
private readonly logger = new Logger('WorkspaceResolver');
private readonly logger = new Logger(WorkspaceResolver.name);
constructor(
private readonly auth: AuthService,

View File

@ -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")

View File

@ -46,13 +46,6 @@ class FakePrisma {
},
};
}
get newFeaturesWaitingList() {
return {
async findUnique() {
return null;
},
};
}
}
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);
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');

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 = {
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',

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 };
}
/** 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;