mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +08:00
feat: improve admin panel (#14180)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user