Spaces:
Paused
Paused
| 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 = /<plan>\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, | |
| }; | |
| } | |
| const issueDocumentSelect = { | |
| 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, | |
| }; | |
| export function documentService(db: Db) { | |
| return { | |
| getIssueDocumentPayload: async (issue: { id: string; description: string | null }) => { | |
| const [planDocument, documentSummaries] = await Promise.all([ | |
| db | |
| .select(issueDocumentSelect) | |
| .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(issueDocumentSelect) | |
| .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(issueDocumentSelect) | |
| .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(issueDocumentSelect) | |
| .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, | |
| title: documentRevisions.title, | |
| format: documentRevisions.format, | |
| 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; | |
| createdByRunId?: 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, | |
| title: input.title ?? null, | |
| format: input.format, | |
| body: input.body, | |
| changeSummary: input.changeSummary ?? null, | |
| createdByAgentId: input.createdByAgentId ?? null, | |
| createdByUserId: input.createdByUserId ?? null, | |
| createdByRunId: input.createdByRunId ?? 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, | |
| title: input.title ?? null, | |
| format: input.format, | |
| body: input.body, | |
| changeSummary: input.changeSummary ?? null, | |
| createdByAgentId: input.createdByAgentId ?? null, | |
| createdByUserId: input.createdByUserId ?? null, | |
| createdByRunId: input.createdByRunId ?? 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; | |
| } | |
| }, | |
| restoreIssueDocumentRevision: async (input: { | |
| issueId: string; | |
| key: string; | |
| revisionId: string; | |
| createdByAgentId?: string | null; | |
| createdByUserId?: string | null; | |
| }) => { | |
| const key = normalizeDocumentKey(input.key); | |
| return db.transaction(async (tx) => { | |
| const existing = await tx | |
| .select(issueDocumentSelect) | |
| .from(issueDocuments) | |
| .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) | |
| .where(and(eq(issueDocuments.issueId, input.issueId), eq(issueDocuments.key, key))) | |
| .then((rows) => rows[0] ?? null); | |
| if (!existing) throw notFound("Document not found"); | |
| const revision = await tx | |
| .select({ | |
| id: documentRevisions.id, | |
| companyId: documentRevisions.companyId, | |
| documentId: documentRevisions.documentId, | |
| revisionNumber: documentRevisions.revisionNumber, | |
| title: documentRevisions.title, | |
| format: documentRevisions.format, | |
| body: documentRevisions.body, | |
| }) | |
| .from(documentRevisions) | |
| .where(and(eq(documentRevisions.id, input.revisionId), eq(documentRevisions.documentId, existing.id))) | |
| .then((rows) => rows[0] ?? null); | |
| if (!revision) throw notFound("Document revision not found"); | |
| if (existing.latestRevisionId === revision.id) { | |
| throw conflict("Selected revision is already the latest revision", { | |
| currentRevisionId: existing.latestRevisionId, | |
| }); | |
| } | |
| const now = new Date(); | |
| const nextRevisionNumber = existing.latestRevisionNumber + 1; | |
| const [restoredRevision] = await tx | |
| .insert(documentRevisions) | |
| .values({ | |
| companyId: existing.companyId, | |
| documentId: existing.id, | |
| revisionNumber: nextRevisionNumber, | |
| title: revision.title ?? null, | |
| format: revision.format, | |
| body: revision.body, | |
| changeSummary: `Restored from revision ${revision.revisionNumber}`, | |
| createdByAgentId: input.createdByAgentId ?? null, | |
| createdByUserId: input.createdByUserId ?? null, | |
| createdAt: now, | |
| }) | |
| .returning(); | |
| await tx | |
| .update(documents) | |
| .set({ | |
| title: revision.title ?? null, | |
| format: revision.format, | |
| latestBody: revision.body, | |
| latestRevisionId: restoredRevision.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 { | |
| restoredFromRevisionId: revision.id, | |
| restoredFromRevisionNumber: revision.revisionNumber, | |
| document: { | |
| ...existing, | |
| title: revision.title ?? null, | |
| format: revision.format, | |
| body: revision.body, | |
| latestRevisionId: restoredRevision.id, | |
| latestRevisionNumber: nextRevisionNumber, | |
| updatedByAgentId: input.createdByAgentId ?? null, | |
| updatedByUserId: input.createdByUserId ?? null, | |
| updatedAt: now, | |
| }, | |
| }; | |
| }); | |
| }, | |
| deleteIssueDocument: async (issueId: string, rawKey: string) => { | |
| const key = normalizeDocumentKey(rawKey); | |
| return db.transaction(async (tx) => { | |
| const existing = await tx | |
| .select(issueDocumentSelect) | |
| .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, | |
| }; | |
| }); | |
| }, | |
| }; | |
| } | |