diff --git a/packages/backend/server/migrations/20251231172409_workspace_index/migration.sql b/packages/backend/server/migrations/20251231172409_workspace_index/migration.sql new file mode 100644 index 0000000000..ab06977a73 --- /dev/null +++ b/packages/backend/server/migrations/20251231172409_workspace_index/migration.sql @@ -0,0 +1,11 @@ +-- CreateIndex +CREATE INDEX "blobs_workspace_id_status_deleted_at_idx" ON "blobs"("workspace_id", "status", "deleted_at"); + +-- CreateIndex +CREATE INDEX "workspace_pages_workspace_id_public_idx" ON "workspace_pages"("workspace_id", "public"); + +-- CreateIndex +CREATE INDEX "workspace_user_permissions_workspace_id_type_status_idx" ON "workspace_user_permissions"("workspace_id", "type", "status"); + +-- CreateIndex +CREATE INDEX "workspaces_created_at_idx" ON "workspaces"("created_at"); \ No newline at end of file diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 4b0c4e8e01..abb31c7778 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -135,6 +135,7 @@ model Workspace { commentAttachments CommentAttachment[] @@index([lastCheckEmbeddings]) + @@index([createdAt]) @@map("workspaces") } @@ -159,6 +160,7 @@ model WorkspaceDoc { workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@id([workspaceId, docId]) + @@index([workspaceId, public]) @@map("workspace_pages") } @@ -206,6 +208,7 @@ model WorkspaceUserRole { @@unique([workspaceId, userId]) // optimize for querying user's workspace permissions + @@index([workspaceId, type, status]) @@index(userId) @@map("workspace_user_permissions") } @@ -832,6 +835,7 @@ model Blob { AiWorkspaceBlobEmbedding AiWorkspaceBlobEmbedding[] @@id([workspaceId, key]) + @@index([workspaceId, status, deletedAt]) @@map("blobs") } diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index 370cbce924..2273f83af5 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -165,6 +165,7 @@ export class AdminWorkspaceResolver { keyword: filter.keyword, features: filter.features, order: this.mapSort(filter.orderBy), + includeTotal: false, }); return rows; } @@ -174,11 +175,9 @@ export class AdminWorkspaceResolver { @Args('filter', { type: () => ListWorkspaceInput }) filter: ListWorkspaceInput ) { - const { total } = await this.models.workspace.adminListWorkspaces({ - ...filter, - first: 1, - skip: 0, - order: this.mapSort(filter.orderBy), + const total = await this.models.workspace.adminCountWorkspaces({ + keyword: filter.keyword, + features: filter.features, }); return total; } @@ -193,6 +192,7 @@ export class AdminWorkspaceResolver { skip: 0, keyword: id, order: 'createdAt', + includeTotal: false, }); const row = rows.find(r => r.id === id); if (!row) { @@ -281,6 +281,7 @@ export class AdminWorkspaceResolver { skip: 0, keyword: id, order: 'createdAt', + includeTotal: false, }); const row = rows.find(r => r.id === id); if (!row) { diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index 639c976b5c..0a599a632b 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -27,7 +27,6 @@ type RawWorkspaceSummary = { ownerName: string | null; ownerEmail: string | null; ownerAvatarUrl: string | null; - total: bigint | number; }; export type AdminWorkspaceSummary = { @@ -187,102 +186,201 @@ export class WorkspaceModel extends BaseModel { keyword?: string | null; features?: WorkspaceFeatureName[] | null; order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize'; + includeTotal?: boolean; }): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> { const keyword = options.keyword?.trim(); const features = options.features ?? []; - const order = this.buildAdminOrder(options.order); + const includeTotal = options.includeTotal ?? true; + const total = includeTotal + ? await this.adminCountWorkspaces({ keyword, features }) + : 0; + if (includeTotal && total === 0) { + return { rows: [], total: 0 }; + } - const rows = await this.db.$queryRaw` - 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(COALESCE(size, 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 rows = + options.order === 'createdAt' || !options.order + ? await this.db.$queryRaw` + 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`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}` + : 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 + ` + : await this.db.$queryRaw` + 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`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}` + : 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} + `; const mapped = rows.map(row => ({ id: row.id, @@ -313,6 +411,53 @@ export class WorkspaceModel extends BaseModel { return { rows: mapped, total }; } + async adminCountWorkspaces(options: { + keyword?: string | null; + features?: WorkspaceFeatureName[] | null; + }) { + const keyword = options.keyword?.trim(); + const features = options.features ?? []; + + 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"`} + ) + 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`COALESCE(fs.features, ARRAY[]::text[]) @> ${features}` + : Prisma.sql`TRUE` + } + `; + + return row?.total ? Number(row.total) : 0; + } + private buildAdminOrder( order?: 'createdAt' | 'snapshotSize' | 'blobCount' | 'blobSize' ) {