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
25 changed files with 497 additions and 244 deletions

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