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
@@ -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,
};
+11 -81
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);
}
}
}
}
@@ -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,
+71 -11
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
@@ -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,
+200 -1
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
}