From eae089aa2e1bc71b968d2c1a4a9d09f2fe209a1b Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 5 Jun 2026 16:17:23 +0000 Subject: [PATCH] Add company artifacts projection API --- packages/shared/src/index.ts | 16 + packages/shared/src/types/artifact.ts | 41 ++ packages/shared/src/types/index.ts | 9 + packages/shared/src/validators/artifact.ts | 57 +++ packages/shared/src/validators/index.ts | 12 + .../company-artifacts-service.test.ts | 401 ++++++++++++++++++ server/src/routes/companies.ts | 10 + server/src/routes/openapi.ts | 24 ++ server/src/services/company-artifacts.ts | 399 +++++++++++++++++ server/src/services/index.ts | 1 + 10 files changed, 970 insertions(+) create mode 100644 packages/shared/src/types/artifact.ts create mode 100644 packages/shared/src/validators/artifact.ts create mode 100644 server/src/__tests__/company-artifacts-service.test.ts create mode 100644 server/src/services/company-artifacts.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2cb1e69822d..c9169cfb313 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -446,6 +446,13 @@ export type { IssueWorkProductProvider, IssueWorkProductStatus, IssueWorkProductReviewState, + CompanyArtifact, + CompanyArtifactAgentSummary, + CompanyArtifactIssueSummary, + CompanyArtifactMediaKind, + CompanyArtifactProjectSummary, + CompanyArtifactSource, + CompanyArtifactsResponse, CreateDocumentAnnotationCommentRequest, CreateDocumentAnnotationThreadRequest, DocumentAnnotationAnchorRemapSnapshot, @@ -982,6 +989,14 @@ export { issueWorkProductTypeSchema, issueWorkProductStatusSchema, issueWorkProductReviewStateSchema, + COMPANY_ARTIFACTS_DEFAULT_LIMIT, + COMPANY_ARTIFACTS_MAX_LIMIT, + COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactMediaKindSchema, + companyArtifactSchema, + companyArtifactSourceSchema, + companyArtifactsQuerySchema, + companyArtifactsResponseSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, executionWorkspaceCloseActionKindSchema, @@ -1016,6 +1031,7 @@ export { type CreateIssueAttachmentMetadata, type CreateIssueWorkProduct, type UpdateIssueWorkProduct, + type CompanyArtifactsQuery, type UpdateExecutionWorkspace, type IssueDocumentFormat, type UpsertIssueDocument, diff --git a/packages/shared/src/types/artifact.ts b/packages/shared/src/types/artifact.ts new file mode 100644 index 00000000000..413ed68f437 --- /dev/null +++ b/packages/shared/src/types/artifact.ts @@ -0,0 +1,41 @@ +export type CompanyArtifactSource = "document" | "attachment" | "work_product"; + +export type CompanyArtifactMediaKind = "image" | "video" | "text" | "document" | "file" | "empty"; + +export interface CompanyArtifactIssueSummary { + id: string; + identifier: string; + title: string; +} + +export interface CompanyArtifactProjectSummary { + id: string; + name: string; +} + +export interface CompanyArtifactAgentSummary { + id: string; + name: string; +} + +export interface CompanyArtifact { + id: string; + source: CompanyArtifactSource; + mediaKind: CompanyArtifactMediaKind; + title: string; + previewText: string | null; + contentType: string | null; + contentPath: string | null; + openPath: string | null; + downloadPath: string | null; + issue: CompanyArtifactIssueSummary; + project: CompanyArtifactProjectSummary | null; + createdByAgent: CompanyArtifactAgentSummary | null; + updatedAt: string; + href: string; +} + +export interface CompanyArtifactsResponse { + artifacts: CompanyArtifact[]; + nextCursor: string | null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a235150f068..238308f809d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -213,6 +213,15 @@ export type { IssueWorkProductReviewState, AttachmentArtifactWorkProductMetadata, } from "./work-product.js"; +export type { + CompanyArtifact, + CompanyArtifactAgentSummary, + CompanyArtifactIssueSummary, + CompanyArtifactMediaKind, + CompanyArtifactProjectSummary, + CompanyArtifactSource, + CompanyArtifactsResponse, +} from "./artifact.js"; export type { Issue, IssueWorkMode, diff --git a/packages/shared/src/validators/artifact.ts b/packages/shared/src/validators/artifact.ts new file mode 100644 index 00000000000..72251d413d8 --- /dev/null +++ b/packages/shared/src/validators/artifact.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +export const COMPANY_ARTIFACTS_DEFAULT_LIMIT = 30; +export const COMPANY_ARTIFACTS_MAX_LIMIT = 100; +export const COMPANY_ARTIFACTS_MAX_QUERY_LENGTH = 160; + +export const companyArtifactSourceSchema = z.enum(["document", "attachment", "work_product"]); + +export const companyArtifactMediaKindSchema = z.enum(["image", "video", "text", "document", "file", "empty"]); + +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(), + limit: z.coerce + .number() + .int() + .min(1) + .max(COMPANY_ARTIFACTS_MAX_LIMIT) + .optional() + .default(COMPANY_ARTIFACTS_DEFAULT_LIMIT), + cursor: z.string().min(1).optional(), +}); + +export const companyArtifactSchema = z.object({ + id: z.string().min(1), + source: companyArtifactSourceSchema, + mediaKind: companyArtifactMediaKindSchema, + title: z.string(), + previewText: z.string().nullable(), + contentType: z.string().nullable(), + contentPath: z.string().nullable(), + openPath: z.string().nullable(), + downloadPath: z.string().nullable(), + issue: z.object({ + id: z.string().uuid(), + identifier: z.string(), + title: z.string(), + }), + project: z.object({ + id: z.string().uuid(), + name: z.string(), + }).nullable(), + createdByAgent: z.object({ + id: z.string().uuid(), + name: z.string(), + }).nullable(), + updatedAt: z.string().datetime(), + href: z.string().min(1), +}); + +export const companyArtifactsResponseSchema = z.object({ + artifacts: z.array(companyArtifactSchema), + nextCursor: z.string().nullable(), +}); + +export type CompanyArtifactsQuery = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e8cc4233aa5..85719b7e5df 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -326,6 +326,18 @@ export { type UpdateIssueWorkProduct, } from "./work-product.js"; +export { + COMPANY_ARTIFACTS_DEFAULT_LIMIT, + COMPANY_ARTIFACTS_MAX_LIMIT, + COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, + companyArtifactMediaKindSchema, + companyArtifactSchema, + companyArtifactSourceSchema, + companyArtifactsQuerySchema, + companyArtifactsResponseSchema, + type CompanyArtifactsQuery, +} from "./artifact.js"; + export { executionWorkspaceConfigSchema, updateExecutionWorkspaceSchema, diff --git a/server/src/__tests__/company-artifacts-service.test.ts b/server/src/__tests__/company-artifacts-service.test.ts new file mode 100644 index 00000000000..620d17e0bab --- /dev/null +++ b/server/src/__tests__/company-artifacts-service.test.ts @@ -0,0 +1,401 @@ +import { Readable } from "node:stream"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agents, + assets, + companies, + createDb, + documents, + heartbeatRuns, + issueAttachments, + issueComments, + issueDocuments, + issues, + issueWorkProducts, + projects, +} from "@paperclipai/db"; +import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { companyRoutes } from "../routes/companies.js"; +import { companyArtifactsService } from "../services/company-artifacts.js"; +import type { StorageService } from "../storage/types.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company artifacts tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +function createStorageService(files: Record = {}): StorageService { + return { + provider: "local_disk", + putFile: vi.fn(), + getObject: vi.fn(async (_companyId, objectKey, options) => { + const body = files[objectKey] ?? Buffer.alloc(0); + const range = options?.range; + const ranged = range ? body.subarray(range.start, range.end + 1) : body; + return { + stream: Readable.from(ranged), + contentType: "text/plain", + contentLength: ranged.length, + }; + }), + headObject: vi.fn(), + deleteObject: vi.fn(), + }; +} + +describeEmbeddedPostgres("companyArtifactsService", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-artifacts-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(issueWorkProducts); + await db.delete(issueAttachments); + await db.delete(assets); + await db.delete(issueComments); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedArtifacts() { + const companyId = "11111111-1111-4111-8111-111111111111"; + const otherCompanyId = "22222222-2222-4222-8222-222222222222"; + const agentId = "33333333-3333-4333-8333-333333333333"; + const otherAgentId = "44444444-4444-4444-8444-444444444444"; + const projectId = "55555555-5555-4555-8555-555555555555"; + const issueId = "66666666-6666-4666-8666-666666666666"; + const secondIssueId = "77777777-7777-4777-8777-777777777777"; + const otherIssueId = "88888888-8888-4888-8888-888888888888"; + const runId = "99999999-9999-4999-8999-999999999999"; + const directAttachmentId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const workProductAttachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + + await db.insert(companies).values([ + { id: companyId, name: "Paperclip", issuePrefix: "PAP", requireBoardApprovalForNewAgents: false }, + { id: otherCompanyId, name: "OtherCo", issuePrefix: "OTH", requireBoardApprovalForNewAgents: false }, + ]); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Coder", role: "engineer" }, + { id: otherAgentId, companyId: otherCompanyId, name: "Other", role: "engineer" }, + ]); + await db.insert(projects).values({ id: projectId, companyId, name: "Artifacts", status: "in_progress" }); + await db.insert(issues).values([ + { + id: issueId, + companyId, + projectId, + identifier: "PAP-1", + title: "Make the reel", + status: "done", + priority: "medium", + }, + { + id: secondIssueId, + companyId, + identifier: "PAP-2", + title: "Write the plan", + status: "done", + priority: "medium", + }, + { + id: otherIssueId, + companyId: otherCompanyId, + identifier: "OTH-1", + title: "Other output", + status: "done", + priority: "medium", + }, + ]); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "completed", + }); + await db.insert(documents).values([ + { + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + companyId, + title: "Review Notes", + latestBody: "# Review\n\nAgent-created review document with useful details.", + createdByAgentId: agentId, + updatedAt: new Date("2026-01-04T00:00:00.000Z"), + }, + { + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + companyId, + title: "Continuation Summary", + latestBody: "System handoff", + createdByAgentId: agentId, + updatedAt: new Date("2026-01-05T00:00:00.000Z"), + }, + { + id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + companyId, + title: "User Upload Notes", + latestBody: "User-authored context", + createdByUserId: "user-1", + updatedAt: new Date("2026-01-06T00:00:00.000Z"), + }, + { + id: "ffffffff-ffff-4fff-8fff-ffffffffffff", + companyId: otherCompanyId, + title: "Other Company Plan", + latestBody: "Must not cross tenants", + createdByAgentId: otherAgentId, + updatedAt: new Date("2026-01-07T00:00:00.000Z"), + }, + ]); + await db.insert(issueDocuments).values([ + { + companyId, + issueId: secondIssueId, + documentId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + key: "review", + }, + { + companyId, + issueId, + documentId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + }, + { + companyId, + issueId, + documentId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + key: "user-notes", + }, + { + companyId: otherCompanyId, + issueId: otherIssueId, + documentId: "ffffffff-ffff-4fff-8fff-ffffffffffff", + key: "plan", + }, + ]); + await db.insert(issueComments).values({ + id: "12121212-1212-4121-8121-121212121212", + companyId, + issueId, + authorType: "agent", + authorAgentId: agentId, + body: "comment with screenshot", + }); + await db.insert(assets).values([ + { + id: "13131313-1313-4131-8131-131313131313", + companyId, + provider: "local_disk", + objectKey: "direct-video.mp4", + contentType: "video/mp4", + byteSize: 100, + sha256: "sha256-direct-video", + originalFilename: "direct-video.mp4", + createdByAgentId: agentId, + }, + { + id: "14141414-1414-4141-8141-141414141414", + companyId, + provider: "local_disk", + objectKey: "primary-cut.mp4", + contentType: "video/mp4", + byteSize: 200, + sha256: "sha256-primary-cut", + originalFilename: "primary-cut.mp4", + createdByAgentId: agentId, + }, + { + id: "15151515-1515-4151-8151-151515151515", + companyId, + provider: "local_disk", + objectKey: "operator-screenshot.png", + contentType: "image/png", + byteSize: 300, + sha256: "sha256-user", + originalFilename: "operator-screenshot.png", + createdByUserId: "user-1", + }, + { + id: "16161616-1616-4161-8161-161616161616", + companyId, + provider: "local_disk", + objectKey: "comment-screenshot.png", + contentType: "image/png", + byteSize: 400, + sha256: "sha256-comment", + originalFilename: "comment-screenshot.png", + createdByAgentId: agentId, + }, + { + id: "17171717-1717-4171-8171-171717171717", + companyId, + provider: "local_disk", + objectKey: "notes.txt", + contentType: "text/plain", + byteSize: 64, + sha256: "sha256-notes", + originalFilename: "notes.txt", + createdByAgentId: agentId, + }, + ]); + await db.insert(issueAttachments).values([ + { + id: directAttachmentId, + companyId, + issueId, + assetId: "13131313-1313-4131-8131-131313131313", + updatedAt: new Date("2026-01-03T00:00:00.000Z"), + }, + { + id: workProductAttachmentId, + companyId, + issueId, + assetId: "14141414-1414-4141-8141-141414141414", + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + }, + { + companyId, + issueId, + assetId: "15151515-1515-4151-8151-151515151515", + updatedAt: new Date("2026-01-08T00:00:00.000Z"), + }, + { + companyId, + issueId, + assetId: "16161616-1616-4161-8161-161616161616", + issueCommentId: "12121212-1212-4121-8121-121212121212", + updatedAt: new Date("2026-01-09T00:00:00.000Z"), + }, + { + companyId, + issueId, + assetId: "17171717-1717-4171-8171-171717171717", + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + ]); + await db.insert(issueWorkProducts).values({ + id: "18181818-1818-4181-8181-181818181818", + companyId, + projectId, + issueId, + type: "artifact", + provider: "paperclip", + title: "Primary Cut", + status: "ready_for_review", + summary: "Main render for review", + isPrimary: true, + metadata: { + attachmentId: workProductAttachmentId, + contentType: "video/mp4", + byteSize: 200, + contentPath: `/api/attachments/${workProductAttachmentId}/content`, + openPath: `/api/attachments/${workProductAttachmentId}/content`, + downloadPath: `/api/attachments/${workProductAttachmentId}/content?download=1`, + originalFilename: "primary-cut.mp4", + }, + createdByRunId: runId, + updatedAt: new Date("2026-01-02T12:00:00.000Z"), + }); + + return { companyId, projectId }; + } + + it("projects agent-created documents, direct attachments, and work products while excluding noisy sources", async () => { + const { companyId } = await seedArtifacts(); + const storage = createStorageService({ "notes.txt": Buffer.from("Text file preview from an agent output.") }); + const result = await companyArtifactsService(db, storage).list(companyId, { limit: 20 }); + + expect(result.nextCursor).toBeNull(); + expect(result.artifacts.map((artifact) => artifact.title)).toEqual([ + "Review Notes", + "direct-video.mp4", + "Primary Cut", + "notes.txt", + ]); + expect(result.artifacts.map((artifact) => artifact.source)).toEqual([ + "document", + "attachment", + "work_product", + "attachment", + ]); + expect(result.artifacts.find((artifact) => artifact.title === "notes.txt")?.previewText) + .toBe("Text file preview from an agent output."); + expect(result.artifacts.some((artifact) => artifact.title === "primary-cut.mp4")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.title === "Continuation Summary")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.title === "operator-screenshot.png")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.title === "comment-screenshot.png")).toBe(false); + expect(result.artifacts.some((artifact) => artifact.issue.identifier === "OTH-1")).toBe(false); + }); + + it("supports project, kind, search, and cursor filters", async () => { + const { companyId, projectId } = await seedArtifacts(); + const storage = createStorageService({ "notes.txt": Buffer.from("Searchable notes preview") }); + + const projectVideos = await companyArtifactsService(db, storage).list(companyId, { + projectId, + kind: "video", + limit: 10, + }); + expect(projectVideos.artifacts.map((artifact) => artifact.title)).toEqual(["direct-video.mp4", "Primary Cut"]); + + const search = await companyArtifactsService(db, storage).list(companyId, { + q: "review document", + limit: 10, + }); + expect(search.artifacts.map((artifact) => artifact.title)).toEqual(["Review Notes"]); + + const firstPage = await companyArtifactsService(db, storage).list(companyId, { limit: 2 }); + expect(firstPage.artifacts.map((artifact) => artifact.title)).toEqual(["Review Notes", "direct-video.mp4"]); + expect(firstPage.nextCursor).toEqual(expect.any(String)); + + const secondPage = await companyArtifactsService(db, storage).list(companyId, { + limit: 10, + cursor: firstPage.nextCursor ?? undefined, + }); + expect(secondPage.artifacts.map((artifact) => artifact.title)).toEqual(["Primary Cut", "notes.txt"]); + }); +}); + +describe("company artifacts route authorization", () => { + it("rejects agent access across company boundaries before reading artifacts", async () => { + const app = express(); + app.use((_req, _res, next) => { + (_req as any).actor = { + type: "agent", + agentId: "agent-1", + companyId: "company-allowed", + }; + next(); + }); + app.use("/api/companies", companyRoutes({} as any, createStorageService())); + app.use(errorHandler); + + const res = await request(app).get("/api/companies/company-denied/artifacts"); + + expect(res.status).toBe(403); + expect(res.body.error).toBe("Agent key cannot access another company"); + }); +}); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 2704f502098..fcc13423fa5 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -5,6 +5,7 @@ import type { Db } from "@paperclipai/db"; import { agents as agentsTable } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, + companyArtifactsQuerySchema, companyPortabilityExportSchema, companyPortabilityImportSchema, companyPortabilityPreviewSchema, @@ -21,6 +22,7 @@ import { accessService, agentService, budgetService, + companyArtifactsService, companyPortabilityService, companyService, feedbackService, @@ -37,6 +39,7 @@ export function companyRoutes(db: Db, storage?: StorageService) { const portability = companyPortabilityService(db, storage); const access = accessService(db); const budgets = budgetService(db); + const artifacts = companyArtifactsService(db, storage); const feedback = feedbackService(db); const importJobs = new Map(); const importJobTerminalRetentionMs = 5 * 60 * 1000; @@ -125,6 +128,13 @@ export function companyRoutes(db: Db, storage?: StorageService) { }); }); + router.get("/:companyId/artifacts", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const query = companyArtifactsQuerySchema.parse(req.query); + res.json(await artifacts.list(companyId, query)); + }); + router.get("/:companyId", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 73e9841d03a..7a0b8fb6821 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -35,6 +35,8 @@ import { createCompanySchema, updateCompanySchema, updateCompanyBrandingSchema, + companyArtifactsQuerySchema, + companyArtifactsResponseSchema, // Routine createRoutineSchema, updateRoutineSchema, @@ -789,6 +791,28 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/artifacts", + tags: ["companies"], + summary: "List company artifacts", + request: { + params: z.object({ companyId: z.string() }), + query: companyArtifactsQuerySchema, + }, + responses: { + 200: { + description: "Company artifact projection", + content: { + "application/json": { + schema: companyArtifactsResponseSchema, + }, + }, + }, + 401: r.unauthorized, + }, +}); + registry.registerPath({ method: "patch", path: "/api/companies/{companyId}", diff --git a/server/src/services/company-artifacts.ts b/server/src/services/company-artifacts.ts new file mode 100644 index 00000000000..c6556572c29 --- /dev/null +++ b/server/src/services/company-artifacts.ts @@ -0,0 +1,399 @@ +import { buffer } from "node:stream/consumers"; +import { and, desc, eq, isNotNull, isNull, or, sql, type SQL } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import type { Db } from "@paperclipai/db"; +import { + agents, + assets, + companies, + documents, + heartbeatRuns, + issueAttachments, + issueDocuments, + issues, + issueWorkProducts, + projects, +} from "@paperclipai/db"; +import { + attachmentArtifactWorkProductMetadataSchema, + COMPANY_ARTIFACTS_MAX_LIMIT, + companyArtifactsQuerySchema, + ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + isSystemIssueDocumentKey, + type CompanyArtifact, + type CompanyArtifactMediaKind, + type CompanyArtifactsQuery, + type CompanyArtifactsResponse, +} from "@paperclipai/shared"; +import { badRequest, notFound } from "../errors.js"; +import type { StorageService } from "../storage/types.js"; + +const TEXT_PREVIEW_BYTES = 4096; +const PREVIEW_TEXT_MAX_LENGTH = 280; + +type ArtifactCursor = { + updatedAt: string; + id: string; +}; + +function encodeCursor(cursor: ArtifactCursor) { + return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string | undefined): ArtifactCursor | null { + if (!cursor) return null; + try { + const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial; + if (typeof parsed.id !== "string" || typeof parsed.updatedAt !== "string") { + throw new Error("Invalid cursor"); + } + const date = new Date(parsed.updatedAt); + if (Number.isNaN(date.getTime())) { + throw new Error("Invalid cursor date"); + } + return { id: parsed.id, updatedAt: date.toISOString() }; + } catch { + throw badRequest("Invalid artifacts cursor"); + } +} + +function cursorCondition(updatedAt: SQL, artifactId: SQL, cursor: ArtifactCursor | null) { + if (!cursor) return undefined; + return sql`(${updatedAt} < ${cursor.updatedAt}::timestamptz OR (${updatedAt} = ${cursor.updatedAt}::timestamptz AND ${artifactId} < ${cursor.id}))`; +} + +function escapeLikePattern(value: string) { + return value.replace(/[\\%_]/g, (match) => `\\${match}`); +} + +function normalizePreviewText(input: string | null | undefined) { + if (!input) return null; + const stripped = input + .replace(/```[\s\S]*?```/g, " ") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/[#>*_\-~|]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!stripped) return null; + return stripped.length > PREVIEW_TEXT_MAX_LENGTH + ? `${stripped.slice(0, PREVIEW_TEXT_MAX_LENGTH - 3).trimEnd()}...` + : stripped; +} + +function classifyMediaKind(contentType: string | null | undefined, fallback: CompanyArtifactMediaKind = "file") { + const normalized = (contentType ?? "").toLowerCase(); + if (!normalized) return fallback; + if (normalized.startsWith("image/")) return "image"; + if (normalized.startsWith("video/")) return "video"; + if ( + normalized.startsWith("text/") || + normalized === "application/json" || + normalized.endsWith("+json") || + normalized === "application/xml" || + normalized.endsWith("+xml") || + normalized === "application/markdown" + ) { + return "text"; + } + return "file"; +} + +function contentTypeKindCondition(contentTypeExpression: SQL, kind: CompanyArtifactsQuery["kind"]) { + if (!kind || kind === "all") return undefined; + if (kind === "image") return sql`${contentTypeExpression} ILIKE 'image/%'`; + if (kind === "video") return sql`${contentTypeExpression} ILIKE 'video/%'`; + if (kind === "text") { + return sql`(${contentTypeExpression} ILIKE 'text/%' OR ${contentTypeExpression} IN ('application/json', 'application/xml', 'application/markdown') OR ${contentTypeExpression} ILIKE '%+json' OR ${contentTypeExpression} ILIKE '%+xml')`; + } + if (kind === "file") { + return sql`NOT (${contentTypeExpression} ILIKE 'image/%' OR ${contentTypeExpression} ILIKE 'video/%' OR ${contentTypeExpression} ILIKE 'text/%' OR ${contentTypeExpression} IN ('application/json', 'application/xml', 'application/markdown') OR ${contentTypeExpression} ILIKE '%+json' OR ${contentTypeExpression} ILIKE '%+xml')`; + } + return undefined; +} + +function buildIssueHref(companyPrefix: string, identifier: string, anchor: string) { + return `/${encodeURIComponent(companyPrefix)}/issues/${encodeURIComponent(identifier)}#${anchor}`; +} + +function attachmentContentPath(attachmentId: string) { + return `/api/attachments/${attachmentId}/content`; +} + +async function readTextAttachmentPreview( + storage: StorageService | undefined, + input: { companyId: string; objectKey: string; byteSize: number }, +) { + if (!storage || input.byteSize <= 0) return null; + try { + const object = await storage.getObject(input.companyId, input.objectKey, { + range: { start: 0, end: Math.min(input.byteSize, TEXT_PREVIEW_BYTES) - 1 }, + }); + const body = await buffer(object.stream); + return normalizePreviewText(body.toString("utf8")); + } catch { + return null; + } +} + +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 company = await db + .select({ id: companies.id, issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) throw notFound("Company not found"); + + const fetchLimit = Math.min(query.limit + 1, COMPANY_ARTIFACTS_MAX_LIMIT + 1); + const q = query.q ? `%${escapeLikePattern(query.q)}%` : null; + const artifacts: CompanyArtifact[] = []; + const workProductAttachmentIds = new Set(); + + if (query.kind === "all" || query.kind === "document") { + const createdAgent = alias(agents, "document_created_agent"); + const updatedAgent = alias(agents, "document_updated_agent"); + const documentArtifactId = sql`concat('document:', ${documents.id})`; + const documentConditions: SQL[] = [ + eq(documents.companyId, companyId), + or(isNotNull(documents.createdByAgentId), isNotNull(documents.updatedByAgentId))!, + sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`, + ]; + const documentCursor = cursorCondition(sql`${documents.updatedAt}`, documentArtifactId, cursor); + if (documentCursor) documentConditions.push(documentCursor); + if (query.projectId) documentConditions.push(eq(issues.projectId, query.projectId)); + if (q) { + documentConditions.push(sql`( + coalesce(${documents.title}, '') ILIKE ${q} ESCAPE '\\' + OR ${documents.latestBody} ILIKE ${q} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${q} ESCAPE '\\' + OR ${issues.title} ILIKE ${q} ESCAPE '\\' + )`); + } + + const documentRows = await db + .select({ + artifactId: documentArtifactId, + documentId: documents.id, + issueId: issues.id, + issueIdentifier: issues.identifier, + issueTitle: issues.title, + projectId: projects.id, + projectName: projects.name, + key: issueDocuments.key, + title: documents.title, + latestBody: documents.latestBody, + createdByAgentId: sql`coalesce(${createdAgent.id}, ${updatedAgent.id})`, + createdByAgentName: sql`coalesce(${createdAgent.name}, ${updatedAgent.name})`, + updatedAt: documents.updatedAt, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .innerJoin(issues, eq(issueDocuments.issueId, issues.id)) + .leftJoin(projects, eq(issues.projectId, projects.id)) + .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); + + for (const row of documentRows) { + if (isSystemIssueDocumentKey(row.key)) continue; + const identifier = row.issueIdentifier ?? row.issueId; + artifacts.push({ + id: row.artifactId, + source: "document", + mediaKind: "document", + title: row.title ?? row.key, + previewText: normalizePreviewText(row.latestBody), + contentType: "text/markdown", + contentPath: null, + openPath: null, + downloadPath: null, + issue: { id: row.issueId, identifier, title: row.issueTitle }, + project: row.projectId && row.projectName ? { id: row.projectId, name: row.projectName } : null, + createdByAgent: row.createdByAgentId && row.createdByAgentName + ? { id: row.createdByAgentId, name: row.createdByAgentName } + : null, + updatedAt: row.updatedAt.toISOString(), + href: buildIssueHref(company.issuePrefix, identifier, `document-${row.key}`), + }); + } + } + + if (query.kind !== "document") { + const workProductAgent = alias(agents, "work_product_agent"); + const workProductArtifactId = sql`concat('work_product:', ${issueWorkProducts.id})`; + const workProductContentType = sql`coalesce(${issueWorkProducts.metadata}->>'contentType', '')`; + const workProductConditions: SQL[] = [ + eq(issueWorkProducts.companyId, companyId), + eq(issueWorkProducts.type, "artifact"), + eq(issueWorkProducts.provider, "paperclip"), + ]; + const workProductCursor = cursorCondition(sql`${issueWorkProducts.updatedAt}`, workProductArtifactId, cursor); + const workProductKind = contentTypeKindCondition(workProductContentType, query.kind); + if (workProductCursor) workProductConditions.push(workProductCursor); + if (workProductKind) workProductConditions.push(workProductKind); + if (query.projectId) workProductConditions.push(eq(issues.projectId, query.projectId)); + if (q) { + workProductConditions.push(sql`( + ${issueWorkProducts.title} ILIKE ${q} ESCAPE '\\' + OR coalesce(${issueWorkProducts.summary}, '') ILIKE ${q} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${q} ESCAPE '\\' + OR ${issues.title} ILIKE ${q} ESCAPE '\\' + )`); + } + + const workProductRows = await db + .select({ + artifactId: workProductArtifactId, + workProductId: issueWorkProducts.id, + issueId: issues.id, + issueIdentifier: issues.identifier, + issueTitle: issues.title, + projectId: projects.id, + projectName: projects.name, + title: issueWorkProducts.title, + summary: issueWorkProducts.summary, + metadata: issueWorkProducts.metadata, + createdByAgentId: workProductAgent.id, + createdByAgentName: workProductAgent.name, + updatedAt: issueWorkProducts.updatedAt, + }) + .from(issueWorkProducts) + .innerJoin(issues, eq(issueWorkProducts.issueId, issues.id)) + .leftJoin(projects, eq(issues.projectId, projects.id)) + .leftJoin(heartbeatRuns, eq(issueWorkProducts.createdByRunId, heartbeatRuns.id)) + .leftJoin(workProductAgent, eq(heartbeatRuns.agentId, workProductAgent.id)) + .where(and(...workProductConditions)) + .orderBy(desc(issueWorkProducts.updatedAt), desc(workProductArtifactId)) + .limit(fetchLimit); + + for (const row of workProductRows) { + const metadata = attachmentArtifactWorkProductMetadataSchema.safeParse(row.metadata); + const attachmentMetadata = metadata.success ? metadata.data : null; + if (attachmentMetadata) { + workProductAttachmentIds.add(attachmentMetadata.attachmentId); + } + const contentType = attachmentMetadata?.contentType ?? null; + const identifier = row.issueIdentifier ?? row.issueId; + artifacts.push({ + id: row.artifactId, + source: "work_product", + mediaKind: classifyMediaKind(contentType, attachmentMetadata ? "file" : "empty"), + title: row.title, + previewText: normalizePreviewText(row.summary), + contentType, + contentPath: attachmentMetadata?.contentPath ?? null, + openPath: attachmentMetadata?.openPath ?? (typeof row.metadata?.openPath === "string" ? row.metadata.openPath : null), + downloadPath: attachmentMetadata?.downloadPath ?? null, + issue: { id: row.issueId, identifier, title: row.issueTitle }, + project: row.projectId && row.projectName ? { id: row.projectId, name: row.projectName } : null, + createdByAgent: row.createdByAgentId && row.createdByAgentName + ? { id: row.createdByAgentId, name: row.createdByAgentName } + : null, + updatedAt: row.updatedAt.toISOString(), + href: buildIssueHref(company.issuePrefix, identifier, `work-product-${row.workProductId}`), + }); + } + + const attachmentAgent = alias(agents, "attachment_agent"); + const attachmentArtifactId = sql`concat('attachment:', ${issueAttachments.id})`; + const attachmentConditions: SQL[] = [ + eq(issueAttachments.companyId, companyId), + isNull(issueAttachments.issueCommentId), + isNotNull(assets.createdByAgentId), + ]; + const attachmentCursor = cursorCondition(sql`${issueAttachments.updatedAt}`, attachmentArtifactId, cursor); + const attachmentKind = contentTypeKindCondition(sql`${assets.contentType}`, query.kind); + if (attachmentCursor) attachmentConditions.push(attachmentCursor); + if (attachmentKind) attachmentConditions.push(attachmentKind); + if (query.projectId) attachmentConditions.push(eq(issues.projectId, query.projectId)); + if (q) { + attachmentConditions.push(sql`( + coalesce(${assets.originalFilename}, '') ILIKE ${q} ESCAPE '\\' + OR coalesce(${issues.identifier}, '') ILIKE ${q} ESCAPE '\\' + OR ${issues.title} ILIKE ${q} ESCAPE '\\' + )`); + } + + const attachmentRows = await db + .select({ + artifactId: attachmentArtifactId, + attachmentId: issueAttachments.id, + companyId: issueAttachments.companyId, + issueId: issues.id, + issueIdentifier: issues.identifier, + issueTitle: issues.title, + projectId: projects.id, + projectName: projects.name, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + originalFilename: assets.originalFilename, + createdByAgentId: attachmentAgent.id, + createdByAgentName: attachmentAgent.name, + updatedAt: issueAttachments.updatedAt, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .innerJoin(issues, eq(issueAttachments.issueId, issues.id)) + .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); + + const attachmentArtifacts = await Promise.all(attachmentRows.map(async (row): Promise => { + if (workProductAttachmentIds.has(row.attachmentId)) return null; + const mediaKind = classifyMediaKind(row.contentType); + const contentPath = attachmentContentPath(row.attachmentId); + const identifier = row.issueIdentifier ?? row.issueId; + return { + id: row.artifactId, + source: "attachment", + mediaKind, + title: row.originalFilename ?? "Attachment", + previewText: mediaKind === "text" + ? await readTextAttachmentPreview(storage, { + companyId: row.companyId, + objectKey: row.objectKey, + byteSize: row.byteSize, + }) + : null, + contentType: row.contentType, + contentPath, + openPath: contentPath, + downloadPath: `${contentPath}?download=1`, + issue: { id: row.issueId, identifier, title: row.issueTitle }, + project: row.projectId && row.projectName ? { id: row.projectId, name: row.projectName } : null, + createdByAgent: row.createdByAgentId && row.createdByAgentName + ? { id: row.createdByAgentId, name: row.createdByAgentName } + : null, + updatedAt: row.updatedAt.toISOString(), + href: buildIssueHref(company.issuePrefix, identifier, `attachment-${row.attachmentId}`), + }; + })); + + 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; + + return { artifacts: page, nextCursor }; + }, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index cb193e2fef0..c0ac9f4708b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -1,4 +1,5 @@ export { companyService } from "./companies.js"; +export { companyArtifactsService } from "./company-artifacts.js"; export { companySearchService } from "./company-search.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js";