From 33b6fab3b30bfec225353d13a70cc757e5304aeb Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 7 Jun 2026 00:52:03 +0000 Subject: [PATCH] Add document management API foundation --- .../0099_document_management_foundation.sql | 79 +++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/document_links.ts | 38 ++ packages/db/src/schema/documents.ts | 13 +- packages/db/src/schema/index.ts | 1 + packages/shared/src/constants.ts | 17 + packages/shared/src/index.ts | 20 + packages/shared/src/types/document.ts | 63 ++ packages/shared/src/types/index.ts | 7 + packages/shared/src/types/issue.ts | 8 + packages/shared/src/validators/document.ts | 55 ++ packages/shared/src/validators/index.ts | 11 + server/src/__tests__/documents-routes.test.ts | 161 +++++ .../src/__tests__/documents-service.test.ts | 33 + server/src/__tests__/openapi-routes.test.ts | 1 + server/src/app.ts | 2 + server/src/routes/documents.ts | 172 +++++ server/src/routes/index.ts | 1 + server/src/routes/openapi.ts | 84 +++ server/src/services/documents.ts | 589 +++++++++++++++++- 20 files changed, 1354 insertions(+), 8 deletions(-) create mode 100644 packages/db/src/migrations/0099_document_management_foundation.sql create mode 100644 packages/db/src/schema/document_links.ts create mode 100644 packages/shared/src/types/document.ts create mode 100644 packages/shared/src/validators/document.ts create mode 100644 server/src/__tests__/documents-routes.test.ts create mode 100644 server/src/routes/documents.ts diff --git a/packages/db/src/migrations/0099_document_management_foundation.sql b/packages/db/src/migrations/0099_document_management_foundation.sql new file mode 100644 index 00000000000..c38e5c6e3f9 --- /dev/null +++ b/packages/db/src/migrations/0099_document_management_foundation.sql @@ -0,0 +1,79 @@ +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "status" text NOT NULL DEFAULT 'draft';--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "document_type" text NOT NULL DEFAULT 'other';--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "summary" text;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "owner_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "owner_user_id" text;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "archived_by_agent_id" uuid;--> statement-breakpoint +ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "archived_by_user_id" text;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "documents" ADD CONSTRAINT "documents_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "documents" ADD CONSTRAINT "documents_archived_by_agent_id_agents_id_fk" FOREIGN KEY ("archived_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "documents_company_status_updated_idx" ON "documents" USING btree ("company_id","status","updated_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "documents_company_type_updated_idx" ON "documents" USING btree ("company_id","document_type","updated_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "documents_company_owner_agent_updated_idx" ON "documents" USING btree ("company_id","owner_agent_id","updated_at");--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "document_links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" uuid NOT NULL, + "relationship" text DEFAULT 'related' NOT NULL, + "issue_document_id" uuid, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_links" ADD CONSTRAINT "document_links_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_links" ADD CONSTRAINT "document_links_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_links" ADD CONSTRAINT "document_links_issue_document_id_issue_documents_id_fk" FOREIGN KEY ("issue_document_id") REFERENCES "public"."issue_documents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_links" ADD CONSTRAINT "document_links_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "document_links_document_target_uq" ON "document_links" USING btree ("company_id","document_id","target_type","target_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_links_company_target_idx" ON "document_links" USING btree ("company_id","target_type","target_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_links_company_document_idx" ON "document_links" USING btree ("company_id","document_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_links_issue_document_idx" ON "document_links" USING btree ("issue_document_id");--> statement-breakpoint +INSERT INTO "document_links" ( + "company_id", + "document_id", + "target_type", + "target_id", + "relationship", + "issue_document_id", + "created_at", + "updated_at" +) +SELECT + "company_id", + "document_id", + 'issue', + "issue_id", + 'issue_document', + "id", + "created_at", + "updated_at" +FROM "issue_documents" +ON CONFLICT ("company_id","document_id","target_type","target_id") DO NOTHING; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 96c9209ead3..235ddf7b686 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -694,6 +694,13 @@ "when": 1780534200000, "tag": "0098_project_icon", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1780794000000, + "tag": "0099_document_management_foundation", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/document_links.ts b/packages/db/src/schema/document_links.ts new file mode 100644 index 00000000000..9b10365ea6c --- /dev/null +++ b/packages/db/src/schema/document_links.ts @@ -0,0 +1,38 @@ +import type { DocumentLinkTargetType } from "@paperclipai/shared"; +import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { documents } from "./documents.js"; +import { issueDocuments } from "./issue_documents.js"; + +export const documentLinks = pgTable( + "document_links", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + targetType: text("target_type").$type().notNull(), + targetId: uuid("target_id").notNull(), + relationship: text("relationship").notNull().default("related"), + issueDocumentId: uuid("issue_document_id").references(() => issueDocuments.id, { onDelete: "cascade" }), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + documentTargetUq: uniqueIndex("document_links_document_target_uq").on( + table.companyId, + table.documentId, + table.targetType, + table.targetId, + ), + companyTargetIdx: index("document_links_company_target_idx").on( + table.companyId, + table.targetType, + table.targetId, + ), + companyDocumentIdx: index("document_links_company_document_idx").on(table.companyId, table.documentId), + issueDocumentIdx: index("document_links_issue_document_idx").on(table.issueDocumentId), + }), +); diff --git a/packages/db/src/schema/documents.ts b/packages/db/src/schema/documents.ts index 7dcc0f4578d..7a4748ca319 100644 --- a/packages/db/src/schema/documents.ts +++ b/packages/db/src/schema/documents.ts @@ -1,5 +1,5 @@ import { pgTable, uuid, text, integer, timestamp, index, jsonb } from "drizzle-orm/pg-core"; -import type { SourceTrustMetadata } from "@paperclipai/shared"; +import type { DocumentStatus, DocumentType, SourceTrustMetadata } from "@paperclipai/shared"; import { companies } from "./companies.js"; import { agents } from "./agents.js"; @@ -10,6 +10,11 @@ export const documents = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), title: text("title"), format: text("format").notNull().default("markdown"), + status: text("status").$type().notNull().default("draft"), + documentType: text("document_type").$type().notNull().default("other"), + summary: text("summary"), + ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }), + ownerUserId: text("owner_user_id"), latestBody: text("latest_body").notNull(), latestRevisionId: uuid("latest_revision_id"), latestRevisionNumber: integer("latest_revision_number").notNull().default(1), @@ -21,12 +26,18 @@ export const documents = pgTable( lockedByAgentId: uuid("locked_by_agent_id").references(() => agents.id, { onDelete: "set null" }), lockedByUserId: text("locked_by_user_id"), sourceTrust: jsonb("source_trust").$type(), + archivedAt: timestamp("archived_at", { withTimezone: true }), + archivedByAgentId: uuid("archived_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + archivedByUserId: text("archived_by_user_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt), companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt), + companyStatusUpdatedIdx: index("documents_company_status_updated_idx").on(table.companyId, table.status, table.updatedAt), + companyTypeUpdatedIdx: index("documents_company_type_updated_idx").on(table.companyId, table.documentType, table.updatedAt), + companyOwnerAgentUpdatedIdx: index("documents_company_owner_agent_updated_idx").on(table.companyId, table.ownerAgentId, table.updatedAt), titleSearchIdx: index("documents_title_search_idx").using("gin", table.title.op("gin_trgm_ops")), bodySearchIdx: index("documents_latest_body_search_idx").using("gin", table.latestBody.op("gin_trgm_ops")), }), diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 9d78a33cbd2..22098f7c390 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -54,6 +54,7 @@ export { issueReadStates } from "./issue_read_states.js"; export { assets } from "./assets.js"; export { issueAttachments } from "./issue_attachments.js"; export { documents } from "./documents.js"; +export { documentLinks } from "./document_links.js"; export { documentRevisions } from "./document_revisions.js"; export { issueDocuments } from "./issue_documents.js"; export { documentAnnotationThreads } from "./document_annotation_threads.js"; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3a8e7d7c3c2..b9a9767a0cd 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -351,6 +351,23 @@ export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [ export type DocumentAnnotationAnchorConfidence = (typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number]; +export const DOCUMENT_STATUSES = ["draft", "in_review", "approved", "archived"] as const; +export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]; + +export const DOCUMENT_TYPES = ["plan", "spec", "brief", "report", "other"] as const; +export type DocumentType = (typeof DOCUMENT_TYPES)[number]; + +export const DOCUMENT_LINK_TARGET_TYPES = [ + "issue", + "project", + "goal", + "run", + "work_product", + "approval", + "agent", +] as const; +export type DocumentLinkTargetType = (typeof DOCUMENT_LINK_TARGET_TYPES)[number]; + export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const; export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7744af39364..7bbbdfeb7bf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -90,6 +90,9 @@ export { DOCUMENT_ANNOTATION_THREAD_STATUSES, DOCUMENT_ANNOTATION_ANCHOR_STATES, DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES, + DOCUMENT_STATUSES, + DOCUMENT_TYPES, + DOCUMENT_LINK_TARGET_TYPES, ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, ISSUE_MONITOR_SCHEDULED_BY, @@ -214,6 +217,9 @@ export { type DocumentAnnotationThreadStatus, type DocumentAnnotationAnchorState, type DocumentAnnotationAnchorConfidence, + type DocumentStatus, + type DocumentType, + type DocumentLinkTargetType, type IssueExecutionPolicyMode, type IssueExecutionStageType, type IssueMonitorScheduledBy, @@ -488,6 +494,11 @@ export type { DocumentTextPosition, DocumentTextProjection, DocumentTextRange, + CompanyDocument, + CompanyDocumentSummary, + DocumentBacklink, + DocumentFeedbackCounts, + DocumentLink, UpdateDocumentAnnotationThreadRequest, AttachmentArtifactWorkProductMetadata, Issue, @@ -1040,12 +1051,21 @@ export { issueDocumentKeySchema, upsertIssueDocumentSchema, restoreIssueDocumentRevisionSchema, + companyDocumentListQuerySchema, + createDocumentLinkSchema, + documentLinkTargetTypeSchema, + documentStatusSchema, + documentTypeSchema, + updateDocumentMetadataSchema, createIssueTreeHoldSchema, issueTreeControlModeSchema, issueTreeHoldReleasePolicySchema, previewIssueTreeControlSchema, releaseIssueTreeHoldSchema, type CreateIssue, + type CompanyDocumentListQuery, + type CreateDocumentLink, + type UpdateDocumentMetadata, type CreateChildIssue, type CreateAcceptedPlanDecomposition, type CreateIssueLabel, diff --git a/packages/shared/src/types/document.ts b/packages/shared/src/types/document.ts new file mode 100644 index 00000000000..96d8625095e --- /dev/null +++ b/packages/shared/src/types/document.ts @@ -0,0 +1,63 @@ +import type { + DocumentLinkTargetType, + DocumentStatus, + DocumentType, +} from "../constants.js"; +import type { SourceTrustMetadata } from "../trust-policy.js"; + +export interface DocumentBacklink { + id: string; + companyId: string; + documentId: string; + targetType: DocumentLinkTargetType; + targetId: string; + relationship: string; + issueDocumentId: string | null; + issueDocumentKey: string | null; + title: string | null; + identifier: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface DocumentFeedbackCounts { + openComments: number; + resolvedComments: number; + staleAnchors: number; + orphanedAnchors: number; +} + +export interface CompanyDocumentSummary { + id: string; + companyId: string; + title: string | null; + format: "markdown"; + status: DocumentStatus; + documentType: DocumentType; + summary: string | null; + ownerAgentId: string | null; + ownerUserId: string | null; + latestRevisionId: string | null; + latestRevisionNumber: number; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + lockedAt: Date | null; + lockedByAgentId: string | null; + lockedByUserId: string | null; + sourceTrust?: SourceTrustMetadata | null; + archivedAt: Date | null; + archivedByAgentId: string | null; + archivedByUserId: string | null; + createdAt: Date; + updatedAt: Date; + backlinks: DocumentBacklink[]; + feedbackCounts: DocumentFeedbackCounts; +} + +export interface CompanyDocument extends CompanyDocumentSummary { + body: string; +} + +export interface DocumentLink extends DocumentBacklink {} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b1ea7661f26..ad18100998d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -168,6 +168,13 @@ export type { DocumentTextRange, UpdateDocumentAnnotationThreadRequest, } from "./document-annotation.js"; +export type { + CompanyDocument, + CompanyDocumentSummary, + DocumentBacklink, + DocumentFeedbackCounts, + DocumentLink, +} from "./document.js"; export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { CompanySearchHighlight, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 100df099aa0..2338bb53062 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -95,6 +95,14 @@ export interface IssueDocumentSummary { key: string; title: string | null; format: DocumentFormat; + status?: import("../constants.js").DocumentStatus; + documentType?: import("../constants.js").DocumentType; + summary?: string | null; + ownerAgentId?: string | null; + ownerUserId?: string | null; + archivedAt?: Date | null; + archivedByAgentId?: string | null; + archivedByUserId?: string | null; latestRevisionId: string | null; latestRevisionNumber: number; createdByAgentId: string | null; diff --git a/packages/shared/src/validators/document.ts b/packages/shared/src/validators/document.ts new file mode 100644 index 00000000000..7f9e0f8bd49 --- /dev/null +++ b/packages/shared/src/validators/document.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { + DOCUMENT_LINK_TARGET_TYPES, + DOCUMENT_STATUSES, + DOCUMENT_TYPES, +} from "../constants.js"; + +const stringList = (item: z.ZodTypeAny) => + z.union([item, z.array(item)]).optional().transform((value) => { + if (value === undefined) return undefined; + return Array.isArray(value) ? value : [value]; + }); + +export const documentStatusSchema = z.enum(DOCUMENT_STATUSES); +export const documentTypeSchema = z.enum(DOCUMENT_TYPES); +export const documentLinkTargetTypeSchema = z.enum(DOCUMENT_LINK_TARGET_TYPES); + +export const companyDocumentListQuerySchema = z.object({ + q: z.string().trim().min(1).max(200).optional(), + status: stringList(documentStatusSchema), + type: stringList(documentTypeSchema), + ownerAgentId: z.string().uuid().optional(), + ownerUserId: z.string().trim().min(1).max(200).optional(), + targetType: documentLinkTargetTypeSchema.optional(), + targetId: z.string().uuid().optional(), + projectId: z.string().uuid().optional(), + hasOpenFeedback: z.coerce.boolean().optional(), + trustedOnly: z.coerce.boolean().optional(), + includeArchived: z.coerce.boolean().optional(), + updatedAfter: z.coerce.date().optional(), + updatedBefore: z.coerce.date().optional(), + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), +}).strict(); + +export const updateDocumentMetadataSchema = z.object({ + title: z.string().trim().min(1).max(300).nullable().optional(), + status: documentStatusSchema.optional(), + documentType: documentTypeSchema.optional(), + summary: z.string().trim().max(2_000).nullable().optional(), + ownerAgentId: z.string().uuid().nullable().optional(), + ownerUserId: z.string().trim().min(1).max(200).nullable().optional(), +}).strict().refine((value) => Object.keys(value).length > 0, { + message: "At least one field must be provided", +}); + +export const createDocumentLinkSchema = z.object({ + targetType: documentLinkTargetTypeSchema, + targetId: z.string().uuid(), + relationship: z.string().trim().min(1).max(80).optional().default("related"), +}).strict(); + +export type CompanyDocumentListQuery = z.infer; +export type UpdateDocumentMetadata = z.infer; +export type CreateDocumentLink = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 1595a680e03..eea29cca8d7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -56,6 +56,17 @@ export { updateResourceMembershipSchema, type UpdateResourceMembership, } from "./resource-memberships.js"; +export { + companyDocumentListQuerySchema, + createDocumentLinkSchema, + documentLinkTargetTypeSchema, + documentStatusSchema, + documentTypeSchema, + updateDocumentMetadataSchema, + type CompanyDocumentListQuery, + type CreateDocumentLink, + type UpdateDocumentMetadata, +} from "./document.js"; export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, diff --git a/server/src/__tests__/documents-routes.test.ts b/server/src/__tests__/documents-routes.test.ts new file mode 100644 index 00000000000..ad7601c5c89 --- /dev/null +++ b/server/src/__tests__/documents-routes.test.ts @@ -0,0 +1,161 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + companies, + createDb, + documentLinks, + documentRevisions, + documents, + issueDocuments, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { documentRoutes } from "../routes/documents.js"; +import { documentService } from "../services/documents.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres document route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("document routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-documents-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(documentRevisions); + await db.delete(documentLinks); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issues); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId: "local-board", + userName: "Local Board", + userEmail: null, + isInstanceAdmin: true, + source: "local_implicit", + }; + next(); + }); + app.use("/api", documentRoutes(db)); + app.use(errorHandler); + return app; + } + + async function seedCompany(label: string) { + const id = randomUUID(); + await db.insert(companies).values({ + id, + name: `Company ${label}`, + issuePrefix: `D${label}${id.replace(/-/g, "").slice(0, 5).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + return id; + } + + async function seedIssueDocument(companyId: string, identifier: string, body: string) { + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, + companyId, + identifier, + title: `Issue ${identifier}`, + description: null, + status: "in_progress", + priority: "medium", + }); + const result = await documentService(db).upsertIssueDocument({ + issueId, + key: "plan", + title: `${identifier} plan`, + format: "markdown", + body, + }); + return { issueId, document: result.document }; + } + + it("lists and searches only documents in the requested company", async () => { + const companyA = await seedCompany("A"); + const companyB = await seedCompany("B"); + const docA = await seedIssueDocument(companyA, "DA-1", "Alpha roadmap"); + const docB = await seedIssueDocument(companyB, "DB-1", "Alpha secret"); + + const app = createApp(); + + const list = await request(app).get(`/api/companies/${companyA}/documents`).expect(200); + expect(list.body.map((doc: { id: string }) => doc.id)).toEqual([docA.document.id]); + + const search = await request(app) + .get(`/api/companies/${companyA}/documents`) + .query({ q: "Alpha" }) + .expect(200); + expect(search.body.map((doc: { id: string }) => doc.id)).toEqual([docA.document.id]); + + await request(app).get(`/api/companies/${companyA}/documents/${docB.document.id}`).expect(404); + }); + + it("rejects document links to targets from another company and logs same-company link mutations", async () => { + const companyA = await seedCompany("A"); + const companyB = await seedCompany("B"); + const docA = await seedIssueDocument(companyA, "DA-1", "Linkable plan"); + const issueB = await seedIssueDocument(companyB, "DB-1", "Other plan"); + const linkedIssue = await seedIssueDocument(companyA, "DA-2", "Related plan"); + + const app = createApp(); + + await request(app) + .post(`/api/companies/${companyA}/documents/${docA.document.id}/links`) + .send({ targetType: "issue", targetId: issueB.issueId }) + .expect(404); + + const created = await request(app) + .post(`/api/companies/${companyA}/documents/${docA.document.id}/links`) + .send({ targetType: "issue", targetId: linkedIssue.issueId, relationship: "supporting" }) + .expect(201); + + expect(created.body).toEqual(expect.objectContaining({ + documentId: docA.document.id, + targetType: "issue", + targetId: linkedIssue.issueId, + relationship: "supporting", + })); + + const activity = await db.select().from(activityLog); + expect(activity).toEqual(expect.arrayContaining([ + expect.objectContaining({ + companyId: companyA, + action: "document.link_created", + entityId: docA.document.id, + }), + ])); + }); +}); diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts index 44a6d3b1844..752671efde3 100644 --- a/server/src/__tests__/documents-service.test.ts +++ b/server/src/__tests__/documents-service.test.ts @@ -3,6 +3,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { companies, createDb, + documentLinks, documentRevisions, documents, issueDocuments, @@ -37,6 +38,7 @@ describeEmbeddedPostgres("documentService system issue documents", () => { afterEach(async () => { await db.delete(documentRevisions); + await db.delete(documentLinks); await db.delete(issueDocuments); await db.delete(documents); await db.delete(issues); @@ -193,4 +195,35 @@ describeEmbeddedPostgres("documentService system issue documents", () => { lockedAt: null, })); }); + + it("maintains document links for issue-document compatibility and company library queries", async () => { + const { issueId } = await createIssueWithDocuments(); + + const plan = await svc.getIssueDocumentByKey(issueId, "plan"); + expect(plan).not.toBeNull(); + + const links = await db.select().from(documentLinks); + expect(links).toEqual(expect.arrayContaining([ + expect.objectContaining({ + documentId: plan!.id, + targetType: "issue", + targetId: issueId, + relationship: "issue_document", + }), + ])); + + const docs = await svc.listCompanyDocuments(plan!.companyId, { + limit: 50, + offset: 0, + }); + expect(docs.map((doc) => doc.id)).toContain(plan!.id); + expect(docs.find((doc) => doc.id === plan!.id)?.backlinks).toEqual(expect.arrayContaining([ + expect.objectContaining({ + targetType: "issue", + targetId: issueId, + issueDocumentKey: "plan", + identifier: "PAP-1600", + }), + ])); + }); }); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 36d0bafbb3d..34de5fe67d0 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -23,6 +23,7 @@ const apiPrefixes: Record = { "company-skills.ts": "/api", "costs.ts": "/api", "dashboard.ts": "/api", + "documents.ts": "/api", "environments.ts": "/api", "execution-workspaces.ts": "/api", "goals.ts": "/api", diff --git a/server/src/app.ts b/server/src/app.ts index 40061e117af..38adf741414 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; +import { documentRoutes } from "./routes/documents.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; import { routineRoutes } from "./routes/routines.js"; import { environmentRoutes } from "./routes/environments.js"; @@ -217,6 +218,7 @@ export async function createApp( api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); + api.use(documentRoutes(db)); api.use(issueRoutes(db, opts.storageService, { feedbackExportService: opts.feedbackExportService, pluginWorkerManager: workerManager, diff --git a/server/src/routes/documents.ts b/server/src/routes/documents.ts new file mode 100644 index 00000000000..5a40079fcb8 --- /dev/null +++ b/server/src/routes/documents.ts @@ -0,0 +1,172 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { + companyDocumentListQuerySchema, + createDocumentLinkSchema, + updateDocumentMetadataSchema, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { accessService, documentService, logActivity, projectService } from "../services/index.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function documentRoutes(db: Db) { + const router = Router(); + const documents = documentService(db); + const projects = projectService(db); + const access = accessService(db); + + router.get("/companies/:companyId/documents", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const query = companyDocumentListQuerySchema.parse(req.query); + res.json(await documents.listCompanyDocuments(companyId, query)); + }); + + router.get("/projects/:projectId/documents", async (req, res) => { + const projectId = req.params.projectId as string; + const project = await projects.getById(projectId); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, project.companyId); + const decision = await access.decide({ + actor: req.actor, + action: "project:read", + resource: { type: "project", companyId: project.companyId, projectId: project.id }, + }); + if (!decision.allowed) { + res.status(403).json({ error: "Project is outside this actor's authorization boundary" }); + return; + } + const query = companyDocumentListQuerySchema.parse({ + ...req.query, + projectId: project.id, + }); + res.json(await documents.listCompanyDocuments(project.companyId, query)); + }); + + router.get("/companies/:companyId/documents/:documentId", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const document = await documents.getCompanyDocumentById(companyId, req.params.documentId as string); + if (!document) { + res.status(404).json({ error: "Document not found" }); + return; + } + res.json(document); + }); + + router.get("/companies/:companyId/documents/:documentId/backlinks", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await documents.listDocumentBacklinks(companyId, req.params.documentId as string)); + }); + + router.patch( + "/companies/:companyId/documents/:documentId", + validate(updateDocumentMetadataSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const documentId = req.params.documentId as string; + assertCompanyAccess(req, companyId); + const actor = getActorInfo(req); + const document = await documents.updateDocumentMetadata({ + companyId, + documentId, + ...req.body, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "document.metadata_updated", + entityType: "document", + entityId: document.id, + details: { + status: document.status, + documentType: document.documentType, + ownerAgentId: document.ownerAgentId, + ownerUserId: document.ownerUserId, + archivedAt: document.archivedAt, + }, + }); + res.json(document); + }, + ); + + router.post( + "/companies/:companyId/documents/:documentId/links", + validate(createDocumentLinkSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const documentId = req.params.documentId as string; + assertCompanyAccess(req, companyId); + const actor = getActorInfo(req); + const link = await documents.createDocumentLink({ + companyId, + documentId, + targetType: req.body.targetType, + targetId: req.body.targetId, + relationship: req.body.relationship, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "document.link_created", + entityType: "document", + entityId: documentId, + details: link + ? { + linkId: link.id, + targetType: link.targetType, + targetId: link.targetId, + relationship: link.relationship, + } + : null, + }); + res.status(201).json(link); + }, + ); + + router.delete("/companies/:companyId/documents/:documentId/links/:linkId", async (req, res) => { + const companyId = req.params.companyId as string; + const documentId = req.params.documentId as string; + const linkId = req.params.linkId as string; + assertCompanyAccess(req, companyId); + const removed = await documents.deleteDocumentLink(companyId, documentId, linkId); + if (!removed) { + res.status(404).json({ error: "Document link not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "document.link_deleted", + entityType: "document", + entityId: documentId, + details: { + linkId: removed.id, + targetType: removed.targetType, + targetId: removed.targetId, + relationship: removed.relationship, + }, + }); + res.status(204).end(); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 274af72381e..93926ab25dc 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -5,6 +5,7 @@ export { teamsCatalogRoutes } from "./teams-catalog.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; export { issueRoutes } from "./issues.js"; +export { documentRoutes } from "./documents.js"; export { issueTreeControlRoutes } from "./issue-tree-control.js"; export { routineRoutes } from "./routines.js"; export { goalRoutes } from "./goals.js"; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 7a0b8fb6821..dba4263196f 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -25,6 +25,9 @@ import { updateIssueWorkProductSchema, upsertIssueDocumentSchema, restoreIssueDocumentRevisionSchema, + companyDocumentListQuerySchema, + createDocumentLinkSchema, + updateDocumentMetadataSchema, upsertIssueFeedbackVoteSchema, // Project createProjectSchema, @@ -418,6 +421,10 @@ const responses = { description: "Not found", content: { "application/json": { schema: ErrorSchema } }, }, + conflict: { + description: "Conflict", + content: { "application/json": { schema: ErrorSchema } }, + }, unprocessable: { description: "Unprocessable entity", content: { "application/json": { schema: ErrorSchema } }, @@ -1409,6 +1416,83 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/documents", + tags: ["documents"], + summary: "List company documents", + request: { + params: z.object({ companyId: z.string().uuid() }), + query: companyDocumentListQuerySchema, + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "get", + path: "/api/projects/{projectId}/documents", + tags: ["documents"], + summary: "List project-linked documents", + request: { + params: z.object({ projectId: z.string().uuid() }), + query: companyDocumentListQuerySchema.partial({ projectId: true }), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/documents/{documentId}", + tags: ["documents"], + summary: "Get a company document", + request: { params: z.object({ companyId: z.string().uuid(), documentId: z.string().uuid() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/documents/{documentId}", + tags: ["documents"], + summary: "Update document metadata", + request: { + params: z.object({ companyId: z.string().uuid(), documentId: z.string().uuid() }), + body: jsonBody(updateDocumentMetadataSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/documents/{documentId}/backlinks", + tags: ["documents"], + summary: "List document backlinks", + request: { params: z.object({ companyId: z.string().uuid(), documentId: z.string().uuid() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/documents/{documentId}/links", + tags: ["documents"], + summary: "Create or update a document link", + request: { + params: z.object({ companyId: z.string().uuid(), documentId: z.string().uuid() }), + body: jsonBody(createDocumentLinkSchema), + }, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/documents/{documentId}/links/{linkId}", + tags: ["documents"], + summary: "Delete a document link", + request: { + params: z.object({ companyId: z.string().uuid(), documentId: z.string().uuid(), linkId: z.string().uuid() }), + }, + responses: { 204: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + registry.registerPath({ method: "get", path: "/api/issues/{id}/documents/{key}", diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 78d57f92aef..05ff5d5f090 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -1,7 +1,29 @@ -import { and, asc, desc, eq } from "drizzle-orm"; +import { and, asc, desc, eq, gte, ilike, inArray, lte, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { documentRevisions, documents, issueDocuments, issues } from "@paperclipai/db"; -import { isSystemIssueDocumentKey, issueDocumentKeySchema } from "@paperclipai/shared"; +import { + agents, + approvals, + documentAnnotationThreads, + documentLinks, + documentRevisions, + documents, + goals, + heartbeatRuns, + issueDocuments, + issues, + issueWorkProducts, + projects, +} from "@paperclipai/db"; +import { + type CompanyDocumentListQuery, + type DocumentBacklink, + type DocumentFeedbackCounts, + type DocumentLinkTargetType, + type DocumentStatus, + type DocumentType, + isSystemIssueDocumentKey, + issueDocumentKeySchema, +} from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; function normalizeDocumentKey(key: string) { @@ -47,6 +69,11 @@ function mapIssueDocumentRow( key: string; title: string | null; format: string; + status: typeof documents.$inferSelect.status; + documentType: typeof documents.$inferSelect.documentType; + summary: string | null; + ownerAgentId: string | null; + ownerUserId: string | null; latestBody: string; latestRevisionId: string | null; latestRevisionNumber: number; @@ -58,6 +85,9 @@ function mapIssueDocumentRow( lockedByAgentId: string | null; lockedByUserId: string | null; sourceTrust: typeof documents.$inferSelect.sourceTrust; + archivedAt: Date | null; + archivedByAgentId: string | null; + archivedByUserId: string | null; createdAt: Date; updatedAt: Date; }, @@ -70,6 +100,11 @@ function mapIssueDocumentRow( key: row.key, title: row.title, format: row.format, + status: row.status, + documentType: row.documentType, + summary: row.summary, + ownerAgentId: row.ownerAgentId, + ownerUserId: row.ownerUserId, ...(includeBody ? { body: row.latestBody } : {}), latestRevisionId: row.latestRevisionId ?? null, latestRevisionNumber: row.latestRevisionNumber, @@ -81,6 +116,9 @@ function mapIssueDocumentRow( lockedByAgentId: row.lockedByAgentId, lockedByUserId: row.lockedByUserId, sourceTrust: row.sourceTrust ?? null, + archivedAt: row.archivedAt, + archivedByAgentId: row.archivedByAgentId, + archivedByUserId: row.archivedByUserId, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -93,6 +131,11 @@ const issueDocumentSelect = { key: issueDocuments.key, title: documents.title, format: documents.format, + status: documents.status, + documentType: documents.documentType, + summary: documents.summary, + ownerAgentId: documents.ownerAgentId, + ownerUserId: documents.ownerUserId, latestBody: documents.latestBody, latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, @@ -104,15 +147,505 @@ const issueDocumentSelect = { lockedByAgentId: documents.lockedByAgentId, lockedByUserId: documents.lockedByUserId, sourceTrust: documents.sourceTrust, + archivedAt: documents.archivedAt, + archivedByAgentId: documents.archivedByAgentId, + archivedByUserId: documents.archivedByUserId, createdAt: documents.createdAt, updatedAt: documents.updatedAt, }; +const documentSelect = { + id: documents.id, + companyId: documents.companyId, + title: documents.title, + format: documents.format, + status: documents.status, + documentType: documents.documentType, + summary: documents.summary, + ownerAgentId: documents.ownerAgentId, + ownerUserId: documents.ownerUserId, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + createdByAgentId: documents.createdByAgentId, + createdByUserId: documents.createdByUserId, + updatedByAgentId: documents.updatedByAgentId, + updatedByUserId: documents.updatedByUserId, + lockedAt: documents.lockedAt, + lockedByAgentId: documents.lockedByAgentId, + lockedByUserId: documents.lockedByUserId, + sourceTrust: documents.sourceTrust, + archivedAt: documents.archivedAt, + archivedByAgentId: documents.archivedByAgentId, + archivedByUserId: documents.archivedByUserId, + createdAt: documents.createdAt, + updatedAt: documents.updatedAt, +}; + +function mapDocumentRow( + row: typeof documents.$inferSelect, + includeBody: boolean, + backlinks: DocumentBacklink[] = [], + feedbackCounts: DocumentFeedbackCounts = emptyFeedbackCounts(), +) { + return { + id: row.id, + companyId: row.companyId, + title: row.title, + format: row.format as "markdown", + status: row.status, + documentType: row.documentType, + summary: row.summary, + ownerAgentId: row.ownerAgentId, + ownerUserId: row.ownerUserId, + ...(includeBody ? { body: row.latestBody } : {}), + latestRevisionId: row.latestRevisionId ?? null, + latestRevisionNumber: row.latestRevisionNumber, + createdByAgentId: row.createdByAgentId, + createdByUserId: row.createdByUserId, + updatedByAgentId: row.updatedByAgentId, + updatedByUserId: row.updatedByUserId, + lockedAt: row.lockedAt, + lockedByAgentId: row.lockedByAgentId, + lockedByUserId: row.lockedByUserId, + sourceTrust: row.sourceTrust ?? null, + archivedAt: row.archivedAt, + archivedByAgentId: row.archivedByAgentId, + archivedByUserId: row.archivedByUserId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + backlinks, + feedbackCounts, + }; +} + +function emptyFeedbackCounts(): DocumentFeedbackCounts { + return { + openComments: 0, + resolvedComments: 0, + staleAnchors: 0, + orphanedAnchors: 0, + }; +} + +function normalizeRelationship(value: string | null | undefined) { + const normalized = (value ?? "related").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "_"); + return normalized.slice(0, 80) || "related"; +} + export function documentService(db: Db) { const filterSystemDocuments = (rows: T[], includeSystem: boolean) => includeSystem ? rows : rows.filter((row) => !isSystemIssueDocumentKey(row.key)); + async function feedbackCountsForDocuments(companyId: string, documentIds: string[]) { + const result = new Map(); + if (documentIds.length === 0) return result; + + const rows = await db + .select({ + documentId: documentAnnotationThreads.documentId, + openComments: sql`count(*) filter (where ${documentAnnotationThreads.status} = 'open')`.mapWith(Number), + resolvedComments: sql`count(*) filter (where ${documentAnnotationThreads.status} = 'resolved')`.mapWith(Number), + staleAnchors: sql`count(*) filter (where ${documentAnnotationThreads.anchorState} = 'stale')`.mapWith(Number), + orphanedAnchors: sql`count(*) filter (where ${documentAnnotationThreads.anchorState} = 'orphaned')`.mapWith(Number), + }) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.companyId, companyId), + inArray(documentAnnotationThreads.documentId, documentIds), + )) + .groupBy(documentAnnotationThreads.documentId); + + for (const row of rows) { + result.set(row.documentId, { + openComments: row.openComments, + resolvedComments: row.resolvedComments, + staleAnchors: row.staleAnchors, + orphanedAnchors: row.orphanedAnchors, + }); + } + return result; + } + + async function targetTitles(companyId: string, links: Array<{ targetType: DocumentLinkTargetType; targetId: string }>) { + const result = new Map(); + const idsByType = new Map(); + for (const link of links) { + idsByType.set(link.targetType, [...(idsByType.get(link.targetType) ?? []), link.targetId]); + } + + const put = (type: DocumentLinkTargetType, id: string, value: { title: string | null; identifier: string | null }) => { + result.set(`${type}:${id}`, value); + }; + + const issueIds = idsByType.get("issue") ?? []; + if (issueIds.length > 0) { + const rows = await db + .select({ id: issues.id, title: issues.title, identifier: issues.identifier }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds))); + for (const row of rows) put("issue", row.id, { title: row.title, identifier: row.identifier }); + } + + const projectIds = idsByType.get("project") ?? []; + if (projectIds.length > 0) { + const rows = await db + .select({ id: projects.id, title: projects.name }) + .from(projects) + .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds))); + for (const row of rows) put("project", row.id, { title: row.title, identifier: null }); + } + + const goalIds = idsByType.get("goal") ?? []; + if (goalIds.length > 0) { + const rows = await db + .select({ id: goals.id, title: goals.title }) + .from(goals) + .where(and(eq(goals.companyId, companyId), inArray(goals.id, goalIds))); + for (const row of rows) put("goal", row.id, { title: row.title, identifier: null }); + } + + const agentIds = idsByType.get("agent") ?? []; + if (agentIds.length > 0) { + const rows = await db + .select({ id: agents.id, title: agents.name }) + .from(agents) + .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))); + for (const row of rows) put("agent", row.id, { title: row.title, identifier: null }); + } + + const approvalIds = idsByType.get("approval") ?? []; + if (approvalIds.length > 0) { + const rows = await db + .select({ id: approvals.id, type: approvals.type, status: approvals.status }) + .from(approvals) + .where(and(eq(approvals.companyId, companyId), inArray(approvals.id, approvalIds))); + for (const row of rows) put("approval", row.id, { title: `${row.type} (${row.status})`, identifier: null }); + } + + const runIds = idsByType.get("run") ?? []; + if (runIds.length > 0) { + const rows = await db + .select({ id: heartbeatRuns.id, status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds))); + for (const row of rows) put("run", row.id, { title: `Run ${row.status}`, identifier: null }); + } + + const workProductIds = idsByType.get("work_product") ?? []; + if (workProductIds.length > 0) { + const rows = await db + .select({ id: issueWorkProducts.id, title: issueWorkProducts.title }) + .from(issueWorkProducts) + .where(and(eq(issueWorkProducts.companyId, companyId), inArray(issueWorkProducts.id, workProductIds))); + for (const row of rows) put("work_product", row.id, { title: row.title, identifier: null }); + } + + return result; + } + + async function backlinksForDocuments(companyId: string, documentIds: string[]) { + const result = new Map(); + if (documentIds.length === 0) return result; + const rows = await db + .select({ + id: documentLinks.id, + companyId: documentLinks.companyId, + documentId: documentLinks.documentId, + targetType: documentLinks.targetType, + targetId: documentLinks.targetId, + relationship: documentLinks.relationship, + issueDocumentId: documentLinks.issueDocumentId, + issueDocumentKey: issueDocuments.key, + createdAt: documentLinks.createdAt, + updatedAt: documentLinks.updatedAt, + }) + .from(documentLinks) + .leftJoin(issueDocuments, eq(documentLinks.issueDocumentId, issueDocuments.id)) + .where(and(eq(documentLinks.companyId, companyId), inArray(documentLinks.documentId, documentIds))) + .orderBy(asc(documentLinks.targetType), asc(documentLinks.createdAt)); + + const titles = await targetTitles(companyId, rows); + for (const row of rows) { + const target = titles.get(`${row.targetType}:${row.targetId}`) ?? { title: null, identifier: null }; + const backlink: DocumentBacklink = { + id: row.id, + companyId: row.companyId, + documentId: row.documentId, + targetType: row.targetType, + targetId: row.targetId, + relationship: row.relationship, + issueDocumentId: row.issueDocumentId, + issueDocumentKey: row.issueDocumentKey ?? null, + title: target.title, + identifier: target.identifier, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + result.set(row.documentId, [...(result.get(row.documentId) ?? []), backlink]); + } + return result; + } + + async function assertTargetInCompany(companyId: string, targetType: DocumentLinkTargetType, targetId: string) { + if (targetType === "issue") { + const row = await db.select({ id: issues.id }).from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, targetId))).limit(1); + if (row.length > 0) return; + } + if (targetType === "project") { + const row = await db.select({ id: projects.id }).from(projects) + .where(and(eq(projects.companyId, companyId), eq(projects.id, targetId))).limit(1); + if (row.length > 0) return; + } + if (targetType === "goal") { + const row = await db.select({ id: goals.id }).from(goals) + .where(and(eq(goals.companyId, companyId), eq(goals.id, targetId))).limit(1); + if (row.length > 0) return; + } + if (targetType === "run") { + const row = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, targetId))).limit(1); + if (row.length > 0) return; + } + if (targetType === "work_product") { + const row = await db.select({ id: issueWorkProducts.id }).from(issueWorkProducts) + .where(and(eq(issueWorkProducts.companyId, companyId), eq(issueWorkProducts.id, targetId))).limit(1); + if (row.length > 0) return; + } + if (targetType === "approval") { + const row = await db.select({ id: approvals.id }).from(approvals) + .where(and(eq(approvals.companyId, companyId), eq(approvals.id, targetId))).limit(1); + if (row.length > 0) return; + } + if (targetType === "agent") { + const row = await db.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, targetId))).limit(1); + if (row.length > 0) return; + } + throw notFound("Document link target not found"); + } + return { + listCompanyDocuments: async (companyId: string, query: CompanyDocumentListQuery) => { + const conditions = [eq(documents.companyId, companyId)]; + if (!query.includeArchived) { + conditions.push(ne(documents.status, "archived")); + } + if (query.q) { + const pattern = `%${query.q.replace(/[\\%_]/g, "\\$&")}%`; + conditions.push(or( + ilike(documents.title, pattern), + ilike(documents.summary, pattern), + ilike(documents.latestBody, pattern), + )!); + } + if (query.status?.length) conditions.push(inArray(documents.status, query.status)); + if (query.type?.length) conditions.push(inArray(documents.documentType, query.type)); + if (query.ownerAgentId) conditions.push(eq(documents.ownerAgentId, query.ownerAgentId)); + if (query.ownerUserId) conditions.push(eq(documents.ownerUserId, query.ownerUserId)); + if (query.updatedAfter) conditions.push(gte(documents.updatedAt, query.updatedAfter)); + if (query.updatedBefore) conditions.push(lte(documents.updatedAt, query.updatedBefore)); + if (query.trustedOnly) { + conditions.push(sql`(${documents.sourceTrust} is null or ${documents.sourceTrust}->>'disposition' <> 'quarantined')`); + } + const targetType = query.targetType ?? (query.projectId ? "project" : undefined); + const targetId = query.targetId ?? query.projectId; + if (targetType && targetId) { + conditions.push(sql`exists ( + select 1 from ${documentLinks} + where ${documentLinks.companyId} = ${documents.companyId} + and ${documentLinks.documentId} = ${documents.id} + and ${documentLinks.targetType} = ${targetType} + and ${documentLinks.targetId} = ${targetId} + )`); + } + if (query.hasOpenFeedback) { + conditions.push(sql`exists ( + select 1 from ${documentAnnotationThreads} + where ${documentAnnotationThreads.companyId} = ${documents.companyId} + and ${documentAnnotationThreads.documentId} = ${documents.id} + and ${documentAnnotationThreads.status} = 'open' + )`); + } + + const rows = await db + .select(documentSelect) + .from(documents) + .where(and(...conditions)) + .orderBy(desc(documents.updatedAt), desc(documents.id)) + .limit(query.limit) + .offset(query.offset); + const documentIds = rows.map((row) => row.id); + const [backlinks, counts] = await Promise.all([ + backlinksForDocuments(companyId, documentIds), + feedbackCountsForDocuments(companyId, documentIds), + ]); + return rows.map((row) => mapDocumentRow( + row, + false, + backlinks.get(row.id) ?? [], + counts.get(row.id) ?? emptyFeedbackCounts(), + )); + }, + + getCompanyDocumentById: async (companyId: string, documentId: string) => { + const row = await db + .select(documentSelect) + .from(documents) + .where(and(eq(documents.companyId, companyId), eq(documents.id, documentId))) + .then((rows) => rows[0] ?? null); + if (!row) return null; + const [backlinks, counts] = await Promise.all([ + backlinksForDocuments(companyId, [documentId]), + feedbackCountsForDocuments(companyId, [documentId]), + ]); + return mapDocumentRow( + row, + true, + backlinks.get(row.id) ?? [], + counts.get(row.id) ?? emptyFeedbackCounts(), + ); + }, + + listDocumentBacklinks: async (companyId: string, documentId: string) => { + const row = await db + .select({ id: documents.id }) + .from(documents) + .where(and(eq(documents.companyId, companyId), eq(documents.id, documentId))) + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Document not found"); + return backlinksForDocuments(companyId, [documentId]).then((map) => map.get(documentId) ?? []); + }, + + updateDocumentMetadata: async (input: { + companyId: string; + documentId: string; + title?: string | null; + status?: DocumentStatus; + documentType?: DocumentType; + summary?: string | null; + ownerAgentId?: string | null; + ownerUserId?: string | null; + updatedByAgentId?: string | null; + updatedByUserId?: string | null; + }) => { + if (input.ownerAgentId) { + const owner = await db + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.companyId, input.companyId), eq(agents.id, input.ownerAgentId))) + .then((rows) => rows[0] ?? null); + if (!owner) throw notFound("Document owner agent not found"); + } + const existing = await db + .select(documentSelect) + .from(documents) + .where(and(eq(documents.companyId, input.companyId), eq(documents.id, input.documentId))) + .then((rows) => rows[0] ?? null); + if (!existing) throw notFound("Document not found"); + const now = new Date(); + const patch: Partial = { + updatedByAgentId: input.updatedByAgentId ?? null, + updatedByUserId: input.updatedByUserId ?? null, + updatedAt: now, + }; + if ("title" in input) patch.title = input.title ?? null; + if ("status" in input && input.status) { + patch.status = input.status; + if (input.status === "archived" && existing.status !== "archived") { + patch.archivedAt = now; + patch.archivedByAgentId = input.updatedByAgentId ?? null; + patch.archivedByUserId = input.updatedByUserId ?? null; + } else if (input.status !== "archived") { + patch.archivedAt = null; + patch.archivedByAgentId = null; + patch.archivedByUserId = null; + } + } + if ("documentType" in input && input.documentType) patch.documentType = input.documentType; + if ("summary" in input) patch.summary = input.summary ?? null; + if ("ownerAgentId" in input) patch.ownerAgentId = input.ownerAgentId ?? null; + if ("ownerUserId" in input) patch.ownerUserId = input.ownerUserId ?? null; + + const [updated] = await db + .update(documents) + .set(patch) + .where(and(eq(documents.companyId, input.companyId), eq(documents.id, input.documentId))) + .returning(); + return mapDocumentRow(updated, true); + }, + + createDocumentLink: async (input: { + companyId: string; + documentId: string; + targetType: DocumentLinkTargetType; + targetId: string; + relationship?: string | null; + createdByAgentId?: string | null; + createdByUserId?: string | null; + }) => { + const document = await db + .select({ id: documents.id }) + .from(documents) + .where(and(eq(documents.companyId, input.companyId), eq(documents.id, input.documentId))) + .then((rows) => rows[0] ?? null); + if (!document) throw notFound("Document not found"); + await assertTargetInCompany(input.companyId, input.targetType, input.targetId); + + const now = new Date(); + const [link] = await db + .insert(documentLinks) + .values({ + companyId: input.companyId, + documentId: input.documentId, + targetType: input.targetType, + targetId: input.targetId, + relationship: normalizeRelationship(input.relationship), + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + documentLinks.companyId, + documentLinks.documentId, + documentLinks.targetType, + documentLinks.targetId, + ], + set: { + relationship: normalizeRelationship(input.relationship), + updatedAt: now, + }, + }) + .returning(); + await db.update(documents).set({ updatedAt: now }).where(eq(documents.id, input.documentId)); + return (await backlinksForDocuments(input.companyId, [input.documentId])).get(input.documentId) + ?.find((backlink) => backlink.id === link.id) ?? null; + }, + + deleteDocumentLink: async (companyId: string, documentId: string, linkId: string) => { + const existing = await db + .select() + .from(documentLinks) + .where(and( + eq(documentLinks.companyId, companyId), + eq(documentLinks.documentId, documentId), + eq(documentLinks.id, linkId), + )) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + if (existing.issueDocumentId) { + throw conflict("Issue document links must be removed through the issue document route", { + issueDocumentId: existing.issueDocumentId, + }); + } + await db.delete(documentLinks).where(eq(documentLinks.id, existing.id)); + await db.update(documents).set({ updatedAt: new Date() }).where(eq(documents.id, documentId)); + return existing; + }, + getIssueDocumentPayload: async ( issue: { id: string; description: string | null }, options: { includeSystem?: boolean } = {}, @@ -301,14 +834,27 @@ export function documentService(db: Db) { .set({ latestRevisionId: revision.id }) .where(eq(documents.id, document.id)); - await tx.insert(issueDocuments).values({ + const [issueDocument] = await tx.insert(issueDocuments).values({ companyId: issue.companyId, issueId: issue.id, documentId: document.id, key: fallbackKey, createdAt: now, updatedAt: now, - }); + }).returning({ id: issueDocuments.id }); + + await tx.insert(documentLinks).values({ + companyId: issue.companyId, + documentId: document.id, + targetType: "issue", + targetId: issue.id, + relationship: "issue_document", + issueDocumentId: issueDocument.id, + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + createdAt: now, + updatedAt: now, + }).onConflictDoNothing(); return { created: true as const, @@ -323,6 +869,11 @@ export function documentService(db: Db) { key: fallbackKey, title: document.title, format: document.format, + status: document.status, + documentType: document.documentType, + summary: document.summary, + ownerAgentId: document.ownerAgentId, + ownerUserId: document.ownerUserId, body: document.latestBody, latestRevisionId: revision.id, latestRevisionNumber: 1, @@ -334,6 +885,9 @@ export function documentService(db: Db) { lockedByAgentId: null, lockedByUserId: null, sourceTrust: document.sourceTrust ?? null, + archivedAt: document.archivedAt, + archivedByAgentId: document.archivedByAgentId, + archivedByUserId: document.archivedByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt, }, @@ -464,14 +1018,27 @@ export function documentService(db: Db) { .set({ latestRevisionId: revision.id }) .where(eq(documents.id, document.id)); - await tx.insert(issueDocuments).values({ + const [issueDocument] = await tx.insert(issueDocuments).values({ companyId: issue.companyId, issueId: issue.id, documentId: document.id, key, createdAt: now, updatedAt: now, - }); + }).returning({ id: issueDocuments.id }); + + await tx.insert(documentLinks).values({ + companyId: issue.companyId, + documentId: document.id, + targetType: "issue", + targetId: issue.id, + relationship: "issue_document", + issueDocumentId: issueDocument.id, + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + createdAt: now, + updatedAt: now, + }).onConflictDoNothing(); return { created: true as const, @@ -482,6 +1049,11 @@ export function documentService(db: Db) { key, title: document.title, format: document.format, + status: document.status, + documentType: document.documentType, + summary: document.summary, + ownerAgentId: document.ownerAgentId, + ownerUserId: document.ownerUserId, body: document.latestBody, latestRevisionId: revision.id, latestRevisionNumber: 1, @@ -493,6 +1065,9 @@ export function documentService(db: Db) { lockedByAgentId: document.lockedByAgentId, lockedByUserId: document.lockedByUserId, sourceTrust: document.sourceTrust ?? null, + archivedAt: document.archivedAt, + archivedByAgentId: document.archivedByAgentId, + archivedByUserId: document.archivedByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt, },