mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Add document management API foundation
This commit is contained in:
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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")),
|
||||
}),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user