| import { and, asc, desc, eq } from "drizzle-orm"; |
| import type { Db } from "@penclipai/db"; |
| import { documentRevisions, documents, issueDocuments, issues } from "@penclipai/db"; |
| import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@penclipai/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) { |
| const filterSystemDocuments = <T extends { key: string }>(rows: T[], includeSystem: boolean) => |
| includeSystem ? rows : rows.filter((row) => !isSystemIssueDocumentKey(row.key)); |
|
|
| return { |
| getIssueDocumentPayload: async ( |
| issue: { id: string; description: string | null }, |
| options: { includeSystem?: boolean } = {}, |
| ) => { |
| 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: filterSystemDocuments(documentSummaries, options.includeSystem ?? false) |
| .map((row) => mapIssueDocumentRow(row, false)), |
| legacyPlanDocument: legacyPlanBody |
| ? { |
| key: "plan" as const, |
| body: legacyPlanBody, |
| source: "issue_description" as const, |
| } |
| : null, |
| }; |
| }, |
|
|
| listIssueDocuments: async (issueId: string, options: { includeSystem?: boolean } = {}) => { |
| 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 filterSystemDocuments(rows, options.includeSystem ?? false).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; |
| let restoredRevision: typeof documentRevisions.$inferSelect; |
| try { |
| [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(); |
| } catch (error) { |
| if (isUniqueViolation(error)) { |
| throw conflict("Document was updated by someone else", { |
| currentRevisionId: existing.latestRevisionId, |
| }); |
| } |
| throw error; |
| } |
|
|
| 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, |
| }; |
| }); |
| }, |
| }; |
| } |
|
|