feat: improve admin panel (#14180)

This commit is contained in:
DarkSky
2025-12-30 05:22:54 +08:00
committed by GitHub
parent d6b380aee5
commit 95a5e941e7
94 changed files with 3146 additions and 1114 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,3 +1,4 @@
export * from './admin';
export * from './blob';
export * from './doc';
export * from './history';

View File

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

View File

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

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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