Add document management API foundation

This commit is contained in:
Dotta committed 2026-06-07 00:52:03 +00:00
1 parent 71a8464fee
commit 33b6fab3b3
20 files changed
+1354 -8

No files matched your search

@@ -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;
@@ -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
}
]
}
+38
View File
@@ -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<DocumentLinkTargetType>().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),
}),
);
+12 -1
View File
@@ -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<DocumentStatus>().notNull().default("draft"),
documentType: text("document_type").$type<DocumentType>().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<SourceTrustMetadata | null>(),
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")),
}),
+1
View File
@@ -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";
+17
View File
@@ -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];
+20
View File
@@ -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,
+63
View File
@@ -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 {}
+7
View File
@@ -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,
+8
View File
@@ -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;
@@ -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<typeof companyDocumentListQuerySchema>;
export type UpdateDocumentMetadata = z.infer<typeof updateDocumentMetadataSchema>;
export type CreateDocumentLink = z.infer<typeof createDocumentLinkSchema>;
+11
View File
@@ -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,
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
}),
]));
});
});
@@ -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",
}),
]));
});
});
@@ -23,6 +23,7 @@ const apiPrefixes: Record<string, string> = {
"company-skills.ts": "/api",
"costs.ts": "/api",
"dashboard.ts": "/api",
"documents.ts": "/api",
"environments.ts": "/api",
"execution-workspaces.ts": "/api",
"goals.ts": "/api",
+2
View File
@@ -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,
+172
View File
@@ -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;
}
+1
View File
@@ -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";
+84
View File
@@ -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}",
+582 -7
View File
@@ -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 = <T extends { key: string }>(rows: T[], includeSystem: boolean) =>
includeSystem ? rows : rows.filter((row) => !isSystemIssueDocumentKey(row.key));
async function feedbackCountsForDocuments(companyId: string, documentIds: string[]) {
const result = new Map<string, DocumentFeedbackCounts>();
if (documentIds.length === 0) return result;
const rows = await db
.select({
documentId: documentAnnotationThreads.documentId,
openComments: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'open')`.mapWith(Number),
resolvedComments: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'resolved')`.mapWith(Number),
staleAnchors: sql<number>`count(*) filter (where ${documentAnnotationThreads.anchorState} = 'stale')`.mapWith(Number),
orphanedAnchors: sql<number>`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<string, { title: string | null; identifier: string | null }>();
const idsByType = new Map<DocumentLinkTargetType, string[]>();
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<string, DocumentBacklink[]>();
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<typeof documents.$inferInsert> = {
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,
},