mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
feat: improve admin panel (#14180)
This commit is contained in:
@@ -28,22 +28,9 @@ export class MockTeamWorkspace extends Mocker<
|
||||
},
|
||||
});
|
||||
|
||||
const feature = await this.db.feature.findFirst({
|
||||
where: {
|
||||
name: Feature.TeamPlan,
|
||||
},
|
||||
});
|
||||
|
||||
if (!feature) {
|
||||
throw new Error(
|
||||
`Feature ${Feature.TeamPlan} does not exist in DB. You might forgot to run data-migration first.`
|
||||
);
|
||||
}
|
||||
|
||||
await this.db.workspaceFeature.create({
|
||||
data: {
|
||||
workspaceId: id,
|
||||
featureId: feature.id,
|
||||
reason: 'test',
|
||||
activated: true,
|
||||
name: Feature.TeamPlan,
|
||||
|
||||
@@ -27,23 +27,10 @@ export class MockUser extends Mocker<MockUserInput, MockedUser> {
|
||||
});
|
||||
|
||||
if (feature) {
|
||||
const featureRecord = await this.db.feature.findFirst({
|
||||
where: {
|
||||
name: feature,
|
||||
},
|
||||
});
|
||||
|
||||
if (!featureRecord) {
|
||||
throw new Error(
|
||||
`Feature ${feature} does not exist in DB. You might forgot to run data-migration first.`
|
||||
);
|
||||
}
|
||||
|
||||
const config = FeatureConfigs[feature];
|
||||
await this.db.userFeature.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
featureId: featureRecord.id,
|
||||
name: feature,
|
||||
type: config.type,
|
||||
reason: 'test',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { FeatureType } from '../../models';
|
||||
import { FeatureModel } from '../../models/feature';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
|
||||
@@ -39,96 +38,3 @@ test('should throw if feature not found', async t => {
|
||||
message: 'Feature not_found_feature not found',
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw if feature config in invalid', async t => {
|
||||
const { feature } = t.context;
|
||||
const freePlanFeature = await feature.get('free_plan_v1');
|
||||
|
||||
// @ts-expect-error internal
|
||||
await feature.db.feature.update({
|
||||
where: {
|
||||
id: freePlanFeature.id,
|
||||
},
|
||||
data: {
|
||||
configs: {
|
||||
...freePlanFeature.configs,
|
||||
memberLimit: 'invalid' as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(feature.get('free_plan_v1'), {
|
||||
message: 'Invalid feature config for free_plan_v1',
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE(@forehalo): backward compatibility
|
||||
// new version of feature config may introduce new field
|
||||
// this test means to ensure that the older version of AFFiNE Server can still read it
|
||||
test('should get feature if extra fields exist in feature config', async t => {
|
||||
const { feature } = t.context;
|
||||
const freePlanFeature = await feature.get('free_plan_v1');
|
||||
|
||||
// @ts-expect-error internal
|
||||
await feature.db.feature.update({
|
||||
where: {
|
||||
id: freePlanFeature.id,
|
||||
},
|
||||
data: {
|
||||
configs: {
|
||||
...freePlanFeature.configs,
|
||||
extraField: 'extraValue',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const freePlanFeature2 = await feature.get('free_plan_v1');
|
||||
|
||||
t.snapshot(freePlanFeature2.configs);
|
||||
});
|
||||
|
||||
test('should create feature', async t => {
|
||||
const { feature } = t.context;
|
||||
|
||||
// @ts-expect-error internal
|
||||
const newFeature = await feature.upsert(
|
||||
'new_feature' as any,
|
||||
{},
|
||||
FeatureType.Feature,
|
||||
1
|
||||
);
|
||||
|
||||
t.deepEqual(newFeature.configs, {});
|
||||
});
|
||||
|
||||
test('should update feature', async t => {
|
||||
const { feature } = t.context;
|
||||
const freePlanFeature = await feature.get('free_plan_v1');
|
||||
|
||||
// @ts-expect-error internal
|
||||
const newFreePlanFeature = await feature.upsert(
|
||||
'free_plan_v1',
|
||||
{
|
||||
...freePlanFeature.configs,
|
||||
memberLimit: 10,
|
||||
},
|
||||
FeatureType.Quota,
|
||||
1
|
||||
);
|
||||
|
||||
t.deepEqual(newFreePlanFeature.configs, {
|
||||
...freePlanFeature.configs,
|
||||
memberLimit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw if feature config is invalid when updating', async t => {
|
||||
const { feature } = t.context;
|
||||
await t.throwsAsync(
|
||||
// @ts-expect-error internal
|
||||
feature.upsert('free_plan_v1', {} as any, FeatureType.Quota, 1),
|
||||
{
|
||||
message: 'Invalid feature config for free_plan_v1',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -295,7 +295,7 @@ test('should paginate users', async t => {
|
||||
)
|
||||
);
|
||||
|
||||
const users = await t.context.user.pagination(0, 10);
|
||||
const users = await t.context.user.list({ skip: 0, take: 10 });
|
||||
t.is(users.length, 10);
|
||||
t.deepEqual(
|
||||
users.map(user => user.email),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { INestApplicationContext, LogLevel } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import whywhywhy from 'why-is-node-running';
|
||||
|
||||
import { RefreshFeatures0001 } from '../../data/migrations/0001-refresh-features';
|
||||
|
||||
export const TEST_LOG_LEVEL: LogLevel =
|
||||
(process.env.TEST_LOG_LEVEL as LogLevel) ?? 'fatal';
|
||||
|
||||
@@ -27,7 +24,6 @@ async function flushDB(client: PrismaClient) {
|
||||
export async function initTestingDB(context: INestApplicationContext) {
|
||||
const db = context.get(PrismaClient, { strict: false });
|
||||
await flushDB(db);
|
||||
await RefreshFeatures0001.up(db, context.get(ModuleRef));
|
||||
}
|
||||
|
||||
export async function sleep(ms: number) {
|
||||
|
||||
@@ -375,10 +375,6 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
message:
|
||||
'You are trying to sign in by a different method than you signed up with.',
|
||||
},
|
||||
early_access_required: {
|
||||
type: 'action_forbidden',
|
||||
message: `You don't have early access permission. Visit https://community.affine.pro/c/insider-general/ for more information.`,
|
||||
},
|
||||
sign_up_forbidden: {
|
||||
type: 'action_forbidden',
|
||||
message: `You are not allowed to sign up.`,
|
||||
|
||||
@@ -213,12 +213,6 @@ export class WrongSignInMethod extends UserFriendlyError {
|
||||
}
|
||||
}
|
||||
|
||||
export class EarlyAccessRequired extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'early_access_required', message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SignUpForbidden extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('action_forbidden', 'sign_up_forbidden', message);
|
||||
@@ -1146,7 +1140,6 @@ export enum ErrorNames {
|
||||
INVALID_PASSWORD_LENGTH,
|
||||
PASSWORD_REQUIRED,
|
||||
WRONG_SIGN_IN_METHOD,
|
||||
EARLY_ACCESS_REQUIRED,
|
||||
SIGN_UP_FORBIDDEN,
|
||||
EMAIL_TOKEN_NOT_FOUND,
|
||||
INVALID_EMAIL_TOKEN,
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
Cache,
|
||||
Config,
|
||||
CryptoHelper,
|
||||
EarlyAccessRequired,
|
||||
EmailTokenNotFound,
|
||||
InvalidAuthState,
|
||||
InvalidEmail,
|
||||
@@ -120,7 +120,7 @@ export class AuthController {
|
||||
validators.assertValidEmail(credential.email);
|
||||
const canSignIn = await this.auth.canSignIn(credential.email);
|
||||
if (!canSignIn) {
|
||||
throw new EarlyAccessRequired();
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
if (credential.password) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, SignUpForbidden } from '../../base';
|
||||
import { Models, type User, type UserSession } from '../../models';
|
||||
import { FeatureService } from '../features';
|
||||
import { Mailer } from '../mail/mailer';
|
||||
import { createDevUsers } from './dev';
|
||||
import type { CurrentUser } from './session';
|
||||
@@ -44,8 +43,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly models: Models,
|
||||
private readonly mailer: Mailer,
|
||||
private readonly feature: FeatureService
|
||||
private readonly mailer: Mailer
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -54,8 +52,9 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
async canSignIn(email: string) {
|
||||
return await this.feature.canEarlyAccess(email);
|
||||
async canSignIn(_email: string) {
|
||||
// may add more sign-in check later
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from 'zod';
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
export interface ServerFlags {
|
||||
earlyAccessControl: boolean;
|
||||
allowGuestDemoWorkspace: boolean;
|
||||
}
|
||||
|
||||
@@ -72,10 +71,6 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
|
||||
});
|
||||
|
||||
defineModuleConfig('flags', {
|
||||
earlyAccessControl: {
|
||||
desc: 'Only allow users with early access features to access the app',
|
||||
default: false,
|
||||
},
|
||||
allowGuestDemoWorkspace: {
|
||||
desc: 'Whether allow guest users to create demo workspaces.',
|
||||
default: true,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, URLHelper } from '../../base';
|
||||
import { Namespace } from '../../env';
|
||||
import { Feature } from '../../models';
|
||||
import { Feature, type WorkspaceFeatureName } from '../../models';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { AvailableUserFeatureConfig } from '../features';
|
||||
@@ -75,7 +75,7 @@ export class ServerConfigResolver {
|
||||
name:
|
||||
this.config.server.name ??
|
||||
(env.selfhosted
|
||||
? 'AFFiNE Selfhosted Cloud'
|
||||
? 'AFFiNE SelfHosted Cloud'
|
||||
: env.namespaces.canary
|
||||
? 'AFFiNE Canary Cloud'
|
||||
: env.namespaces.beta
|
||||
@@ -85,8 +85,6 @@ export class ServerConfigResolver {
|
||||
baseUrl: this.url.requestBaseUrl,
|
||||
type: env.DEPLOYMENT_TYPE,
|
||||
features: this.server.features,
|
||||
// TODO(@fengmk2): remove this field after the feature 0.25.0 is released
|
||||
allowGuestDemoWorkspace: this.config.flags.allowGuestDemoWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +168,13 @@ export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
|
||||
override availableUserFeatures() {
|
||||
return super.availableUserFeatures();
|
||||
}
|
||||
|
||||
@ResolveField(() => [Feature], {
|
||||
description: 'Workspace features available for admin configuration',
|
||||
})
|
||||
availableWorkspaceFeatures(): WorkspaceFeatureName[] {
|
||||
return ['unlimited_workspace', 'team_plan_v1'];
|
||||
}
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -40,11 +40,4 @@ export class ServerConfigType {
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'Whether allow guest users to create demo workspaces.',
|
||||
deprecationReason:
|
||||
'This field is deprecated, please use `features` instead. Will be removed in 0.25.0',
|
||||
})
|
||||
allowGuestDemoWorkspace!: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
|
||||
const STAFF = ['@toeverything.info', '@affine.pro'];
|
||||
@@ -14,10 +13,7 @@ export enum EarlyAccessType {
|
||||
export class FeatureService {
|
||||
protected logger = new Logger(FeatureService.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly models: Models
|
||||
) {}
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
// ======== Admin ========
|
||||
isStaff(email: string) {
|
||||
@@ -38,27 +34,6 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
async addEarlyAccess(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.models.userFeature.add(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access',
|
||||
'Early access user'
|
||||
);
|
||||
}
|
||||
|
||||
async removeEarlyAccess(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
return this.models.userFeature.remove(
|
||||
userId,
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
|
||||
);
|
||||
}
|
||||
|
||||
async isEarlyAccessUser(
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
@@ -68,21 +43,4 @@ export class FeatureService {
|
||||
type === EarlyAccessType.App ? 'early_access' : 'ai_early_access'
|
||||
);
|
||||
}
|
||||
|
||||
async canEarlyAccess(
|
||||
email: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
const earlyAccessControlEnabled = this.config.flags.earlyAccessControl;
|
||||
|
||||
if (earlyAccessControlEnabled && !this.isStaff(email)) {
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return this.isEarlyAccessUser(user.id, type);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../base';
|
||||
import { Models, UserSettingsSchema } from '../../models';
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
UserFeatureName,
|
||||
UserSettingsSchema,
|
||||
} from '../../models';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
@@ -194,6 +199,12 @@ class ListUserInput {
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 20 })
|
||||
first!: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
keyword?: string;
|
||||
|
||||
@Field(() => [Feature], { nullable: true })
|
||||
features?: Feature[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -242,8 +253,14 @@ export class UserManagementResolver {
|
||||
@Query(() => Int, {
|
||||
description: 'Get users count',
|
||||
})
|
||||
async usersCount(): Promise<number> {
|
||||
return this.db.user.count();
|
||||
async usersCount(
|
||||
@Args({ name: 'filter', type: () => ListUserInput, nullable: true })
|
||||
input?: ListUserInput
|
||||
): Promise<number> {
|
||||
return this.models.user.count({
|
||||
keyword: input?.keyword ?? null,
|
||||
features: (input?.features as UserFeatureName[]) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [UserType], {
|
||||
@@ -252,7 +269,12 @@ export class UserManagementResolver {
|
||||
async users(
|
||||
@Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput
|
||||
): Promise<UserType[]> {
|
||||
const users = await this.models.user.pagination(input.skip, input.first);
|
||||
const users = await this.models.user.list({
|
||||
skip: input.skip,
|
||||
take: input.first,
|
||||
keyword: input.keyword,
|
||||
features: input.features as UserFeatureName[],
|
||||
});
|
||||
|
||||
return users.map(sessionUser);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
WorkspaceMemberResolver,
|
||||
WorkspaceResolver,
|
||||
} from './resolvers';
|
||||
import { AdminWorkspaceResolver } from './resolvers/admin';
|
||||
import { WorkspaceService } from './service';
|
||||
|
||||
@Module({
|
||||
@@ -43,6 +44,7 @@ import { WorkspaceService } from './service';
|
||||
WorkspaceBlobResolver,
|
||||
WorkspaceService,
|
||||
WorkspaceEvents,
|
||||
AdminWorkspaceResolver,
|
||||
],
|
||||
exports: [WorkspaceService],
|
||||
})
|
||||
|
||||
305
packages/backend/server/src/core/workspaces/resolvers/admin.ts
Normal file
305
packages/backend/server/src/core/workspaces/resolvers/admin.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
PartialType,
|
||||
PickType,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
WorkspaceFeatureName,
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceRole,
|
||||
} from '../../../models';
|
||||
import { Admin } from '../../common';
|
||||
import { WorkspaceUserType } from '../../user';
|
||||
|
||||
enum AdminWorkspaceSort {
|
||||
CreatedAt = 'CreatedAt',
|
||||
SnapshotSize = 'SnapshotSize',
|
||||
BlobCount = 'BlobCount',
|
||||
BlobSize = 'BlobSize',
|
||||
}
|
||||
|
||||
registerEnumType(AdminWorkspaceSort, {
|
||||
name: 'AdminWorkspaceSort',
|
||||
});
|
||||
|
||||
@InputType()
|
||||
class ListWorkspaceInput {
|
||||
@Field(() => Int, { defaultValue: 20 })
|
||||
first!: number;
|
||||
|
||||
@Field(() => Int, { defaultValue: 0 })
|
||||
skip!: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
keyword?: string;
|
||||
|
||||
@Field(() => [Feature], { nullable: true })
|
||||
features?: WorkspaceFeatureName[];
|
||||
|
||||
@Field(() => AdminWorkspaceSort, { nullable: true })
|
||||
orderBy?: AdminWorkspaceSort;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminWorkspaceMember {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
email!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@Field(() => WorkspaceRole)
|
||||
role!: WorkspaceRole;
|
||||
|
||||
@Field(() => WorkspaceMemberStatus)
|
||||
status!: WorkspaceMemberStatus;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AdminWorkspace {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
public!: boolean;
|
||||
|
||||
@Field()
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
avatarKey?: string | null;
|
||||
|
||||
@Field()
|
||||
enableAi!: boolean;
|
||||
|
||||
@Field()
|
||||
enableUrlPreview!: boolean;
|
||||
|
||||
@Field()
|
||||
enableDocEmbedding!: boolean;
|
||||
|
||||
@Field(() => [Feature])
|
||||
features!: WorkspaceFeatureName[];
|
||||
|
||||
@Field(() => WorkspaceUserType, { nullable: true })
|
||||
owner?: WorkspaceUserType | null;
|
||||
|
||||
@Field(() => Int)
|
||||
memberCount!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
publicPageCount!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
snapshotCount!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
snapshotSize!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
blobCount!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
blobSize!: number;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class AdminUpdateWorkspaceInput extends PartialType(
|
||||
PickType(AdminWorkspace, [
|
||||
'public',
|
||||
'enableAi',
|
||||
'enableUrlPreview',
|
||||
'enableDocEmbedding',
|
||||
'name',
|
||||
'avatarKey',
|
||||
] as const),
|
||||
InputType
|
||||
) {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field(() => [Feature], { nullable: true })
|
||||
features?: WorkspaceFeatureName[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@Admin()
|
||||
@Resolver(() => AdminWorkspace)
|
||||
export class AdminWorkspaceResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@Query(() => [AdminWorkspace], {
|
||||
description: 'List workspaces for admin',
|
||||
})
|
||||
async adminWorkspaces(
|
||||
@Args('filter', { type: () => ListWorkspaceInput })
|
||||
filter: ListWorkspaceInput
|
||||
) {
|
||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
||||
first: filter.first,
|
||||
skip: filter.skip,
|
||||
keyword: filter.keyword,
|
||||
features: filter.features,
|
||||
order: this.mapSort(filter.orderBy),
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Query(() => Int, { description: 'Workspaces count for admin' })
|
||||
async adminWorkspacesCount(
|
||||
@Args('filter', { type: () => ListWorkspaceInput })
|
||||
filter: ListWorkspaceInput
|
||||
) {
|
||||
const { total } = await this.models.workspace.adminListWorkspaces({
|
||||
...filter,
|
||||
first: 1,
|
||||
skip: 0,
|
||||
order: this.mapSort(filter.orderBy),
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
@Query(() => AdminWorkspace, {
|
||||
description: 'Get workspace detail for admin',
|
||||
nullable: true,
|
||||
})
|
||||
async adminWorkspace(@Args('id') id: string) {
|
||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
||||
first: 1,
|
||||
skip: 0,
|
||||
keyword: id,
|
||||
order: 'createdAt',
|
||||
});
|
||||
const row = rows.find(r => r.id === id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@ResolveField(() => [AdminWorkspaceMember], {
|
||||
description: 'Members of workspace',
|
||||
})
|
||||
async members(
|
||||
@Parent() workspace: AdminWorkspace,
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip: number | null,
|
||||
@Args('take', { type: () => Int, nullable: true }) take: number | null,
|
||||
@Args('query', { type: () => String, nullable: true }) query: string | null
|
||||
): Promise<AdminWorkspaceMember[]> {
|
||||
const workspaceId = workspace.id;
|
||||
const pagination = {
|
||||
offset: skip ?? 0,
|
||||
first: take ?? 20,
|
||||
after: undefined,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
const list = await this.models.workspaceUser.search(
|
||||
workspaceId,
|
||||
query,
|
||||
pagination
|
||||
);
|
||||
return list.map(({ user, status, type }) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
const [list] = await this.models.workspaceUser.paginate(
|
||||
workspaceId,
|
||||
pagination
|
||||
);
|
||||
return list.map(({ user, status, type }) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: type,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
@Mutation(() => AdminWorkspace, {
|
||||
description: 'Update workspace flags and features for admin',
|
||||
nullable: true,
|
||||
})
|
||||
async adminUpdateWorkspace(
|
||||
@Args('input', { type: () => AdminUpdateWorkspaceInput })
|
||||
input: AdminUpdateWorkspaceInput
|
||||
) {
|
||||
const { id, features, ...updates } = input;
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
await this.models.workspace.update(id, updates);
|
||||
}
|
||||
|
||||
if (features) {
|
||||
const current = await this.models.workspaceFeature.list(id);
|
||||
const toAdd = features.filter(feature => !current.includes(feature));
|
||||
const toRemove = current.filter(feature => !features.includes(feature));
|
||||
|
||||
await Promise.all([
|
||||
...toAdd.map(feature =>
|
||||
this.models.workspaceFeature.add(id, feature, 'admin panel update')
|
||||
),
|
||||
...toRemove.map(feature =>
|
||||
this.models.workspaceFeature.remove(id, feature)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const { rows } = await this.models.workspace.adminListWorkspaces({
|
||||
first: 1,
|
||||
skip: 0,
|
||||
keyword: id,
|
||||
order: 'createdAt',
|
||||
});
|
||||
const row = rows.find(r => r.id === id);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private mapSort(orderBy?: AdminWorkspaceSort) {
|
||||
switch (orderBy) {
|
||||
case AdminWorkspaceSort.SnapshotSize:
|
||||
return 'snapshotSize';
|
||||
case AdminWorkspaceSort.BlobCount:
|
||||
return 'blobCount';
|
||||
case AdminWorkspaceSort.BlobSize:
|
||||
return 'blobSize';
|
||||
case AdminWorkspaceSort.CreatedAt:
|
||||
default:
|
||||
return 'createdAt';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './admin';
|
||||
export * from './blob';
|
||||
export * from './doc';
|
||||
export * from './history';
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { FeatureModel } from '../../models';
|
||||
|
||||
export class RefreshFeatures0001 {
|
||||
static always = true;
|
||||
|
||||
// do the migration
|
||||
static async up(_db: PrismaClient, ref: ModuleRef) {
|
||||
await ref.get(FeatureModel, { strict: false }).refreshFeatures();
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { FeatureConfigs, FeatureName, FeatureType } from '../../models';
|
||||
|
||||
export class FeatureRedundant1738590347632 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
const features = await db.feature.findMany();
|
||||
const validFeatures = new Map<
|
||||
number,
|
||||
{
|
||||
name: string;
|
||||
type: FeatureType;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const feature of features) {
|
||||
const def = FeatureConfigs[feature.name as FeatureName];
|
||||
if (!def || def.deprecatedVersion !== feature.deprecatedVersion) {
|
||||
await db.feature.delete({
|
||||
where: { id: feature.id },
|
||||
});
|
||||
} else {
|
||||
validFeatures.set(feature.id, {
|
||||
name: feature.name,
|
||||
type: def.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, def] of validFeatures.entries()) {
|
||||
await db.userFeature.updateMany({
|
||||
where: {
|
||||
featureId: id,
|
||||
},
|
||||
data: {
|
||||
name: def.name,
|
||||
type: def.type,
|
||||
},
|
||||
});
|
||||
await db.workspaceFeature.updateMany({
|
||||
where: {
|
||||
featureId: id,
|
||||
},
|
||||
data: {
|
||||
name: def.name,
|
||||
type: def.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
export * from './0001-refresh-features';
|
||||
export * from './1698398506533-guid';
|
||||
export * from './1703756315970-unamed-account';
|
||||
export * from './1721299086340-refresh-unnamed-user';
|
||||
export * from './1732861452428-migrate-invite-status';
|
||||
export * from './1733125339942-universal-subscription';
|
||||
export * from './1738590347632-feature-redundant';
|
||||
export * from './1745211351719-create-indexer-tables';
|
||||
export * from './1751966744168-correct-session-update-time';
|
||||
|
||||
@@ -57,7 +57,7 @@ export enum Feature {
|
||||
|
||||
// TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`?
|
||||
export const FeaturesShapes = {
|
||||
early_access: z.object({ whitelist: z.array(z.string()) }),
|
||||
early_access: z.object({ whitelist: z.array(z.string()).readonly() }),
|
||||
unlimited_workspace: EMPTY_CONFIG,
|
||||
unlimited_copilot: EMPTY_CONFIG,
|
||||
ai_early_access: EMPTY_CONFIG,
|
||||
@@ -88,86 +88,81 @@ export type FeatureConfig<T extends FeatureName> = z.infer<
|
||||
(typeof FeaturesShapes)[T]
|
||||
>;
|
||||
|
||||
const FreeFeature = {
|
||||
type: FeatureType.Quota,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
blobLimit: 10 * OneMB,
|
||||
businessBlobLimit: 100 * OneMB,
|
||||
storageQuota: 10 * OneGB,
|
||||
historyPeriod: 7 * OneDay,
|
||||
memberLimit: 3,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ProFeature = {
|
||||
type: FeatureType.Quota,
|
||||
configs: {
|
||||
name: 'Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const LifetimeProFeature = {
|
||||
type: FeatureType.Quota,
|
||||
configs: {
|
||||
name: 'Lifetime Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 1024 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const TeamFeature = {
|
||||
type: FeatureType.Quota,
|
||||
configs: {
|
||||
name: 'Team Workspace',
|
||||
blobLimit: 500 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
seatQuota: 20 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
memberLimit: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const WhitelistFeature = {
|
||||
type: FeatureType.Feature,
|
||||
configs: { whitelist: [] },
|
||||
} as const;
|
||||
|
||||
const EmptyFeature = {
|
||||
type: FeatureType.Feature,
|
||||
configs: {},
|
||||
} as const;
|
||||
|
||||
export const FeatureConfigs: {
|
||||
[K in FeatureName]: {
|
||||
type: FeatureType;
|
||||
configs: FeatureConfig<K>;
|
||||
deprecatedVersion: number;
|
||||
};
|
||||
} = {
|
||||
free_plan_v1: {
|
||||
type: FeatureType.Quota,
|
||||
deprecatedVersion: 4,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
blobLimit: 10 * OneMB,
|
||||
businessBlobLimit: 100 * OneMB,
|
||||
storageQuota: 10 * OneGB,
|
||||
historyPeriod: 7 * OneDay,
|
||||
memberLimit: 3,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
pro_plan_v1: {
|
||||
type: FeatureType.Quota,
|
||||
deprecatedVersion: 2,
|
||||
configs: {
|
||||
name: 'Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
lifetime_pro_plan_v1: {
|
||||
type: FeatureType.Quota,
|
||||
deprecatedVersion: 1,
|
||||
configs: {
|
||||
name: 'Lifetime Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 1024 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
team_plan_v1: {
|
||||
type: FeatureType.Quota,
|
||||
deprecatedVersion: 1,
|
||||
configs: {
|
||||
name: 'Team Workspace',
|
||||
blobLimit: 500 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
seatQuota: 20 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
memberLimit: 1,
|
||||
},
|
||||
},
|
||||
early_access: {
|
||||
type: FeatureType.Feature,
|
||||
deprecatedVersion: 2,
|
||||
configs: { whitelist: [] },
|
||||
},
|
||||
unlimited_workspace: {
|
||||
type: FeatureType.Feature,
|
||||
deprecatedVersion: 1,
|
||||
configs: {},
|
||||
},
|
||||
unlimited_copilot: {
|
||||
type: FeatureType.Feature,
|
||||
deprecatedVersion: 1,
|
||||
configs: {},
|
||||
},
|
||||
ai_early_access: {
|
||||
type: FeatureType.Feature,
|
||||
deprecatedVersion: 1,
|
||||
configs: {},
|
||||
},
|
||||
administrator: {
|
||||
type: FeatureType.Feature,
|
||||
deprecatedVersion: 1,
|
||||
configs: {},
|
||||
get free_plan_v1() {
|
||||
return env.selfhosted ? ProFeature : FreeFeature;
|
||||
},
|
||||
pro_plan_v1: ProFeature,
|
||||
lifetime_pro_plan_v1: LifetimeProFeature,
|
||||
team_plan_v1: TeamFeature,
|
||||
early_access: WhitelistFeature,
|
||||
unlimited_workspace: EmptyFeature,
|
||||
unlimited_copilot: EmptyFeature,
|
||||
ai_early_access: EmptyFeature,
|
||||
administrator: EmptyFeature,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
import { Feature } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
@@ -12,12 +10,6 @@ import {
|
||||
FeatureType,
|
||||
} from './common';
|
||||
|
||||
// TODO(@forehalo):
|
||||
// `version` column in `features` table will deprecated because it's makes the whole system complicated without any benefits.
|
||||
// It was brought to introduce a version control for features, but the version controlling is not and will not actually needed.
|
||||
// It even makes things harder when a new version of an existing feature is released.
|
||||
// We have to manually update all the users and workspaces binding to the latest version, which are thousands of handreds.
|
||||
// This is a huge burden for us and we should remove it.
|
||||
@Injectable()
|
||||
export class FeatureModel extends BaseModel {
|
||||
async get<T extends FeatureName>(name: T) {
|
||||
@@ -30,31 +22,32 @@ export class FeatureModel extends BaseModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest feature from database.
|
||||
* Get the latest feature from code definitions.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
async try_get_unchecked<T extends FeatureName>(name: T) {
|
||||
const feature = await this.db.feature.findFirst({
|
||||
where: { name },
|
||||
});
|
||||
const config = FeatureConfigs[name];
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return feature as Omit<Feature, 'configs'> & {
|
||||
configs: Record<string, any>;
|
||||
return {
|
||||
name,
|
||||
configs: config.configs,
|
||||
type: config.type,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest feature from database.
|
||||
* Get the latest feature from code definitions.
|
||||
*
|
||||
* @throws {Error} If the feature is not found in DB.
|
||||
* @throws {Error} If the feature is not found in code.
|
||||
* @internal
|
||||
*/
|
||||
async get_unchecked<T extends FeatureName>(name: T) {
|
||||
const feature = await this.try_get_unchecked(name);
|
||||
|
||||
// All features are hardcoded in the codebase
|
||||
// It would be a fatal error if the feature is not found in DB.
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${name} not found`);
|
||||
}
|
||||
@@ -82,67 +75,4 @@ export class FeatureModel extends BaseModel {
|
||||
getFeatureType(name: FeatureName): FeatureType {
|
||||
return FeatureConfigs[name].type;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
private async upsert<T extends FeatureName>(
|
||||
name: T,
|
||||
configs: FeatureConfig<T>,
|
||||
deprecatedType: FeatureType,
|
||||
deprecatedVersion: number
|
||||
) {
|
||||
const parsedConfigs = this.check(name, configs);
|
||||
|
||||
// TODO(@forehalo):
|
||||
// could be a simple upsert operation, but we got useless `version` column in the database
|
||||
// will be fixed when `version` column gets deprecated
|
||||
const latest = await this.db.feature.findFirst({
|
||||
where: {
|
||||
name,
|
||||
},
|
||||
orderBy: {
|
||||
deprecatedVersion: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
let feature: Feature;
|
||||
if (!latest) {
|
||||
feature = await this.db.feature.create({
|
||||
data: {
|
||||
name,
|
||||
deprecatedType,
|
||||
deprecatedVersion,
|
||||
configs: parsedConfigs,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
feature = await this.db.feature.update({
|
||||
where: { id: latest.id },
|
||||
data: {
|
||||
configs: parsedConfigs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.verbose(`Feature ${name} upserted`);
|
||||
|
||||
return feature as Feature & { configs: FeatureConfig<T> };
|
||||
}
|
||||
|
||||
async refreshFeatures() {
|
||||
for (const key in FeatureConfigs) {
|
||||
const name = key as FeatureName;
|
||||
const def = FeatureConfigs[name];
|
||||
// self-hosted instance will use pro plan as free plan
|
||||
if (name === 'free_plan_v1' && env.selfhosted) {
|
||||
await this.upsert(
|
||||
name,
|
||||
FeatureConfigs['pro_plan_v1'].configs,
|
||||
def.type,
|
||||
def.deprecatedVersion
|
||||
);
|
||||
} else {
|
||||
await this.upsert(name, def.configs, def.type, def.deprecatedVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@ export class UserFeatureModel extends BaseModel {
|
||||
}
|
||||
|
||||
async add(userId: string, name: UserFeatureName, reason: string) {
|
||||
const feature = await this.models.feature.get_unchecked(name);
|
||||
// ensure feature exists
|
||||
await this.models.feature.get_unchecked(name);
|
||||
const existing = await this.db.userFeature.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
@@ -93,7 +94,6 @@ export class UserFeatureModel extends BaseModel {
|
||||
const userFeature = await this.db.userFeature.create({
|
||||
data: {
|
||||
userId,
|
||||
featureId: feature.id,
|
||||
name,
|
||||
type: this.models.feature.getFeatureType(name),
|
||||
activated: true,
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
WrongSignInMethod,
|
||||
} from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import { publicUserSelect, WorkspaceRole, workspaceUserSelect } from './common';
|
||||
import {
|
||||
publicUserSelect,
|
||||
type UserFeatureName,
|
||||
WorkspaceRole,
|
||||
workspaceUserSelect,
|
||||
} from './common';
|
||||
import type { Workspace } from './workspace';
|
||||
|
||||
type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string };
|
||||
@@ -313,23 +318,78 @@ export class UserModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async pagination(skip: number = 0, take: number = 20, after?: Date) {
|
||||
return this.db.user.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gt: after,
|
||||
private buildListWhere(options: {
|
||||
keyword?: string | null;
|
||||
features?: UserFeatureName[] | null;
|
||||
after?: Date;
|
||||
}): Prisma.UserWhereInput {
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
|
||||
if (options.after) {
|
||||
where.createdAt = {
|
||||
gt: options.after,
|
||||
};
|
||||
}
|
||||
|
||||
const keyword = options.keyword?.trim();
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{
|
||||
email: {
|
||||
contains: keyword,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: {
|
||||
contains: keyword,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (options.features?.length) {
|
||||
where.features = {
|
||||
some: {
|
||||
name: {
|
||||
in: options.features,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
async list(options: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
keyword?: string | null;
|
||||
features?: UserFeatureName[] | null;
|
||||
after?: Date;
|
||||
}) {
|
||||
const where = this.buildListWhere(options);
|
||||
|
||||
return this.db.user.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
skip: options.skip,
|
||||
take: options.take,
|
||||
});
|
||||
}
|
||||
|
||||
async count() {
|
||||
return this.db.user.count();
|
||||
async count(
|
||||
options: {
|
||||
keyword?: string | null;
|
||||
features?: UserFeatureName[] | null;
|
||||
after?: Date;
|
||||
} = {}
|
||||
) {
|
||||
const where = this.buildListWhere(options);
|
||||
return this.db.user.count({ where });
|
||||
}
|
||||
|
||||
// #region ConnectedAccount
|
||||
|
||||
@@ -133,7 +133,8 @@ export class WorkspaceFeatureModel extends BaseModel {
|
||||
reason: string,
|
||||
overrides?: Partial<FeatureConfig<T>>
|
||||
) {
|
||||
const feature = await this.models.feature.get_unchecked(name);
|
||||
// ensure feature exists
|
||||
await this.models.feature.get_unchecked(name);
|
||||
|
||||
const existing = await this.db.workspaceFeature.findFirst({
|
||||
where: {
|
||||
@@ -178,7 +179,6 @@ export class WorkspaceFeatureModel extends BaseModel {
|
||||
workspaceFeature = await this.db.workspaceFeature.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
featureId: feature.id,
|
||||
name,
|
||||
type: this.models.feature.getFeatureType(name),
|
||||
activated: true,
|
||||
|
||||
@@ -1,9 +1,58 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Transactional } from '@nestjs-cls/transactional';
|
||||
import { Prisma, type Workspace } from '@prisma/client';
|
||||
import { Prisma, type Workspace, WorkspaceMemberStatus } from '@prisma/client';
|
||||
|
||||
import { EventBus } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import type { WorkspaceFeatureName } from './common';
|
||||
import { WorkspaceRole } from './common/role';
|
||||
|
||||
type RawWorkspaceSummary = {
|
||||
id: string;
|
||||
public: boolean;
|
||||
createdAt: Date;
|
||||
name: string | null;
|
||||
avatarKey: string | null;
|
||||
enableAi: boolean;
|
||||
enableUrlPreview: boolean;
|
||||
enableDocEmbedding: boolean;
|
||||
memberCount: bigint | number | null;
|
||||
publicPageCount: bigint | number | null;
|
||||
snapshotCount: bigint | number | null;
|
||||
snapshotSize: bigint | number | null;
|
||||
blobCount: bigint | number | null;
|
||||
blobSize: bigint | number | null;
|
||||
features: WorkspaceFeatureName[] | null;
|
||||
ownerId: string | null;
|
||||
ownerName: string | null;
|
||||
ownerEmail: string | null;
|
||||
ownerAvatarUrl: string | null;
|
||||
total: bigint | number;
|
||||
};
|
||||
|
||||
export type AdminWorkspaceSummary = {
|
||||
id: string;
|
||||
public: boolean;
|
||||
createdAt: Date;
|
||||
name: string | null;
|
||||
avatarKey: string | null;
|
||||
enableAi: boolean;
|
||||
enableUrlPreview: boolean;
|
||||
enableDocEmbedding: boolean;
|
||||
memberCount: number;
|
||||
publicPageCount: number;
|
||||
snapshotCount: number;
|
||||
snapshotSize: number;
|
||||
blobCount: number;
|
||||
blobSize: number;
|
||||
features: WorkspaceFeatureName[];
|
||||
owner: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
@@ -130,4 +179,154 @@ export class WorkspaceModel extends BaseModel {
|
||||
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region admin
|
||||
async adminListWorkspaces(options: {
|
||||
skip: number;
|
||||
first: number;
|
||||
keyword?: string | null;
|
||||
features?: WorkspaceFeatureName[] | null;
|
||||
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize';
|
||||
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
|
||||
const keyword = options.keyword?.trim();
|
||||
const features = options.features ?? [];
|
||||
const order = this.buildAdminOrder(options.order);
|
||||
|
||||
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||
WITH feature_set AS (
|
||||
SELECT workspace_id, array_agg(DISTINCT name) FILTER (WHERE activated) AS features
|
||||
FROM workspace_features
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
owner AS (
|
||||
SELECT wur.workspace_id,
|
||||
u.id AS owner_id,
|
||||
u.name AS owner_name,
|
||||
u.email AS owner_email,
|
||||
u.avatar_url AS owner_avatar_url
|
||||
FROM workspace_user_permissions AS wur
|
||||
JOIN users u ON wur.user_id = u.id
|
||||
WHERE wur.type = ${WorkspaceRole.Owner}
|
||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
||||
),
|
||||
snapshot_stats AS (
|
||||
SELECT workspace_id,
|
||||
SUM(octet_length(blob)) AS snapshot_size,
|
||||
COUNT(*) AS snapshot_count
|
||||
FROM snapshots
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
blob_stats AS (
|
||||
SELECT workspace_id,
|
||||
SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size,
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count
|
||||
FROM blobs
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
member_stats AS (
|
||||
SELECT workspace_id, COUNT(*) AS member_count
|
||||
FROM workspace_user_permissions
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
public_pages AS (
|
||||
SELECT workspace_id, COUNT(*) AS public_page_count
|
||||
FROM workspace_pages
|
||||
WHERE public = true
|
||||
GROUP BY workspace_id
|
||||
)
|
||||
SELECT w.id,
|
||||
w.public,
|
||||
w.created_at AS "createdAt",
|
||||
w.name,
|
||||
w.avatar_key AS "avatarKey",
|
||||
w.enable_ai AS "enableAi",
|
||||
w.enable_url_preview AS "enableUrlPreview",
|
||||
w.enable_doc_embedding AS "enableDocEmbedding",
|
||||
COALESCE(ms.member_count, 0) AS "memberCount",
|
||||
COALESCE(pp.public_page_count, 0) AS "publicPageCount",
|
||||
COALESCE(ss.snapshot_count, 0) AS "snapshotCount",
|
||||
COALESCE(ss.snapshot_size, 0) AS "snapshotSize",
|
||||
COALESCE(bs.blob_count, 0) AS "blobCount",
|
||||
COALESCE(bs.blob_size, 0) AS "blobSize",
|
||||
COALESCE(fs.features, ARRAY[]::text[]) AS features,
|
||||
o.owner_id AS "ownerId",
|
||||
o.owner_name AS "ownerName",
|
||||
o.owner_email AS "ownerEmail",
|
||||
o.owner_avatar_url AS "ownerAvatarUrl",
|
||||
COUNT(*) OVER() AS total
|
||||
FROM workspaces w
|
||||
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
|
||||
LEFT JOIN owner o ON o.workspace_id = w.id
|
||||
LEFT JOIN snapshot_stats ss ON ss.workspace_id = w.id
|
||||
LEFT JOIN blob_stats bs ON bs.workspace_id = w.id
|
||||
LEFT JOIN member_stats ms ON ms.workspace_id = w.id
|
||||
LEFT JOIN public_pages pp ON pp.workspace_id = w.id
|
||||
WHERE ${
|
||||
keyword
|
||||
? Prisma.sql`
|
||||
(
|
||||
w.id ILIKE ${'%' + keyword + '%'}
|
||||
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
||||
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
||||
)
|
||||
`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
AND ${
|
||||
features.length
|
||||
? Prisma.sql`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
ORDER BY ${Prisma.raw(order)}
|
||||
LIMIT ${options.first}
|
||||
OFFSET ${options.skip}
|
||||
`;
|
||||
|
||||
const total = rows.at(0)?.total ? Number(rows[0].total) : 0;
|
||||
|
||||
const mapped = rows.map(row => ({
|
||||
id: row.id,
|
||||
public: row.public,
|
||||
createdAt: row.createdAt,
|
||||
name: row.name,
|
||||
avatarKey: row.avatarKey,
|
||||
enableAi: row.enableAi,
|
||||
enableUrlPreview: row.enableUrlPreview,
|
||||
enableDocEmbedding: row.enableDocEmbedding,
|
||||
memberCount: Number(row.memberCount ?? 0),
|
||||
publicPageCount: Number(row.publicPageCount ?? 0),
|
||||
snapshotCount: Number(row.snapshotCount ?? 0),
|
||||
snapshotSize: Number(row.snapshotSize ?? 0),
|
||||
blobCount: Number(row.blobCount ?? 0),
|
||||
blobSize: Number(row.blobSize ?? 0),
|
||||
features: (row.features ?? []) as WorkspaceFeatureName[],
|
||||
owner: row.ownerId
|
||||
? {
|
||||
id: row.ownerId,
|
||||
name: row.ownerName ?? '',
|
||||
email: row.ownerEmail ?? '',
|
||||
avatarUrl: row.ownerAvatarUrl,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
return { rows: mapped, total };
|
||||
}
|
||||
|
||||
private buildAdminOrder(
|
||||
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize'
|
||||
) {
|
||||
switch (order) {
|
||||
case 'snapshotSize':
|
||||
return `"snapshotSize" DESC NULLS LAST`;
|
||||
case 'blobCount':
|
||||
return `"blobCount" DESC NULLS LAST`;
|
||||
case 'blobSize':
|
||||
return `"blobSize" DESC NULLS LAST`;
|
||||
case 'createdAt':
|
||||
default:
|
||||
return `"createdAt" DESC`;
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
|
||||
@@ -31,6 +31,55 @@ input AddContextFileInput {
|
||||
contextId: String!
|
||||
}
|
||||
|
||||
input AdminUpdateWorkspaceInput {
|
||||
avatarKey: String
|
||||
enableAi: Boolean
|
||||
enableDocEmbedding: Boolean
|
||||
enableUrlPreview: Boolean
|
||||
features: [FeatureType!]
|
||||
id: String!
|
||||
name: String
|
||||
public: Boolean
|
||||
}
|
||||
|
||||
type AdminWorkspace {
|
||||
avatarKey: String
|
||||
blobCount: Int!
|
||||
blobSize: SafeInt!
|
||||
createdAt: DateTime!
|
||||
enableAi: Boolean!
|
||||
enableDocEmbedding: Boolean!
|
||||
enableUrlPreview: Boolean!
|
||||
features: [FeatureType!]!
|
||||
id: String!
|
||||
memberCount: Int!
|
||||
|
||||
"""Members of workspace"""
|
||||
members(query: String, skip: Int, take: Int): [AdminWorkspaceMember!]!
|
||||
name: String
|
||||
owner: WorkspaceUserType
|
||||
public: Boolean!
|
||||
publicPageCount: Int!
|
||||
snapshotCount: Int!
|
||||
snapshotSize: SafeInt!
|
||||
}
|
||||
|
||||
type AdminWorkspaceMember {
|
||||
avatarUrl: String
|
||||
email: String!
|
||||
id: String!
|
||||
name: String!
|
||||
role: Permission!
|
||||
status: WorkspaceMemberStatus!
|
||||
}
|
||||
|
||||
enum AdminWorkspaceSort {
|
||||
BlobCount
|
||||
BlobSize
|
||||
CreatedAt
|
||||
SnapshotSize
|
||||
}
|
||||
|
||||
type AggregateBucketHitsObjectType {
|
||||
nodes: [SearchNodeObjectType!]!
|
||||
}
|
||||
@@ -740,7 +789,6 @@ enum ErrorNames {
|
||||
DOC_IS_NOT_PUBLIC
|
||||
DOC_NOT_FOUND
|
||||
DOC_UPDATE_BLOCKED
|
||||
EARLY_ACCESS_REQUIRED
|
||||
EMAIL_ALREADY_USED
|
||||
EMAIL_SERVICE_NOT_CONFIGURED
|
||||
EMAIL_TOKEN_NOT_FOUND
|
||||
@@ -1161,10 +1209,20 @@ type LimitedUserType {
|
||||
}
|
||||
|
||||
input ListUserInput {
|
||||
features: [FeatureType!]
|
||||
first: Int = 20
|
||||
keyword: String
|
||||
skip: Int = 0
|
||||
}
|
||||
|
||||
input ListWorkspaceInput {
|
||||
features: [FeatureType!]
|
||||
first: Int! = 20
|
||||
keyword: String
|
||||
orderBy: AdminWorkspaceSort
|
||||
skip: Int! = 0
|
||||
}
|
||||
|
||||
type ListedBlob {
|
||||
createdAt: String!
|
||||
key: String!
|
||||
@@ -1249,6 +1307,9 @@ type Mutation {
|
||||
"""Update workspace embedding files"""
|
||||
addWorkspaceEmbeddingFiles(blob: Upload!, workspaceId: String!): CopilotWorkspaceFile!
|
||||
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
|
||||
|
||||
"""Update workspace flags and features for admin"""
|
||||
adminUpdateWorkspace(input: AdminUpdateWorkspaceInput!): AdminWorkspace
|
||||
approveMember(userId: String!, workspaceId: String!): Boolean!
|
||||
|
||||
"""Ban an user"""
|
||||
@@ -1613,6 +1674,15 @@ type PublicUserType {
|
||||
type Query {
|
||||
accessTokens: [AccessToken!]!
|
||||
|
||||
"""Get workspace detail for admin"""
|
||||
adminWorkspace(id: String!): AdminWorkspace
|
||||
|
||||
"""List workspaces for admin"""
|
||||
adminWorkspaces(filter: ListWorkspaceInput!): [AdminWorkspace!]!
|
||||
|
||||
"""Workspaces count for admin"""
|
||||
adminWorkspacesCount(filter: ListWorkspaceInput!): Int!
|
||||
|
||||
"""get the whole app configuration"""
|
||||
appConfig: JSONObject!
|
||||
|
||||
@@ -1660,7 +1730,7 @@ type Query {
|
||||
users(filter: ListUserInput!): [UserType!]!
|
||||
|
||||
"""Get users count"""
|
||||
usersCount: Int!
|
||||
usersCount(filter: ListUserInput): Int!
|
||||
|
||||
"""Get workspace by id"""
|
||||
workspace(id: String!): WorkspaceType!
|
||||
@@ -1884,15 +1954,15 @@ enum SearchTable {
|
||||
}
|
||||
|
||||
type ServerConfigType {
|
||||
"""Whether allow guest users to create demo workspaces."""
|
||||
allowGuestDemoWorkspace: Boolean! @deprecated(reason: "This field is deprecated, please use `features` instead. Will be removed in 0.25.0")
|
||||
|
||||
"""fetch latest available upgradable release of server"""
|
||||
availableUpgrade: ReleaseVersionType
|
||||
|
||||
"""Features for user that can be configured"""
|
||||
availableUserFeatures: [FeatureType!]!
|
||||
|
||||
"""Workspace features available for admin configuration"""
|
||||
availableWorkspaceFeatures: [FeatureType!]!
|
||||
|
||||
"""server base url"""
|
||||
baseUrl: String!
|
||||
|
||||
|
||||
Reference in New Issue
Block a user