mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 08:09:52 +08:00
feat: pre-aggregation workspace stats
This commit is contained in:
@@ -61,7 +61,8 @@ export type KnownMetricScopes =
|
||||
| 'event'
|
||||
| 'queue'
|
||||
| 'storage'
|
||||
| 'process';
|
||||
| 'process'
|
||||
| 'workspace';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from './resolvers';
|
||||
import { AdminWorkspaceResolver } from './resolvers/admin';
|
||||
import { WorkspaceService } from './service';
|
||||
import { WorkspaceStatsJob } from './stats.job';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -45,6 +46,7 @@ import { WorkspaceService } from './service';
|
||||
WorkspaceService,
|
||||
WorkspaceEvents,
|
||||
AdminWorkspaceResolver,
|
||||
WorkspaceStatsJob,
|
||||
],
|
||||
exports: [WorkspaceService],
|
||||
})
|
||||
|
||||
@@ -31,6 +31,9 @@ enum AdminWorkspaceSort {
|
||||
SnapshotSize = 'SnapshotSize',
|
||||
BlobCount = 'BlobCount',
|
||||
BlobSize = 'BlobSize',
|
||||
SnapshotCount = 'SnapshotCount',
|
||||
MemberCount = 'MemberCount',
|
||||
PublicPageCount = 'PublicPageCount',
|
||||
}
|
||||
|
||||
registerEnumType(AdminWorkspaceSort, {
|
||||
@@ -298,6 +301,12 @@ export class AdminWorkspaceResolver {
|
||||
return 'blobCount';
|
||||
case AdminWorkspaceSort.BlobSize:
|
||||
return 'blobSize';
|
||||
case AdminWorkspaceSort.SnapshotCount:
|
||||
return 'snapshotCount';
|
||||
case AdminWorkspaceSort.MemberCount:
|
||||
return 'memberCount';
|
||||
case AdminWorkspaceSort.PublicPageCount:
|
||||
return 'publicPageCount';
|
||||
case AdminWorkspaceSort.CreatedAt:
|
||||
default:
|
||||
return 'createdAt';
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { metrics } from '../../base';
|
||||
|
||||
const LOCK_NAMESPACE = 97_301;
|
||||
const LOCK_KEY = 1;
|
||||
const DIRTY_BATCH_SIZE = 500;
|
||||
const FULL_REFRESH_BATCH_SIZE = 2000;
|
||||
const TRANSACTION_TIMEOUT_MS = 120_000;
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceStatsJob {
|
||||
private readonly logger = new Logger(WorkspaceStatsJob.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async refreshDirty() {
|
||||
const started = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.withAdvisoryLock(async tx => {
|
||||
const backlog = await this.countDirty(tx);
|
||||
metrics.workspace
|
||||
.gauge('admin_stats_dirty_backlog')
|
||||
.record(Number(backlog));
|
||||
|
||||
const dirty = await this.loadDirty(tx, DIRTY_BATCH_SIZE);
|
||||
if (!dirty.length) {
|
||||
return { processed: 0, backlog };
|
||||
}
|
||||
|
||||
await this.upsertStats(tx, dirty);
|
||||
await this.clearDirty(tx, dirty);
|
||||
return { processed: dirty.length, backlog };
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
this.logger.debug('skip admin stats refresh, lock not acquired');
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.workspace
|
||||
.histogram('admin_stats_refresh_duration_ms')
|
||||
.record(Date.now() - started, { mode: 'incremental' });
|
||||
|
||||
if (result.processed > 0) {
|
||||
this.logger.log(
|
||||
`Refreshed admin stats for ${result.processed} workspace(s); backlog ${result.backlog}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
metrics.workspace.counter('admin_stats_refresh_failed').add(1, {
|
||||
mode: 'incremental',
|
||||
});
|
||||
this.logger.error('Failed to refresh admin stats', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_1AM)
|
||||
async recalibrate() {
|
||||
let lastSid = 0;
|
||||
let processed = 0;
|
||||
|
||||
while (true) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await this.withAdvisoryLock(async tx => {
|
||||
const workspaces = await this.fetchWorkspaceBatch(
|
||||
tx,
|
||||
lastSid,
|
||||
FULL_REFRESH_BATCH_SIZE
|
||||
);
|
||||
if (!workspaces.length) {
|
||||
return { processed: 0, lastSid };
|
||||
}
|
||||
|
||||
const ids = workspaces.map(({ id }) => id);
|
||||
await this.upsertStats(tx, ids);
|
||||
|
||||
return {
|
||||
processed: ids.length,
|
||||
lastSid: workspaces[workspaces.length - 1].sid,
|
||||
};
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
this.logger.debug(
|
||||
'skip admin stats recalibration, lock not acquired'
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.processed === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
processed += result.processed;
|
||||
lastSid = result.lastSid;
|
||||
|
||||
metrics.workspace
|
||||
.histogram('admin_stats_refresh_duration_ms')
|
||||
.record(Date.now() - started, { mode: 'full' });
|
||||
|
||||
if (result.processed < FULL_REFRESH_BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
metrics.workspace.counter('admin_stats_refresh_failed').add(1, {
|
||||
mode: 'full',
|
||||
});
|
||||
this.logger.error(
|
||||
`Failed to recalibrate admin stats after sid ${lastSid}`,
|
||||
error as Error
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (processed > 0) {
|
||||
this.logger.verbose(
|
||||
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async withAdvisoryLock<T>(
|
||||
callback: (tx: Prisma.TransactionClient) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
return await this.prisma.$transaction(
|
||||
async tx => {
|
||||
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>`
|
||||
SELECT pg_try_advisory_lock(${LOCK_NAMESPACE}, ${LOCK_KEY}) AS locked
|
||||
`;
|
||||
|
||||
if (!lock?.locked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await callback(tx);
|
||||
} finally {
|
||||
await tx.$executeRaw`SELECT pg_advisory_unlock(${LOCK_NAMESPACE}, ${LOCK_KEY})`;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxWait: 5_000,
|
||||
timeout: TRANSACTION_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async loadDirty(
|
||||
tx: Prisma.TransactionClient,
|
||||
limit: number
|
||||
): Promise<string[]> {
|
||||
const rows = await tx.$queryRaw<{ workspace_id: string }[]>`
|
||||
SELECT workspace_id
|
||||
FROM workspace_admin_stats_dirty
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT ${limit}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`;
|
||||
|
||||
return rows.map(row => row.workspace_id);
|
||||
}
|
||||
|
||||
private async countDirty(tx: Prisma.TransactionClient) {
|
||||
const [row] = await tx.$queryRaw<{ total: bigint | number }[]>`
|
||||
SELECT COUNT(*) AS total FROM workspace_admin_stats_dirty
|
||||
`;
|
||||
return row?.total ? Number(row.total) : 0;
|
||||
}
|
||||
|
||||
private async clearDirty(
|
||||
tx: Prisma.TransactionClient,
|
||||
workspaceIds: string[]
|
||||
) {
|
||||
if (!workspaceIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM workspace_admin_stats_dirty
|
||||
WHERE workspace_id IN (${Prisma.join(
|
||||
workspaceIds.map(id => Prisma.sql`${id}`)
|
||||
)})
|
||||
`;
|
||||
}
|
||||
|
||||
private async upsertStats(
|
||||
tx: Prisma.TransactionClient,
|
||||
workspaceIds: string[]
|
||||
) {
|
||||
if (!workspaceIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIds = Prisma.join(workspaceIds.map(id => Prisma.sql`${id}`));
|
||||
|
||||
await tx.$executeRaw`
|
||||
WITH targets AS (
|
||||
SELECT UNNEST(ARRAY[${targetIds}]::varchar[]) AS workspace_id
|
||||
),
|
||||
snapshot_stats AS (
|
||||
SELECT workspace_id,
|
||||
COUNT(*) AS snapshot_count,
|
||||
COALESCE(SUM(COALESCE(size, octet_length(blob))), 0) AS snapshot_size
|
||||
FROM snapshots
|
||||
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
blob_stats AS (
|
||||
SELECT workspace_id,
|
||||
COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count,
|
||||
COALESCE(SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed'), 0) AS blob_size
|
||||
FROM blobs
|
||||
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
member_stats AS (
|
||||
SELECT workspace_id, COUNT(*) AS member_count
|
||||
FROM workspace_user_permissions
|
||||
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
public_page_stats AS (
|
||||
SELECT workspace_id, COUNT(*) AS public_page_count
|
||||
FROM workspace_pages
|
||||
WHERE public = TRUE AND workspace_id IN (SELECT workspace_id FROM targets)
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
feature_stats AS (
|
||||
SELECT workspace_id,
|
||||
ARRAY_AGG(DISTINCT name ORDER BY name) FILTER (WHERE activated) AS features
|
||||
FROM workspace_features
|
||||
WHERE workspace_id IN (SELECT workspace_id FROM targets)
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
aggregated AS (
|
||||
SELECT t.workspace_id,
|
||||
COALESCE(ss.snapshot_count, 0) AS snapshot_count,
|
||||
COALESCE(ss.snapshot_size, 0) AS snapshot_size,
|
||||
COALESCE(bs.blob_count, 0) AS blob_count,
|
||||
COALESCE(bs.blob_size, 0) AS blob_size,
|
||||
COALESCE(ms.member_count, 0) AS member_count,
|
||||
COALESCE(pp.public_page_count, 0) AS public_page_count,
|
||||
COALESCE(fs.features, ARRAY[]::text[]) AS features
|
||||
FROM targets t
|
||||
LEFT JOIN snapshot_stats ss ON ss.workspace_id = t.workspace_id
|
||||
LEFT JOIN blob_stats bs ON bs.workspace_id = t.workspace_id
|
||||
LEFT JOIN member_stats ms ON ms.workspace_id = t.workspace_id
|
||||
LEFT JOIN public_page_stats pp ON pp.workspace_id = t.workspace_id
|
||||
LEFT JOIN feature_stats fs ON fs.workspace_id = t.workspace_id
|
||||
)
|
||||
INSERT INTO workspace_admin_stats (
|
||||
workspace_id,
|
||||
snapshot_count,
|
||||
snapshot_size,
|
||||
blob_count,
|
||||
blob_size,
|
||||
member_count,
|
||||
public_page_count,
|
||||
features,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
workspace_id,
|
||||
snapshot_count,
|
||||
snapshot_size,
|
||||
blob_count,
|
||||
blob_size,
|
||||
member_count,
|
||||
public_page_count,
|
||||
features,
|
||||
NOW()
|
||||
FROM aggregated
|
||||
ON CONFLICT (workspace_id) DO UPDATE SET
|
||||
snapshot_count = EXCLUDED.snapshot_count,
|
||||
snapshot_size = EXCLUDED.snapshot_size,
|
||||
blob_count = EXCLUDED.blob_count,
|
||||
blob_size = EXCLUDED.blob_size,
|
||||
member_count = EXCLUDED.member_count,
|
||||
public_page_count = EXCLUDED.public_page_count,
|
||||
features = EXCLUDED.features,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
}
|
||||
|
||||
private async fetchWorkspaceBatch(
|
||||
tx: Prisma.TransactionClient,
|
||||
lastSid: number,
|
||||
limit: number
|
||||
) {
|
||||
return tx.$queryRaw<{ id: string; sid: number }[]>`
|
||||
SELECT id, sid
|
||||
FROM workspaces
|
||||
WHERE sid > ${lastSid}
|
||||
ORDER BY sid
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,14 @@ export class WorkspaceModel extends BaseModel {
|
||||
first: number;
|
||||
keyword?: string | null;
|
||||
features?: WorkspaceFeatureName[] | null;
|
||||
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize';
|
||||
order?:
|
||||
| 'createdAt'
|
||||
| 'snapshotSize'
|
||||
| 'blobCount'
|
||||
| 'blobSize'
|
||||
| 'snapshotCount'
|
||||
| 'memberCount'
|
||||
| 'publicPageCount';
|
||||
includeTotal?: boolean;
|
||||
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
|
||||
const keyword = options.keyword?.trim();
|
||||
@@ -198,189 +205,100 @@ export class WorkspaceModel extends BaseModel {
|
||||
return { rows: [], total: 0 };
|
||||
}
|
||||
|
||||
const rows =
|
||||
options.order === 'createdAt' || !options.order
|
||||
? 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"`}
|
||||
),
|
||||
filtered AS (
|
||||
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(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"
|
||||
FROM workspaces w
|
||||
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
|
||||
LEFT JOIN owner o ON o.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`CAST(COALESCE(fs.features, ARRAY[]::text[]) AS text[]) @> ${Prisma.sql`${features}::text[]`}`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
ORDER BY w.created_at DESC
|
||||
LIMIT ${options.first}
|
||||
OFFSET ${options.skip}
|
||||
)
|
||||
SELECT f.*,
|
||||
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"
|
||||
FROM filtered f
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS member_count
|
||||
FROM workspace_user_permissions
|
||||
WHERE workspace_id = f.id
|
||||
) ms ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS public_page_count
|
||||
FROM workspace_pages
|
||||
WHERE workspace_id = f.id AND public = true
|
||||
) pp ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS snapshot_count,
|
||||
SUM(size) AS snapshot_size
|
||||
FROM snapshots
|
||||
WHERE workspace_id = f.id
|
||||
) ss ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_count,
|
||||
SUM(size) FILTER (WHERE deleted_at IS NULL AND status = 'completed') AS blob_size
|
||||
FROM blobs
|
||||
WHERE workspace_id = f.id
|
||||
) bs ON TRUE
|
||||
ORDER BY f."createdAt" DESC
|
||||
const featuresHaving =
|
||||
features.length > 0
|
||||
? Prisma.sql`
|
||||
HAVING COUNT(
|
||||
DISTINCT CASE
|
||||
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
|
||||
END
|
||||
) = ${features.length}
|
||||
`
|
||||
: 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"`}
|
||||
),
|
||||
filtered AS (
|
||||
SELECT w.id,
|
||||
: Prisma.empty;
|
||||
|
||||
const featureJoin =
|
||||
features.length > 0
|
||||
? Prisma.sql`
|
||||
LEFT JOIN workspace_features wf
|
||||
ON wf.workspace_id = w.id AND wf.activated = TRUE
|
||||
`
|
||||
: Prisma.empty;
|
||||
|
||||
const groupAndHaving =
|
||||
features.length > 0
|
||||
? Prisma.sql`
|
||||
GROUP BY w.id,
|
||||
w.public,
|
||||
w.created_at AS "createdAt",
|
||||
w.created_at,
|
||||
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(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"
|
||||
FROM workspaces w
|
||||
LEFT JOIN feature_set fs ON fs.workspace_id = w.id
|
||||
LEFT JOIN owner o ON o.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`CAST(COALESCE(fs.features, ARRAY[]::text[]) AS text[]) @> ${Prisma.sql`${features}::text[]`}`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
),
|
||||
snapshot_stats AS (
|
||||
SELECT workspace_id,
|
||||
SUM(size) AS snapshot_size,
|
||||
COUNT(*) AS snapshot_count
|
||||
FROM snapshots
|
||||
WHERE workspace_id IN (SELECT id FROM filtered)
|
||||
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
|
||||
WHERE workspace_id IN (SELECT id FROM filtered)
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
member_stats AS (
|
||||
SELECT workspace_id, COUNT(*) AS member_count
|
||||
FROM workspace_user_permissions
|
||||
WHERE workspace_id IN (SELECT id FROM filtered)
|
||||
GROUP BY workspace_id
|
||||
),
|
||||
public_pages AS (
|
||||
SELECT workspace_id, COUNT(*) AS public_page_count
|
||||
FROM workspace_pages
|
||||
WHERE public = true AND workspace_id IN (SELECT id FROM filtered)
|
||||
GROUP BY workspace_id
|
||||
)
|
||||
SELECT f.*,
|
||||
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"
|
||||
FROM filtered f
|
||||
LEFT JOIN snapshot_stats ss ON ss.workspace_id = f.id
|
||||
LEFT JOIN blob_stats bs ON bs.workspace_id = f.id
|
||||
LEFT JOIN member_stats ms ON ms.workspace_id = f.id
|
||||
LEFT JOIN public_pages pp ON pp.workspace_id = f.id
|
||||
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
||||
LIMIT ${options.first}
|
||||
OFFSET ${options.skip}
|
||||
`;
|
||||
w.avatar_key,
|
||||
w.enable_ai,
|
||||
w.enable_url_preview,
|
||||
w.enable_doc_embedding,
|
||||
o.owner_id,
|
||||
o.owner_name,
|
||||
o.owner_email,
|
||||
o.owner_avatar_url
|
||||
${featuresHaving}
|
||||
`
|
||||
: Prisma.empty;
|
||||
|
||||
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
|
||||
WITH filtered AS (
|
||||
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",
|
||||
o.owner_id AS "ownerId",
|
||||
o.owner_name AS "ownerName",
|
||||
o.owner_email AS "ownerEmail",
|
||||
o.owner_avatar_url AS "ownerAvatarUrl"
|
||||
FROM workspaces w
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT 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.workspace_id = w.id
|
||||
AND wur.type = ${WorkspaceRole.Owner}
|
||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
||||
ORDER BY u.created_at ASC
|
||||
LIMIT 1
|
||||
) o ON TRUE
|
||||
${featureJoin}
|
||||
WHERE ${
|
||||
keyword
|
||||
? Prisma.sql`
|
||||
(
|
||||
w.id ILIKE ${'%' + keyword + '%'}
|
||||
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
||||
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
||||
)
|
||||
`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
${groupAndHaving}
|
||||
)
|
||||
SELECT f.*,
|
||||
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
|
||||
COALESCE(s.snapshot_size, 0) AS "snapshotSize",
|
||||
COALESCE(s.blob_count, 0) AS "blobCount",
|
||||
COALESCE(s.blob_size, 0) AS "blobSize",
|
||||
COALESCE(s.member_count, 0) AS "memberCount",
|
||||
COALESCE(s.public_page_count, 0) AS "publicPageCount",
|
||||
COALESCE(s.features, ARRAY[]::text[]) AS features
|
||||
FROM filtered f
|
||||
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
|
||||
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
|
||||
LIMIT ${options.first}
|
||||
OFFSET ${options.skip}
|
||||
`;
|
||||
|
||||
const mapped = rows.map(row => ({
|
||||
id: row.id,
|
||||
@@ -418,56 +336,94 @@ export class WorkspaceModel extends BaseModel {
|
||||
const keyword = options.keyword?.trim();
|
||||
const features = options.features ?? [];
|
||||
|
||||
const featuresHaving =
|
||||
features.length > 0
|
||||
? Prisma.sql`
|
||||
HAVING COUNT(
|
||||
DISTINCT CASE
|
||||
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
|
||||
END
|
||||
) = ${features.length}
|
||||
`
|
||||
: Prisma.empty;
|
||||
|
||||
const featureJoin =
|
||||
features.length > 0
|
||||
? Prisma.sql`
|
||||
LEFT JOIN workspace_features wf
|
||||
ON wf.workspace_id = w.id AND wf.activated = TRUE
|
||||
`
|
||||
: Prisma.empty;
|
||||
|
||||
const groupAndHaving =
|
||||
features.length > 0
|
||||
? Prisma.sql`
|
||||
GROUP BY w.id, o.owner_id, o.owner_email
|
||||
${featuresHaving}
|
||||
`
|
||||
: Prisma.empty;
|
||||
|
||||
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
|
||||
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.email AS owner_email
|
||||
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"`}
|
||||
WITH filtered AS (
|
||||
SELECT w.id,
|
||||
o.owner_id AS "ownerId",
|
||||
o.owner_email AS "ownerEmail"
|
||||
FROM workspaces w
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT wur.workspace_id,
|
||||
u.id AS owner_id,
|
||||
u.email AS owner_email
|
||||
FROM workspace_user_permissions AS wur
|
||||
JOIN users u ON wur.user_id = u.id
|
||||
WHERE wur.workspace_id = w.id
|
||||
AND wur.type = ${WorkspaceRole.Owner}
|
||||
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`}
|
||||
ORDER BY u.created_at ASC
|
||||
LIMIT 1
|
||||
) o ON TRUE
|
||||
${featureJoin}
|
||||
WHERE ${
|
||||
keyword
|
||||
? Prisma.sql`
|
||||
(
|
||||
w.id ILIKE ${'%' + keyword + '%'}
|
||||
OR o.owner_id ILIKE ${'%' + keyword + '%'}
|
||||
OR o.owner_email ILIKE ${'%' + keyword + '%'}
|
||||
)
|
||||
`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
${groupAndHaving}
|
||||
)
|
||||
SELECT COUNT(*) 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
|
||||
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`CAST(COALESCE(fs.features, ARRAY[]::text[]) AS text[]) @> ${Prisma.sql`${features}::text[]`}`
|
||||
: Prisma.sql`TRUE`
|
||||
}
|
||||
SELECT COUNT(*) AS total FROM filtered
|
||||
`;
|
||||
|
||||
return row?.total ? Number(row.total) : 0;
|
||||
}
|
||||
|
||||
private buildAdminOrder(
|
||||
order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize'
|
||||
order?:
|
||||
| 'createdAt'
|
||||
| 'snapshotSize'
|
||||
| 'blobCount'
|
||||
| 'blobSize'
|
||||
| 'snapshotCount'
|
||||
| 'memberCount'
|
||||
| 'publicPageCount'
|
||||
) {
|
||||
switch (order) {
|
||||
case 'snapshotSize':
|
||||
return `"snapshotSize" DESC NULLS LAST`;
|
||||
return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC`;
|
||||
case 'blobCount':
|
||||
return `"blobCount" DESC NULLS LAST`;
|
||||
return `"blobCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||
case 'blobSize':
|
||||
return `"blobSize" DESC NULLS LAST`;
|
||||
return `"blobSize" DESC NULLS LAST, "createdAt" DESC`;
|
||||
case 'snapshotCount':
|
||||
return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||
case 'memberCount':
|
||||
return `"memberCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||
case 'publicPageCount':
|
||||
return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC`;
|
||||
case 'createdAt':
|
||||
default:
|
||||
return `"createdAt" DESC`;
|
||||
|
||||
@@ -77,6 +77,9 @@ enum AdminWorkspaceSort {
|
||||
BlobCount
|
||||
BlobSize
|
||||
CreatedAt
|
||||
MemberCount
|
||||
PublicPageCount
|
||||
SnapshotCount
|
||||
SnapshotSize
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user