import { and, asc, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db"; import { issueDocumentKeySchema } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; function normalizeDocumentKey(key: string) { const normalized = key.trim().toLowerCase(); const parsed = issueDocumentKeySchema.safeParse(normalized); if (!parsed.success) { throw unprocessable("Invalid document key", parsed.error.issues); } return parsed.data; } function isUniqueViolation(error: unknown): boolean { return !!error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "23505"; } export function extractLegacyPlanBody(description: string | null | undefined) { if (!description) return null; const match = /\s*([\s\S]*?)\s*<\/plan>/i.exec(description); if (!match) return null; const body = match[1]?.trim(); return body ? body : null; } function mapIssueDocumentRow( row: { id: string; companyId: string; issueId: string; key: string; title: string | null; format: string; latestBody: string; latestRevisionId: string | null; latestRevisionNumber: number; createdByAgentId: string | null; createdByUserId: string | null; updatedByAgentId: string | null; updatedByUserId: string | null; createdAt: Date; updatedAt: Date; }, includeBody: boolean, ) { return { id: row.id, companyId: row.companyId, issueId: row.issueId, key: row.key, title: row.title, format: row.format, ...(includeBody ? { body: row.latestBody } : {}), latestRevisionId: row.latestRevisionId ?? null, latestRevisionNumber: row.latestRevisionNumber, createdByAgentId: row.createdByAgentId, createdByUserId: row.createdByUserId, updatedByAgentId: row.updatedByAgentId, updatedByUserId: row.updatedByUserId, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } export function documentService(db: Db) { return { getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => { const [planDocument, documentSummaries] = await Promise.all([ db .select({ id: documents.id, companyId: documents.companyId, issueId: issueDocuments.issueId, key: issueDocuments.key, title: documents.title, format: documents.format, latestBody: documents.latestBody, latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, createdByAgentId: documents.createdByAgentId, createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, "plan"))) .then((rows) => rows[0] ?? null), db .select({ id: documents.id, companyId: documents.companyId, issueId: issueDocuments.issueId, key: issueDocuments.key, title: documents.title, format: documents.format, latestBody: documents.latestBody, latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, createdByAgentId: documents.createdByAgentId, createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(eq(issueDocuments.issueId, issue.id)) .orderBy(asc(issueDocuments.key), desc(documents.updatedAt)), ]); const legacyPlanBody = planDocument ? null : extractLegacyPlanBody(issue.description); return { planDocument: planDocument ? mapIssueDocumentRow(planDocument, true) : null, documentSummaries: documentSummaries.map((row) => mapIssueDocumentRow(row, false)), legacyPlanDocument: legacyPlanBody ? { key: "plan" as const, body: legacyPlanBody, source: "issue_description" as const, } : null, }; }, listIssueDocuments: async (issueId: string) => { const rows = await db .select({ id: documents.id, companyId: documents.companyId, issueId: issueDocuments.issueId, key: issueDocuments.key, title: documents.title, format: documents.format, latestBody: documents.latestBody, latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, createdByAgentId: documents.createdByAgentId, createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(eq(issueDocuments.issueId, issueId)) .orderBy(asc(issueDocuments.key), desc(documents.updatedAt)); return rows.map((row) => mapIssueDocumentRow(row, true)); }, getIssueDocumentByKey: async (issueId: string, rawKey: string) => { const key = normalizeDocumentKey(rawKey); const row = await db .select({ id: documents.id, companyId: documents.companyId, issueId: issueDocuments.issueId, key: issueDocuments.key, title: documents.title, format: documents.format, latestBody: documents.latestBody, latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, createdByAgentId: documents.createdByAgentId, createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))) .then((rows) => rows[0] ?? null); return row ? mapIssueDocumentRow(row, true) : null; }, listIssueDocumentRevisions: async (issueId: string, rawKey: string) => { const key = normalizeDocumentKey(rawKey); return db .select({ id: documentRevisions.id, companyId: documentRevisions.companyId, documentId: documentRevisions.documentId, issueId: issueDocuments.issueId, key: issueDocuments.key, revisionNumber: documentRevisions.revisionNumber, body: documentRevisions.body, changeSummary: documentRevisions.changeSummary, createdByAgentId: documentRevisions.createdByAgentId, createdByUserId: documentRevisions.createdByUserId, createdAt: documentRevisions.createdAt, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .innerJoin(documentRevisions, eq(documentRevisions.documentId, documents.id)) .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))) .orderBy(desc(documentRevisions.revisionNumber)); }, upsertIssueDocument: async (input: { issueId: string; key: string; title?: string | null; format: string; body: string; changeSummary?: string | null; baseRevisionId?: string | null; createdByAgentId?: string | null; createdByUserId?: string | null; }) => { const key = normalizeDocumentKey(input.key); const issue = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) .where(eq(issues.id, input.issueId)) .then((rows) => rows[0] ?? null); if (!issue) throw notFound("Issue not found"); try { return await db.transaction(async (tx) => { const now = new Date(); const existing = await tx .select({ id: documents.id, companyId: documents.companyId, issueId: issueDocuments.issueId, key: issueDocuments.key, title: documents.title, format: documents.format, latestBody: documents.latestBody, latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, createdByAgentId: documents.createdByAgentId, createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(and(eq(issueDocuments.issueId, issue.id), eq(issueDocuments.key, key))) .then((rows) => rows[0] ?? null); if (existing) { if (!input.baseRevisionId) { throw conflict("Document update requires baseRevisionId", { currentRevisionId: existing.latestRevisionId, }); } if (input.baseRevisionId !== existing.latestRevisionId) { throw conflict("Document was updated by someone else", { currentRevisionId: existing.latestRevisionId, }); } const nextRevisionNumber = existing.latestRevisionNumber + 1; const [revision] = await tx .insert(documentRevisions) .values({ companyId: issue.companyId, documentId: existing.id, revisionNumber: nextRevisionNumber, body: input.body, changeSummary: input.changeSummary ?? null, createdByAgentId: input.createdByAgentId ?? null, createdByUserId: input.createdByUserId ?? null, createdAt: now, }) .returning(); await tx .update(documents) .set({ title: input.title ?? null, format: input.format, latestBody: input.body, latestRevisionId: revision.id, latestRevisionNumber: nextRevisionNumber, updatedByAgentId: input.createdByAgentId ?? null, updatedByUserId: input.createdByUserId ?? null, updatedAt: now, }) .where(eq(documents.id, existing.id)); await tx .update(issueDocuments) .set({ updatedAt: now }) .where(eq(issueDocuments.documentId, existing.id)); return { created: false as const, document: { ...existing, title: input.title ?? null, format: input.format, body: input.body, latestRevisionId: revision.id, latestRevisionNumber: nextRevisionNumber, updatedByAgentId: input.createdByAgentId ?? null, updatedByUserId: input.createdByUserId ?? null, updatedAt: now, }, }; } if (input.baseRevisionId) { throw conflict("Document does not exist yet", { key }); } const [document] = await tx .insert(documents) .values({ companyId: issue.companyId, title: input.title ?? null, format: input.format, latestBody: input.body, latestRevisionId: null, latestRevisionNumber: 1, createdByAgentId: input.createdByAgentId ?? null, createdByUserId: input.createdByUserId ?? null, updatedByAgentId: input.createdByAgentId ?? null, updatedByUserId: input.createdByUserId ?? null, createdAt: now, updatedAt: now, }) .returning(); const [revision] = await tx .insert(documentRevisions) .values({ companyId: issue.companyId, documentId: document.id, revisionNumber: 1, body: input.body, changeSummary: input.changeSummary ?? null, createdByAgentId: input.createdByAgentId ?? null, createdByUserId: input.createdByUserId ?? null, createdAt: now, }) .returning(); await tx .update(documents) .set({ latestRevisionId: revision.id }) .where(eq(documents.id, document.id)); await tx.insert(issueDocuments).values({ companyId: issue.companyId, issueId: issue.id, documentId: document.id, key, createdAt: now, updatedAt: now, }); return { created: true as const, document: { id: document.id, companyId: issue.companyId, issueId: issue.id, key, title: document.title, format: document.format, body: document.latestBody, latestRevisionId: revision.id, latestRevisionNumber: 1, createdByAgentId: document.createdByAgentId, createdByUserId: document.createdByUserId, updatedByAgentId: document.updatedByAgentId, updatedByUserId: document.updatedByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt, }, }; }); } catch (error) { if (isUniqueViolation(error)) { throw conflict("Document key already exists on this issue", { key }); } throw error; } }, deleteIssueDocument: async (issueId: string, rawKey: string) => { const key = normalizeDocumentKey(rawKey); return db.transaction(async (tx) => { const existing = await tx .select({ id: documents.id, companyId: documents.companyId, issueId: issueDocuments.issueId, key: issueDocuments.key, title: documents.title, format: documents.format, latestBody: documents.latestBody, latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, createdByAgentId: documents.createdByAgentId, createdByUserId: documents.createdByUserId, updatedByAgentId: documents.updatedByAgentId, updatedByUserId: documents.updatedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }) .from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))) .then((rows) => rows[0] ?? null); if (!existing) return null; await tx.delete(issueDocuments).where(eq(issueDocuments.documentId, existing.id)); await tx.delete(documents).where(eq(documents.id, existing.id)); return { ...existing, body: existing.latestBody, latestRevisionId: existing.latestRevisionId ?? null, }; }); }, }; }