From fa4360fe69d9615010fe9bcab59878b5af0c16d6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 13:38:54 +0000 Subject: [PATCH] Implement artifact grouping API --- packages/shared/src/index.ts | 4 + packages/shared/src/types/artifact.ts | 16 ++ packages/shared/src/types/index.ts | 2 + .../shared/src/validators/artifact.test.ts | 86 ++++++ packages/shared/src/validators/artifact.ts | 22 ++ packages/shared/src/validators/index.ts | 2 + .../company-artifacts-service.test.ts | 186 ++++++++++++- server/src/services/company-artifacts.ts | 244 ++++++++++++++++-- 8 files changed, 538 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/validators/artifact.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1cd4d2974ee..2ed8c27acf4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -449,6 +449,8 @@ export type { IssueWorkProductReviewState, CompanyArtifact, CompanyArtifactAgentSummary, + CompanyArtifactGroup, + CompanyArtifactGroupBy, CompanyArtifactIssueSummary, CompanyArtifactMediaKind, CompanyArtifactProjectSummary, @@ -993,6 +995,8 @@ export { COMPANY_ARTIFACTS_DEFAULT_LIMIT, COMPANY_ARTIFACTS_MAX_LIMIT, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactGroupBySchema, + companyArtifactGroupSchema, companyArtifactMediaKindSchema, companyArtifactSchema, companyArtifactSourceSchema, diff --git a/packages/shared/src/types/artifact.ts b/packages/shared/src/types/artifact.ts index 413ed68f437..6d022d34a71 100644 --- a/packages/shared/src/types/artifact.ts +++ b/packages/shared/src/types/artifact.ts @@ -2,6 +2,8 @@ export type CompanyArtifactSource = "document" | "attachment" | "work_product"; export type CompanyArtifactMediaKind = "image" | "video" | "text" | "document" | "file" | "empty"; +export type CompanyArtifactGroupBy = "none" | "task" | "parent_task"; + export interface CompanyArtifactIssueSummary { id: string; identifier: string; @@ -35,7 +37,21 @@ export interface CompanyArtifact { href: string; } +export interface CompanyArtifactGroup { + id: string; + groupBy: Exclude; + issue: CompanyArtifactIssueSummary; + title: string; + count: number; + mediaKinds: CompanyArtifactMediaKind[]; + previewArtifacts: CompanyArtifact[]; + updatedAt: string; + href: string; +} + export interface CompanyArtifactsResponse { artifacts: CompanyArtifact[]; + groups?: CompanyArtifactGroup[]; + selectedGroup?: CompanyArtifactGroup | null; nextCursor: string | null; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9526d13bf39..97cc5b42f2b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -217,6 +217,8 @@ export type { export type { CompanyArtifact, CompanyArtifactAgentSummary, + CompanyArtifactGroup, + CompanyArtifactGroupBy, CompanyArtifactIssueSummary, CompanyArtifactMediaKind, CompanyArtifactProjectSummary, diff --git a/packages/shared/src/validators/artifact.test.ts b/packages/shared/src/validators/artifact.test.ts new file mode 100644 index 00000000000..2f0c138a6fa --- /dev/null +++ b/packages/shared/src/validators/artifact.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { companyArtifactsQuerySchema, companyArtifactsResponseSchema } from "./artifact.js"; + +const issue = { + id: "11111111-1111-4111-8111-111111111111", + identifier: "PAP-1", + title: "Build artifacts", +}; + +const artifact = { + id: "document:22222222-2222-4222-8222-222222222222", + source: "document", + mediaKind: "document", + title: "Plan", + previewText: "Artifact preview", + contentType: "text/markdown", + contentPath: null, + openPath: null, + downloadPath: null, + issue, + project: null, + createdByAgent: null, + updatedAt: "2026-06-06T12:00:00.000Z", + href: "/PAP/issues/PAP-1#document-plan", +}; + +describe("companyArtifactsQuerySchema", () => { + it("defaults to the existing flat artifact query", () => { + expect(companyArtifactsQuerySchema.parse({})).toMatchObject({ + kind: "all", + groupBy: "none", + limit: 30, + }); + }); + + it("accepts grouped artifact query parameters", () => { + expect( + companyArtifactsQuerySchema.parse({ + groupBy: "parent_task", + groupIssueId: issue.id, + kind: "video", + q: "render", + }), + ).toMatchObject({ + groupBy: "parent_task", + groupIssueId: issue.id, + kind: "video", + q: "render", + }); + }); + + it("rejects invalid grouped artifact query parameters", () => { + expect(() => companyArtifactsQuerySchema.parse({ groupBy: "agent" })).toThrow(); + expect(() => companyArtifactsQuerySchema.parse({ groupIssueId: "PAP-1" })).toThrow(); + }); +}); + +describe("companyArtifactsResponseSchema", () => { + it("accepts grouped artifact responses with selected group metadata", () => { + const group = { + id: `task:${issue.id}`, + groupBy: "task", + issue, + title: issue.title, + count: 1, + mediaKinds: ["document"], + previewArtifacts: [artifact], + updatedAt: "2026-06-06T12:00:00.000Z", + href: `/PAP/artifacts?groupBy=task&groupIssueId=${issue.id}`, + }; + + expect( + companyArtifactsResponseSchema.parse({ + artifacts: [artifact], + groups: [group], + selectedGroup: group, + nextCursor: null, + }), + ).toMatchObject({ + artifacts: [artifact], + groups: [group], + selectedGroup: group, + nextCursor: null, + }); + }); +}); diff --git a/packages/shared/src/validators/artifact.ts b/packages/shared/src/validators/artifact.ts index 72251d413d8..4d9f32b2c37 100644 --- a/packages/shared/src/validators/artifact.ts +++ b/packages/shared/src/validators/artifact.ts @@ -8,10 +8,14 @@ export const companyArtifactSourceSchema = z.enum(["document", "attachment", "wo export const companyArtifactMediaKindSchema = z.enum(["image", "video", "text", "document", "file", "empty"]); +export const companyArtifactGroupBySchema = z.enum(["none", "task", "parent_task"]); + export const companyArtifactsQuerySchema = z.object({ kind: z.enum(["image", "video", "text", "document", "file", "all"]).optional().default("all"), projectId: z.string().uuid().optional(), q: z.string().trim().max(COMPANY_ARTIFACTS_MAX_QUERY_LENGTH).optional(), + groupBy: companyArtifactGroupBySchema.optional().default("none"), + groupIssueId: z.string().uuid().optional(), limit: z.coerce .number() .int() @@ -49,8 +53,26 @@ export const companyArtifactSchema = z.object({ href: z.string().min(1), }); +export const companyArtifactGroupSchema = z.object({ + id: z.string().min(1), + groupBy: companyArtifactGroupBySchema.exclude(["none"]), + issue: z.object({ + id: z.string().uuid(), + identifier: z.string(), + title: z.string(), + }), + title: z.string(), + count: z.number().int().min(0), + mediaKinds: z.array(companyArtifactMediaKindSchema), + previewArtifacts: z.array(companyArtifactSchema), + updatedAt: z.string().datetime(), + href: z.string().min(1), +}); + export const companyArtifactsResponseSchema = z.object({ artifacts: z.array(companyArtifactSchema), + groups: z.array(companyArtifactGroupSchema).optional(), + selectedGroup: companyArtifactGroupSchema.nullable().optional(), nextCursor: z.string().nullable(), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 85719b7e5df..db06648debf 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -330,6 +330,8 @@ export { COMPANY_ARTIFACTS_DEFAULT_LIMIT, COMPANY_ARTIFACTS_MAX_LIMIT, COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactGroupBySchema, + companyArtifactGroupSchema, companyArtifactMediaKindSchema, companyArtifactSchema, companyArtifactSourceSchema, diff --git a/server/src/__tests__/company-artifacts-service.test.ts b/server/src/__tests__/company-artifacts-service.test.ts index 429c724b6fd..47cf282f3df 100644 --- a/server/src/__tests__/company-artifacts-service.test.ts +++ b/server/src/__tests__/company-artifacts-service.test.ts @@ -1,5 +1,6 @@ import { Readable } from "node:stream"; import express from "express"; +import { eq } from "drizzle-orm"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { @@ -329,7 +330,7 @@ describeEmbeddedPostgres("companyArtifactsService", () => { updatedAt: new Date("2026-01-02T12:00:00.000Z"), }); - return { companyId, projectId, issueId, otherRunId }; + return { companyId, projectId, issueId, secondIssueId, otherIssueId, otherRunId }; } it("projects agent-created documents, direct attachments, and work products while excluding noisy sources", async () => { @@ -495,6 +496,189 @@ describeEmbeddedPostgres("companyArtifactsService", () => { expect(forged?.createdByAgent).toBeNull(); expect(result.artifacts.some((artifact) => artifact.createdByAgent?.name === "Other")).toBe(false); }); + + it("groups artifacts by task after applying media, project, and search filters", async () => { + const { companyId, projectId, issueId } = await seedArtifacts(); + const storage = createStorageService({ "notes.txt": Buffer.from("Searchable notes preview") }); + + const grouped = await companyArtifactsService(db, storage).list(companyId, { + groupBy: "task", + limit: 10, + }); + expect(grouped.artifacts).toEqual([]); + expect(grouped.nextCursor).toBeNull(); + expect(grouped.groups?.map((group) => ({ + issue: group.issue.identifier, + count: group.count, + mediaKinds: group.mediaKinds, + href: group.href, + }))).toEqual([ + { + issue: "PAP-2", + count: 1, + mediaKinds: ["document"], + href: "/PAP/artifacts?groupBy=task&groupIssueId=77777777-7777-4777-8777-777777777777", + }, + { + issue: "PAP-1", + count: 3, + mediaKinds: ["video", "text"], + href: "/PAP/artifacts?groupBy=task&groupIssueId=66666666-6666-4666-8666-666666666666", + }, + ]); + expect(grouped.groups?.find((group) => group.issue.id === issueId)?.previewArtifacts.map((artifact) => artifact.title)) + .toEqual(["direct-video.mp4", "Primary Cut", "notes.txt"]); + + const projectVideos = await companyArtifactsService(db, storage).list(companyId, { + groupBy: "task", + projectId, + kind: "video", + limit: 10, + }); + expect(projectVideos.groups?.map((group) => ({ + issue: group.issue.identifier, + count: group.count, + href: group.href, + }))).toEqual([ + { + issue: "PAP-1", + count: 2, + href: + "/PAP/artifacts?groupBy=task&groupIssueId=66666666-6666-4666-8666-666666666666&kind=video&projectId=55555555-5555-4555-8555-555555555555", + }, + ]); + + const search = await companyArtifactsService(db, storage).list(companyId, { + groupBy: "task", + q: "review document", + limit: 10, + }); + expect(search.groups?.map((group) => ({ issue: group.issue.identifier, count: group.count }))).toEqual([ + { issue: "PAP-2", count: 1 }, + ]); + }); + + it("paginates grouped task lists with the active group cursor", async () => { + const { companyId } = await seedArtifacts(); + + const firstPage = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + limit: 1, + }); + expect(firstPage.groups?.map((group) => group.issue.identifier)).toEqual(["PAP-2"]); + expect(firstPage.nextCursor).toEqual(expect.any(String)); + + const secondPage = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + limit: 10, + cursor: firstPage.nextCursor ?? undefined, + }); + expect(secondPage.groups?.map((group) => group.issue.identifier)).toEqual(["PAP-1"]); + expect(secondPage.nextCursor).toBeNull(); + }); + + it("groups parent-task artifacts under the topmost same-company ancestor", async () => { + const { companyId, issueId, secondIssueId } = await seedArtifacts(); + const grandchildIssueId = "21212121-2121-4212-8121-212121212121"; + const grandchildAttachmentId = "23232323-2323-4232-8232-232323232323"; + + await db.update(issues).set({ parentId: issueId }).where(eq(issues.id, secondIssueId)); + await db.insert(issues).values({ + id: grandchildIssueId, + companyId, + parentId: secondIssueId, + identifier: "PAP-3", + title: "Grandchild render", + status: "done", + priority: "medium", + }); + await db.insert(assets).values({ + id: "24242424-2424-4242-8242-242424242424", + companyId, + provider: "local_disk", + objectKey: "grandchild.txt", + contentType: "text/plain", + byteSize: 48, + sha256: "sha256-grandchild", + originalFilename: "grandchild.txt", + createdByAgentId: "33333333-3333-4333-8333-333333333333", + }); + await db.insert(issueAttachments).values({ + id: grandchildAttachmentId, + companyId, + issueId: grandchildIssueId, + assetId: "24242424-2424-4242-8242-242424242424", + updatedAt: new Date("2026-01-05T00:00:00.000Z"), + }); + + const grouped = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "parent_task", + limit: 10, + }); + + expect(grouped.artifacts).toEqual([]); + expect(grouped.groups?.map((group) => ({ + issue: group.issue.identifier, + count: group.count, + previewTitles: group.previewArtifacts.map((artifact) => artifact.title), + }))).toEqual([ + { + issue: "PAP-1", + count: 5, + previewTitles: ["grandchild.txt", "Review Notes", "direct-video.mp4"], + }, + ]); + }); + + it("returns selected group artifact pages and metadata without leaking foreign group issues", async () => { + const { companyId, issueId, otherIssueId } = await seedArtifacts(); + + const selected = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: issueId, + limit: 2, + }); + expect(selected.groups).toBeUndefined(); + expect(selected.selectedGroup).toMatchObject({ + id: `task:${issueId}`, + groupBy: "task", + issue: { identifier: "PAP-1" }, + count: 3, + }); + expect(selected.artifacts.map((artifact) => artifact.title)).toEqual(["direct-video.mp4", "Primary Cut"]); + expect(selected.nextCursor).toEqual(expect.any(String)); + + const selectedSecondPage = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: issueId, + limit: 10, + cursor: selected.nextCursor ?? undefined, + }); + expect(selectedSecondPage.artifacts.map((artifact) => artifact.title)).toEqual(["notes.txt"]); + + const selectedEmptyByFilter = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: issueId, + q: "does-not-match-this-stack", + limit: 10, + }); + expect(selectedEmptyByFilter.selectedGroup).toMatchObject({ + id: `task:${issueId}`, + count: 0, + }); + expect(selectedEmptyByFilter.artifacts).toEqual([]); + + const foreignSelected = await companyArtifactsService(db, createStorageService()).list(companyId, { + groupBy: "task", + groupIssueId: otherIssueId, + limit: 10, + }); + expect(foreignSelected).toEqual({ + artifacts: [], + selectedGroup: null, + nextCursor: null, + }); + }); }); describe("company artifacts route authorization", () => { diff --git a/server/src/services/company-artifacts.ts b/server/src/services/company-artifacts.ts index 3e787dce542..90faf46fed8 100644 --- a/server/src/services/company-artifacts.ts +++ b/server/src/services/company-artifacts.ts @@ -20,6 +20,8 @@ import { companyArtifactsQuerySchema, SYSTEM_ISSUE_DOCUMENT_KEYS, type CompanyArtifact, + type CompanyArtifactGroup, + type CompanyArtifactGroupBy, type CompanyArtifactMediaKind, type CompanyArtifactsQuery, type CompanyArtifactsResponse, @@ -29,12 +31,23 @@ import type { StorageService } from "../storage/types.js"; const TEXT_PREVIEW_BYTES = 4096; const PREVIEW_TEXT_MAX_LENGTH = 280; +const GROUP_PREVIEW_ARTIFACT_LIMIT = 3; type ArtifactCursor = { updatedAt: string; id: string; }; +type ArtifactGroupBy = Exclude; + +type IssueGroupingRow = { + id: string; + parentId: string | null; + identifier: string | null; + title: string; + updatedAt: Date; +}; + function encodeCursor(cursor: ArtifactCursor) { return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); } @@ -61,6 +74,12 @@ function cursorCondition(updatedAt: SQL, artifactId: SQL, cursor: return sql`(${updatedAt} < ${cursor.updatedAt}::timestamptz OR (${updatedAt} = ${cursor.updatedAt}::timestamptz AND ${artifactId} < ${cursor.id}))`; } +function isAfterCursor(item: { updatedAt: string; id: string }, cursor: ArtifactCursor | null) { + if (!cursor) return true; + const dateDiff = Date.parse(item.updatedAt) - Date.parse(cursor.updatedAt); + return dateDiff < 0 || (dateDiff === 0 && item.id < cursor.id); +} + function escapeLikePattern(value: string) { return value.replace(/[\\%_]/g, (match) => `\\${match}`); } @@ -116,6 +135,21 @@ function buildIssueHref(companyPrefix: string, identifier: string, anchor: strin return `/${encodeURIComponent(companyPrefix)}/issues/${encodeURIComponent(identifier)}#${anchor}`; } +function buildArtifactsGroupHref( + companyPrefix: string, + query: CompanyArtifactsQuery, + groupBy: ArtifactGroupBy, + groupIssueId: string, +) { + const params = new URLSearchParams(); + params.set("groupBy", groupBy); + params.set("groupIssueId", groupIssueId); + if (query.kind !== "all") params.set("kind", query.kind); + if (query.projectId) params.set("projectId", query.projectId); + if (query.q) params.set("q", query.q); + return `/${encodeURIComponent(companyPrefix)}/artifacts?${params.toString()}`; +} + function attachmentContentPath(attachmentId: string) { return `/api/attachments/${attachmentId}/content`; } @@ -136,11 +170,141 @@ async function readTextAttachmentPreview( } } +function sortArtifacts(artifacts: CompanyArtifact[]) { + return artifacts.sort((a, b) => { + const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + if (dateDiff !== 0) return dateDiff; + return b.id.localeCompare(a.id); + }); +} + +function pageByCursor( + items: T[], + limit: number, + cursor: ArtifactCursor | null, +) { + const filtered = items.filter((item) => isAfterCursor(item, cursor)); + const page = filtered.slice(0, limit); + const nextCursor = filtered.length > limit + ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) + : null; + return { page, nextCursor }; +} + +async function loadIssueGroupingRows(db: Db, companyId: string) { + const rows = await db + .select({ + id: issues.id, + parentId: issues.parentId, + identifier: issues.identifier, + title: issues.title, + updatedAt: issues.updatedAt, + }) + .from(issues) + .where(eq(issues.companyId, companyId)); + + return new Map(rows.map((row): [string, IssueGroupingRow] => [row.id, row])); +} + +function getIssueSummary(issue: IssueGroupingRow) { + return { + id: issue.id, + identifier: issue.identifier ?? issue.id, + title: issue.title, + }; +} + +function resolveRootIssueId(issueId: string, issueRows: Map) { + let current = issueRows.get(issueId); + if (!current) return issueId; + const seen = new Set(); + while (current.parentId && !seen.has(current.id)) { + seen.add(current.id); + const parent = issueRows.get(current.parentId); + if (!parent) break; + current = parent; + } + return current.id; +} + +function resolveGroupIssueId(groupBy: ArtifactGroupBy, issueId: string, issueRows: Map) { + return groupBy === "task" ? issueId : resolveRootIssueId(issueId, issueRows); +} + +function emptyGroup(input: { + companyPrefix: string; + query: CompanyArtifactsQuery; + groupBy: ArtifactGroupBy; + issue: IssueGroupingRow; +}): CompanyArtifactGroup { + const summary = getIssueSummary(input.issue); + return { + id: `${input.groupBy}:${input.issue.id}`, + groupBy: input.groupBy, + issue: summary, + title: summary.title, + count: 0, + mediaKinds: [], + previewArtifacts: [], + updatedAt: input.issue.updatedAt.toISOString(), + href: buildArtifactsGroupHref(input.companyPrefix, input.query, input.groupBy, input.issue.id), + }; +} + +function buildArtifactGroups(input: { + artifacts: CompanyArtifact[]; + companyPrefix: string; + query: CompanyArtifactsQuery; + groupBy: ArtifactGroupBy; + issueRows: Map; +}) { + const groups = new Map(); + + for (const artifact of input.artifacts) { + const groupIssueId = resolveGroupIssueId(input.groupBy, artifact.issue.id, input.issueRows); + const groupIssue = input.issueRows.get(groupIssueId) ?? { + id: artifact.issue.id, + parentId: null, + identifier: artifact.issue.identifier, + title: artifact.issue.title, + updatedAt: new Date(artifact.updatedAt), + }; + const groupId = `${input.groupBy}:${groupIssueId}`; + const existing = groups.get(groupId); + const group = existing ?? emptyGroup({ + companyPrefix: input.companyPrefix, + query: input.query, + groupBy: input.groupBy, + issue: groupIssue, + }); + if (!existing) groups.set(groupId, group); + + group.count += 1; + if (!group.mediaKinds.includes(artifact.mediaKind)) { + group.mediaKinds.push(artifact.mediaKind); + } + if (group.previewArtifacts.length < GROUP_PREVIEW_ARTIFACT_LIMIT) { + group.previewArtifacts.push(artifact); + } + const dateDiff = Date.parse(artifact.updatedAt) - Date.parse(group.updatedAt); + if (dateDiff > 0 || (dateDiff === 0 && artifact.id > group.id)) { + group.updatedAt = artifact.updatedAt; + } + } + + return [...groups.values()].sort((a, b) => { + const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); + if (dateDiff !== 0) return dateDiff; + return b.id.localeCompare(a.id); + }); +} + export function companyArtifactsService(db: Db, storage?: StorageService) { return { list: async (companyId: string, rawQuery: Partial = {}): Promise => { const query = companyArtifactsQuerySchema.parse(rawQuery); const cursor = decodeCursor(query.cursor); + const groupBy = query.groupBy === "none" ? null : query.groupBy; const company = await db .select({ id: companies.id, issuePrefix: companies.issuePrefix }) .from(companies) @@ -162,7 +326,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, notInArray(issueDocuments.key, [...SYSTEM_ISSUE_DOCUMENT_KEYS]), ]; - const documentCursor = cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); + const documentCursor = groupBy ? undefined : cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); if (documentCursor) documentConditions.push(documentCursor); if (query.projectId) documentConditions.push(eq(issues.projectId, query.projectId)); if (q) { @@ -174,7 +338,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { )`); } - const documentRows = await db + const documentRowsQuery = db .select({ artifactId: documentArtifactId, documentId: documents.id, @@ -197,8 +361,8 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { .leftJoin(createdAgent, eq(documents.createdByAgentId, createdAgent.id)) .leftJoin(updatedAgent, eq(documents.updatedByAgentId, updatedAgent.id)) .where(and(...documentConditions)) - .orderBy(desc(documents.updatedAt), desc(documentArtifactId)) - .limit(fetchLimit); + .orderBy(desc(documents.updatedAt), desc(documentArtifactId)); + const documentRows = groupBy ? await documentRowsQuery : await documentRowsQuery.limit(fetchLimit); for (const row of documentRows) { const identifier = row.issueIdentifier ?? row.issueId; @@ -233,7 +397,9 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { eq(issueWorkProducts.provider, "paperclip"), ]; const workProductConditions: SQL[] = [...workProductBaseConditions]; - const workProductCursor = cursorCondition(sql`${issueWorkProducts.updatedAt}`, workProductArtifactId, cursor); + const workProductCursor = groupBy + ? undefined + : cursorCondition(sql`${issueWorkProducts.updatedAt}`, workProductArtifactId, cursor); const workProductKind = contentTypeKindCondition(workProductContentType, query.kind); if (workProductCursor) workProductConditions.push(workProductCursor); if (workProductKind) { @@ -256,7 +422,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { workProductConditions.push(searchCondition); } - const workProductRows = await db + const workProductRowsQuery = db .select({ artifactId: workProductArtifactId, workProductId: issueWorkProducts.id, @@ -290,8 +456,8 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { ), ) .where(and(...workProductConditions)) - .orderBy(desc(issueWorkProducts.updatedAt), desc(workProductArtifactId)) - .limit(fetchLimit); + .orderBy(desc(issueWorkProducts.updatedAt), desc(workProductArtifactId)); + const workProductRows = groupBy ? await workProductRowsQuery : await workProductRowsQuery.limit(fetchLimit); const workProductAttachmentRows = await db .select({ @@ -342,7 +508,9 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { isNull(issueAttachments.issueCommentId), isNotNull(assets.createdByAgentId), ]; - const attachmentCursor = cursorCondition(sql`${issueAttachments.updatedAt}`, attachmentArtifactId, cursor); + const attachmentCursor = groupBy + ? undefined + : cursorCondition(sql`${issueAttachments.updatedAt}`, attachmentArtifactId, cursor); const attachmentKind = contentTypeKindCondition(sql`${assets.contentType}`, query.kind); if (attachmentCursor) attachmentConditions.push(attachmentCursor); if (attachmentKind) attachmentConditions.push(attachmentKind); @@ -355,7 +523,7 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { )`); } - const attachmentRows = await db + const attachmentRowsQuery = db .select({ artifactId: attachmentArtifactId, attachmentId: issueAttachments.id, @@ -379,8 +547,8 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { .leftJoin(projects, eq(issues.projectId, projects.id)) .leftJoin(attachmentAgent, eq(assets.createdByAgentId, attachmentAgent.id)) .where(and(...attachmentConditions)) - .orderBy(desc(issueAttachments.updatedAt), desc(attachmentArtifactId)) - .limit(fetchLimit); + .orderBy(desc(issueAttachments.updatedAt), desc(attachmentArtifactId)); + const attachmentRows = groupBy ? await attachmentRowsQuery : await attachmentRowsQuery.limit(fetchLimit); const attachmentArtifacts = await Promise.all(attachmentRows.map(async (row): Promise => { if (workProductAttachmentIds.has(row.attachmentId)) return null; @@ -416,18 +584,48 @@ export function companyArtifactsService(db: Db, storage?: StorageService) { artifacts.push(...attachmentArtifacts.filter((artifact): artifact is CompanyArtifact => artifact !== null)); } - const sorted = artifacts - .sort((a, b) => { - const dateDiff = Date.parse(b.updatedAt) - Date.parse(a.updatedAt); - if (dateDiff !== 0) return dateDiff; - return b.id.localeCompare(a.id); - }); - const page = sorted.slice(0, query.limit); - const nextCursor = sorted.length > query.limit - ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) - : null; + const sorted = sortArtifacts(artifacts); + if (!groupBy) { + const page = sorted.slice(0, query.limit); + const nextCursor = sorted.length > query.limit + ? encodeCursor({ id: page[page.length - 1]?.id ?? "", updatedAt: page[page.length - 1]?.updatedAt ?? new Date(0).toISOString() }) + : null; - return { artifacts: page, nextCursor }; + return { artifacts: page, nextCursor }; + } + + const issueRows = await loadIssueGroupingRows(db, companyId); + const groups = buildArtifactGroups({ + artifacts: sorted, + companyPrefix: company.issuePrefix, + query, + groupBy, + issueRows, + }); + + if (query.groupIssueId) { + const selectedIssue = issueRows.get(query.groupIssueId); + if (!selectedIssue) { + return { artifacts: [], selectedGroup: null, nextCursor: null }; + } + + const selectedGroupIssueId = resolveGroupIssueId(groupBy, selectedIssue.id, issueRows); + const selectedGroup = groups.find((group) => group.issue.id === selectedGroupIssueId) + ?? emptyGroup({ + companyPrefix: company.issuePrefix, + query, + groupBy, + issue: issueRows.get(selectedGroupIssueId) ?? selectedIssue, + }); + const selectedArtifacts = sorted.filter((artifact) => + resolveGroupIssueId(groupBy, artifact.issue.id, issueRows) === selectedGroupIssueId + ); + const { page, nextCursor } = pageByCursor(selectedArtifacts, query.limit, cursor); + return { artifacts: page, selectedGroup, nextCursor }; + } + + const { page, nextCursor } = pageByCursor(groups, query.limit, cursor); + return { artifacts: [], groups: page, nextCursor }; }, }; }