From 2277a98fe6e7d72bfc836d3df538a88af2994b5a Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 7 Jun 2026 01:13:04 +0000 Subject: [PATCH] Implement document review suggestions backend --- .../0100_document_review_backend.sql | 278 ++++++ packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/document_review_comments.ts | 42 + .../db/src/schema/document_review_threads.ts | 37 + .../document_suggestion_anchor_snapshots.ts | 42 + .../schema/document_suggestion_comments.ts | 42 + .../db/src/schema/document_suggestions.ts | 73 ++ packages/db/src/schema/index.ts | 5 + packages/shared/src/constants.ts | 12 + packages/shared/src/critic-markup.test.ts | 61 ++ packages/shared/src/critic-markup.ts | 126 +++ packages/shared/src/index.ts | 48 + packages/shared/src/types/document-review.ts | 189 ++++ packages/shared/src/types/document.ts | 5 + packages/shared/src/types/index.ts | 18 + .../shared/src/validators/document-review.ts | 89 ++ packages/shared/src/validators/index.ts | 21 + .../__tests__/document-review-service.test.ts | 219 +++++ server/src/routes/issues.ts | 477 ++++++++++ server/src/services/document-review.ts | 832 ++++++++++++++++++ server/src/services/documents.ts | 71 +- server/src/services/index.ts | 1 + 22 files changed, 2693 insertions(+), 2 deletions(-) create mode 100644 packages/db/src/migrations/0100_document_review_backend.sql create mode 100644 packages/db/src/schema/document_review_comments.ts create mode 100644 packages/db/src/schema/document_review_threads.ts create mode 100644 packages/db/src/schema/document_suggestion_anchor_snapshots.ts create mode 100644 packages/db/src/schema/document_suggestion_comments.ts create mode 100644 packages/db/src/schema/document_suggestions.ts create mode 100644 packages/shared/src/critic-markup.test.ts create mode 100644 packages/shared/src/critic-markup.ts create mode 100644 packages/shared/src/types/document-review.ts create mode 100644 packages/shared/src/validators/document-review.ts create mode 100644 server/src/__tests__/document-review-service.test.ts create mode 100644 server/src/services/document-review.ts diff --git a/packages/db/src/migrations/0100_document_review_backend.sql b/packages/db/src/migrations/0100_document_review_backend.sql new file mode 100644 index 00000000000..11e55c1e6be --- /dev/null +++ b/packages/db/src/migrations/0100_document_review_backend.sql @@ -0,0 +1,278 @@ +CREATE TABLE IF NOT EXISTS "document_review_threads" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "document_key" text NOT NULL, + "status" text DEFAULT 'open' NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "resolved_by_agent_id" uuid, + "resolved_by_user_id" text, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "document_review_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "thread_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "body" text NOT NULL, + "author_type" text NOT NULL, + "author_agent_id" uuid, + "author_user_id" text, + "created_by_run_id" uuid, + "issue_comment_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "document_suggestions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "document_key" text NOT NULL, + "kind" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "anchor_state" text DEFAULT 'active' NOT NULL, + "anchor_confidence" text DEFAULT 'exact' NOT NULL, + "original_revision_id" uuid, + "original_revision_number" integer NOT NULL, + "current_revision_id" uuid, + "current_revision_number" integer NOT NULL, + "selected_text" text NOT NULL, + "proposed_text" text, + "insertion_position" text, + "prefix_text" text DEFAULT '' NOT NULL, + "suffix_text" text DEFAULT '' NOT NULL, + "normalized_start" integer NOT NULL, + "normalized_end" integer NOT NULL, + "markdown_start" integer NOT NULL, + "markdown_end" integer NOT NULL, + "anchor_selector" jsonb NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "accepted_by_agent_id" uuid, + "accepted_by_user_id" text, + "accepted_at" timestamp with time zone, + "accepted_revision_id" uuid, + "rejected_by_agent_id" uuid, + "rejected_by_user_id" text, + "rejected_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "document_suggestion_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "suggestion_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "body" text NOT NULL, + "author_type" text NOT NULL, + "author_agent_id" uuid, + "author_user_id" text, + "created_by_run_id" uuid, + "issue_comment_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "document_suggestion_anchor_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "suggestion_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "from_revision_id" uuid, + "from_revision_number" integer, + "to_revision_id" uuid, + "to_revision_number" integer NOT NULL, + "previous_anchor" jsonb NOT NULL, + "next_anchor" jsonb, + "anchor_state" text NOT NULL, + "anchor_confidence" text NOT NULL, + "failure_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_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_review_threads" ADD CONSTRAINT "document_review_threads_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_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_review_threads" ADD CONSTRAINT "document_review_threads_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 +DO $$ BEGIN + ALTER TABLE "document_review_threads" ADD CONSTRAINT "document_review_threads_resolved_by_agent_id_agents_id_fk" FOREIGN KEY ("resolved_by_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 "document_review_comments" ADD CONSTRAINT "document_review_comments_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_review_comments" ADD CONSTRAINT "document_review_comments_thread_id_document_review_threads_id_fk" FOREIGN KEY ("thread_id") REFERENCES "public"."document_review_threads"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_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_review_comments" ADD CONSTRAINT "document_review_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_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 "document_review_comments" ADD CONSTRAINT "document_review_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_review_comments" ADD CONSTRAINT "document_review_comments_issue_comment_id_issue_comments_id_fk" FOREIGN KEY ("issue_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_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_suggestions" ADD CONSTRAINT "document_suggestions_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_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_suggestions" ADD CONSTRAINT "document_suggestions_original_revision_id_document_revisions_id_fk" FOREIGN KEY ("original_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_current_revision_id_document_revisions_id_fk" FOREIGN KEY ("current_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_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 +DO $$ BEGIN + ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_accepted_by_agent_id_agents_id_fk" FOREIGN KEY ("accepted_by_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 "document_suggestions" ADD CONSTRAINT "document_suggestions_accepted_revision_id_document_revisions_id_fk" FOREIGN KEY ("accepted_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestions" ADD CONSTRAINT "document_suggestions_rejected_by_agent_id_agents_id_fk" FOREIGN KEY ("rejected_by_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 "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_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_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_suggestion_id_document_suggestions_id_fk" FOREIGN KEY ("suggestion_id") REFERENCES "public"."document_suggestions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_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_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_author_agent_id_agents_id_fk" FOREIGN KEY ("author_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 "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestion_comments" ADD CONSTRAINT "document_suggestion_comments_issue_comment_id_issue_comments_id_fk" FOREIGN KEY ("issue_comment_id") REFERENCES "public"."issue_comments"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_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_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_suggestion_id_document_suggestions_id_fk" FOREIGN KEY ("suggestion_id") REFERENCES "public"."document_suggestions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_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_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_from_revision_id_document_revisions_id_fk" FOREIGN KEY ("from_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_suggestion_anchor_snapshots" ADD CONSTRAINT "document_suggestion_anchor_snapshots_to_revision_id_document_revisions_id_fk" FOREIGN KEY ("to_revision_id") REFERENCES "public"."document_revisions"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_review_threads_company_document_status_idx" ON "document_review_threads" USING btree ("company_id","document_id","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_review_threads_company_issue_status_idx" ON "document_review_threads" USING btree ("company_id","issue_id","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_review_comments_company_thread_created_at_idx" ON "document_review_comments" USING btree ("company_id","thread_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_review_comments_company_issue_created_at_idx" ON "document_review_comments" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_review_comments_issue_comment_idx" ON "document_review_comments" USING btree ("issue_comment_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_review_comments_body_search_idx" ON "document_review_comments" USING gin ("body" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestions_company_document_status_idx" ON "document_suggestions" USING btree ("company_id","document_id","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestions_company_issue_status_idx" ON "document_suggestions" USING btree ("company_id","issue_id","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestions_company_current_revision_pending_idx" ON "document_suggestions" USING btree ("company_id","document_id","current_revision_id","status");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestions_company_anchor_state_idx" ON "document_suggestions" USING btree ("company_id","anchor_state");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestion_comments_company_suggestion_created_at_idx" ON "document_suggestion_comments" USING btree ("company_id","suggestion_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestion_comments_company_issue_created_at_idx" ON "document_suggestion_comments" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestion_comments_issue_comment_idx" ON "document_suggestion_comments" USING btree ("issue_comment_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestion_comments_body_search_idx" ON "document_suggestion_comments" USING gin ("body" gin_trgm_ops);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestion_anchor_snapshots_company_suggestion_created_at_idx" ON "document_suggestion_anchor_snapshots" USING btree ("company_id","suggestion_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_suggestion_anchor_snapshots_company_document_revision_idx" ON "document_suggestion_anchor_snapshots" USING btree ("company_id","document_id","to_revision_number"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 235ddf7b686..48af8b9598d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -701,6 +701,13 @@ "when": 1780794000000, "tag": "0099_document_management_foundation", "breakpoints": true + }, + { + "idx": 100, + "version": "7", + "when": 1780794300000, + "tag": "0100_document_review_backend", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/document_review_comments.ts b/packages/db/src/schema/document_review_comments.ts new file mode 100644 index 00000000000..d0c6293c8d9 --- /dev/null +++ b/packages/db/src/schema/document_review_comments.ts @@ -0,0 +1,42 @@ +import type { IssueCommentAuthorType } from "@paperclipai/shared"; +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { documentReviewThreads } from "./document_review_threads.js"; +import { documents } from "./documents.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issueComments } from "./issue_comments.js"; +import { issues } from "./issues.js"; + +export const documentReviewComments = pgTable( + "document_review_comments", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + threadId: uuid("thread_id").notNull().references(() => documentReviewThreads.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + body: text("body").notNull(), + authorType: text("author_type").$type().notNull(), + authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }), + authorUserId: text("author_user_id"), + createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + issueCommentId: uuid("issue_comment_id").references(() => issueComments.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyThreadCreatedAtIdx: index("document_review_comments_company_thread_created_at_idx").on( + table.companyId, + table.threadId, + table.createdAt, + ), + companyIssueCreatedAtIdx: index("document_review_comments_company_issue_created_at_idx").on( + table.companyId, + table.issueId, + table.createdAt, + ), + issueCommentIdx: index("document_review_comments_issue_comment_idx").on(table.issueCommentId), + bodySearchIdx: index("document_review_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")), + }), +); diff --git a/packages/db/src/schema/document_review_threads.ts b/packages/db/src/schema/document_review_threads.ts new file mode 100644 index 00000000000..32c172ddaaf --- /dev/null +++ b/packages/db/src/schema/document_review_threads.ts @@ -0,0 +1,37 @@ +import type { DocumentReviewThreadStatus } from "@paperclipai/shared"; +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { documents } from "./documents.js"; +import { issues } from "./issues.js"; + +export const documentReviewThreads = pgTable( + "document_review_threads", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + documentKey: text("document_key").notNull(), + status: text("status").$type().notNull().default("open"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + resolvedByAgentId: uuid("resolved_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + resolvedByUserId: text("resolved_by_user_id"), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyDocumentStatusIdx: index("document_review_threads_company_document_status_idx").on( + table.companyId, + table.documentId, + table.status, + ), + companyIssueStatusIdx: index("document_review_threads_company_issue_status_idx").on( + table.companyId, + table.issueId, + table.status, + ), + }), +); diff --git a/packages/db/src/schema/document_suggestion_anchor_snapshots.ts b/packages/db/src/schema/document_suggestion_anchor_snapshots.ts new file mode 100644 index 00000000000..aecad39ad59 --- /dev/null +++ b/packages/db/src/schema/document_suggestion_anchor_snapshots.ts @@ -0,0 +1,42 @@ +import type { + DocumentAnnotationAnchorConfidence, + DocumentAnnotationAnchorSnapshot, + DocumentAnnotationAnchorState, +} from "@paperclipai/shared"; +import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { documentRevisions } from "./document_revisions.js"; +import { documentSuggestions } from "./document_suggestions.js"; +import { documents } from "./documents.js"; + +export const documentSuggestionAnchorSnapshots = pgTable( + "document_suggestion_anchor_snapshots", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + suggestionId: uuid("suggestion_id").notNull().references(() => documentSuggestions.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + fromRevisionId: uuid("from_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }), + fromRevisionNumber: integer("from_revision_number"), + toRevisionId: uuid("to_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }), + toRevisionNumber: integer("to_revision_number").notNull(), + previousAnchor: jsonb("previous_anchor").$type().notNull(), + nextAnchor: jsonb("next_anchor").$type(), + anchorState: text("anchor_state").$type().notNull(), + anchorConfidence: text("anchor_confidence").$type().notNull(), + failureReason: text("failure_reason"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySuggestionCreatedAtIdx: index("document_suggestion_anchor_snapshots_company_suggestion_created_at_idx").on( + table.companyId, + table.suggestionId, + table.createdAt, + ), + companyDocumentRevisionIdx: index("document_suggestion_anchor_snapshots_company_document_revision_idx").on( + table.companyId, + table.documentId, + table.toRevisionNumber, + ), + }), +); diff --git a/packages/db/src/schema/document_suggestion_comments.ts b/packages/db/src/schema/document_suggestion_comments.ts new file mode 100644 index 00000000000..24fc780d810 --- /dev/null +++ b/packages/db/src/schema/document_suggestion_comments.ts @@ -0,0 +1,42 @@ +import type { IssueCommentAuthorType } from "@paperclipai/shared"; +import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { documentSuggestions } from "./document_suggestions.js"; +import { documents } from "./documents.js"; +import { heartbeatRuns } from "./heartbeat_runs.js"; +import { issueComments } from "./issue_comments.js"; +import { issues } from "./issues.js"; + +export const documentSuggestionComments = pgTable( + "document_suggestion_comments", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + suggestionId: uuid("suggestion_id").notNull().references(() => documentSuggestions.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + body: text("body").notNull(), + authorType: text("author_type").$type().notNull(), + authorAgentId: uuid("author_agent_id").references(() => agents.id, { onDelete: "set null" }), + authorUserId: text("author_user_id"), + createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + issueCommentId: uuid("issue_comment_id").references(() => issueComments.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySuggestionCreatedAtIdx: index("document_suggestion_comments_company_suggestion_created_at_idx").on( + table.companyId, + table.suggestionId, + table.createdAt, + ), + companyIssueCreatedAtIdx: index("document_suggestion_comments_company_issue_created_at_idx").on( + table.companyId, + table.issueId, + table.createdAt, + ), + issueCommentIdx: index("document_suggestion_comments_issue_comment_idx").on(table.issueCommentId), + bodySearchIdx: index("document_suggestion_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")), + }), +); diff --git a/packages/db/src/schema/document_suggestions.ts b/packages/db/src/schema/document_suggestions.ts new file mode 100644 index 00000000000..2401df9b39a --- /dev/null +++ b/packages/db/src/schema/document_suggestions.ts @@ -0,0 +1,73 @@ +import type { + DocumentAnnotationAnchorConfidence, + DocumentAnnotationAnchorSelector, + DocumentAnnotationAnchorState, + DocumentSuggestionInsertPosition, + DocumentSuggestionKind, + DocumentSuggestionStatus, +} from "@paperclipai/shared"; +import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { documentRevisions } from "./document_revisions.js"; +import { documents } from "./documents.js"; +import { issues } from "./issues.js"; + +export const documentSuggestions = pgTable( + "document_suggestions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + documentKey: text("document_key").notNull(), + kind: text("kind").$type().notNull(), + status: text("status").$type().notNull().default("pending"), + anchorState: text("anchor_state").$type().notNull().default("active"), + anchorConfidence: text("anchor_confidence").$type().notNull().default("exact"), + originalRevisionId: uuid("original_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }), + originalRevisionNumber: integer("original_revision_number").notNull(), + currentRevisionId: uuid("current_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }), + currentRevisionNumber: integer("current_revision_number").notNull(), + selectedText: text("selected_text").notNull(), + proposedText: text("proposed_text"), + insertionPosition: text("insertion_position").$type(), + prefixText: text("prefix_text").notNull().default(""), + suffixText: text("suffix_text").notNull().default(""), + normalizedStart: integer("normalized_start").notNull(), + normalizedEnd: integer("normalized_end").notNull(), + markdownStart: integer("markdown_start").notNull(), + markdownEnd: integer("markdown_end").notNull(), + anchorSelector: jsonb("anchor_selector").$type().notNull(), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + acceptedByAgentId: uuid("accepted_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + acceptedByUserId: text("accepted_by_user_id"), + acceptedAt: timestamp("accepted_at", { withTimezone: true }), + acceptedRevisionId: uuid("accepted_revision_id").references(() => documentRevisions.id, { onDelete: "set null" }), + rejectedByAgentId: uuid("rejected_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + rejectedByUserId: text("rejected_by_user_id"), + rejectedAt: timestamp("rejected_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyDocumentStatusIdx: index("document_suggestions_company_document_status_idx").on( + table.companyId, + table.documentId, + table.status, + ), + companyIssueStatusIdx: index("document_suggestions_company_issue_status_idx").on( + table.companyId, + table.issueId, + table.status, + ), + companyCurrentRevisionPendingIdx: index("document_suggestions_company_current_revision_pending_idx").on( + table.companyId, + table.documentId, + table.currentRevisionId, + table.status, + ), + companyAnchorStateIdx: index("document_suggestions_company_anchor_state_idx").on(table.companyId, table.anchorState), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 22098f7c390..b4e5f314ea6 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -60,6 +60,11 @@ export { issueDocuments } from "./issue_documents.js"; export { documentAnnotationThreads } from "./document_annotation_threads.js"; export { documentAnnotationComments } from "./document_annotation_comments.js"; export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js"; +export { documentReviewThreads } from "./document_review_threads.js"; +export { documentReviewComments } from "./document_review_comments.js"; +export { documentSuggestions } from "./document_suggestions.js"; +export { documentSuggestionComments } from "./document_suggestion_comments.js"; +export { documentSuggestionAnchorSnapshots } from "./document_suggestion_anchor_snapshots.js"; export { heartbeatRuns } from "./heartbeat_runs.js"; export { heartbeatRunEvents } from "./heartbeat_run_events.js"; export { heartbeatRunWatchdogDecisions } from "./heartbeat_run_watchdog_decisions.js"; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b9a9767a0cd..223fd575151 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -338,6 +338,9 @@ export type IssueReferenceSourceKind = (typeof ISSUE_REFERENCE_SOURCE_KINDS)[num export const DOCUMENT_ANNOTATION_THREAD_STATUSES = ["open", "resolved"] as const; export type DocumentAnnotationThreadStatus = (typeof DOCUMENT_ANNOTATION_THREAD_STATUSES)[number]; +export const DOCUMENT_REVIEW_THREAD_STATUSES = ["open", "resolved"] as const; +export type DocumentReviewThreadStatus = (typeof DOCUMENT_REVIEW_THREAD_STATUSES)[number]; + export const DOCUMENT_ANNOTATION_ANCHOR_STATES = ["active", "stale", "orphaned"] as const; export type DocumentAnnotationAnchorState = (typeof DOCUMENT_ANNOTATION_ANCHOR_STATES)[number]; @@ -351,6 +354,15 @@ export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [ export type DocumentAnnotationAnchorConfidence = (typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number]; +export const DOCUMENT_SUGGESTION_KINDS = ["insertion", "deletion", "substitution"] as const; +export type DocumentSuggestionKind = (typeof DOCUMENT_SUGGESTION_KINDS)[number]; + +export const DOCUMENT_SUGGESTION_INSERT_POSITIONS = ["before", "after"] as const; +export type DocumentSuggestionInsertPosition = (typeof DOCUMENT_SUGGESTION_INSERT_POSITIONS)[number]; + +export const DOCUMENT_SUGGESTION_STATUSES = ["pending", "accepted", "rejected"] as const; +export type DocumentSuggestionStatus = (typeof DOCUMENT_SUGGESTION_STATUSES)[number]; + export const DOCUMENT_STATUSES = ["draft", "in_review", "approved", "archived"] as const; export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]; diff --git a/packages/shared/src/critic-markup.test.ts b/packages/shared/src/critic-markup.test.ts new file mode 100644 index 00000000000..5d84c1c0bd6 --- /dev/null +++ b/packages/shared/src/critic-markup.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { parseCriticMarkup, renderCriticMarkupSuggestion } from "./critic-markup.js"; + +describe("critic markup helpers", () => { + it("extracts CriticMarkup-like edits outside code", () => { + const parsed = parseCriticMarkup("Keep {++new++} {--old--} {~~rough~>polished~~}."); + + expect(parsed.plainMarkdown).toBe("Keep new polished."); + expect(parsed.suggestions).toEqual([ + { + kind: "insertion", + selectedText: "", + proposedText: "new", + markdownStart: 5, + markdownEnd: 8, + }, + { + kind: "deletion", + selectedText: "old", + proposedText: null, + markdownStart: 9, + markdownEnd: 9, + }, + { + kind: "substitution", + selectedText: "rough", + proposedText: "polished", + markdownStart: 10, + markdownEnd: 18, + }, + ]); + }); + + it("ignores markers inside inline code and fenced code", () => { + const parsed = parseCriticMarkup([ + "Visible {--remove--}.", + "Inline `{++literal++}` stays.", + "```", + "{~~old~>new~~}", + "```", + ].join("\n")); + + expect(parsed.plainMarkdown).toBe([ + "Visible .", + "Inline `{++literal++}` stays.", + "```", + "{~~old~>new~~}", + "```", + ].join("\n")); + expect(parsed.suggestions).toHaveLength(1); + expect(parsed.suggestions[0]?.kind).toBe("deletion"); + }); + + it("renders optional CriticMarkup export markers", () => { + expect(renderCriticMarkupSuggestion({ + kind: "substitution", + selectedText: "old", + proposedText: "new", + })).toBe("{~~old~>new~~}"); + }); +}); diff --git a/packages/shared/src/critic-markup.ts b/packages/shared/src/critic-markup.ts new file mode 100644 index 00000000000..7d513087b76 --- /dev/null +++ b/packages/shared/src/critic-markup.ts @@ -0,0 +1,126 @@ +import type { DocumentSuggestionKind } from "./constants.js"; + +export interface CriticMarkupSuggestion { + kind: DocumentSuggestionKind; + selectedText: string; + proposedText: string | null; + markdownStart: number; + markdownEnd: number; +} + +export interface ParseCriticMarkupResult { + plainMarkdown: string; + suggestions: CriticMarkupSuggestion[]; +} + +export function parseCriticMarkup(markdown: string): ParseCriticMarkupResult { + let index = 0; + let lineStart = true; + let inFence = false; + let plainMarkdown = ""; + const suggestions: CriticMarkupSuggestion[] = []; + + const append = (value: string) => { + plainMarkdown += value; + lineStart = value.endsWith("\n"); + }; + + while (index < markdown.length) { + const rest = markdown.slice(index); + const fence = lineStart ? rest.match(/^(\s*)(```+|~~~+)/) : null; + if (fence) { + const lineEnd = markdown.indexOf("\n", index); + const end = lineEnd === -1 ? markdown.length : lineEnd + 1; + append(markdown.slice(index, end)); + index = end; + inFence = !inFence; + continue; + } + + if (inFence) { + const lineEnd = markdown.indexOf("\n", index); + const end = lineEnd === -1 ? markdown.length : lineEnd + 1; + append(markdown.slice(index, end)); + index = end; + continue; + } + + const tick = rest.match(/^`+/); + if (tick) { + const marker = tick[0]; + const close = markdown.indexOf(marker, index + marker.length); + const end = close === -1 ? index + marker.length : close + marker.length; + append(markdown.slice(index, end)); + index = end; + continue; + } + + if (rest.startsWith("{++")) { + const close = markdown.indexOf("++}", index + 3); + if (close !== -1) { + const proposedText = markdown.slice(index + 3, close); + const start = plainMarkdown.length; + append(proposedText); + suggestions.push({ + kind: "insertion", + selectedText: "", + proposedText, + markdownStart: start, + markdownEnd: start + proposedText.length, + }); + index = close + 3; + continue; + } + } + + if (rest.startsWith("{--")) { + const close = markdown.indexOf("--}", index + 3); + if (close !== -1) { + const selectedText = markdown.slice(index + 3, close); + const start = plainMarkdown.length; + suggestions.push({ + kind: "deletion", + selectedText, + proposedText: null, + markdownStart: start, + markdownEnd: start, + }); + index = close + 3; + continue; + } + } + + if (rest.startsWith("{~~")) { + const middle = markdown.indexOf("~>", index + 3); + const close = middle === -1 ? -1 : markdown.indexOf("~~}", middle + 2); + if (middle !== -1 && close !== -1) { + const selectedText = markdown.slice(index + 3, middle); + const proposedText = markdown.slice(middle + 2, close); + const start = plainMarkdown.length; + append(proposedText); + suggestions.push({ + kind: "substitution", + selectedText, + proposedText, + markdownStart: start, + markdownEnd: start + proposedText.length, + }); + index = close + 3; + continue; + } + } + + append(markdown[index] ?? ""); + index += 1; + } + + return { plainMarkdown, suggestions }; +} + +export function renderCriticMarkupSuggestion( + suggestion: Pick, +) { + if (suggestion.kind === "insertion") return `{++${suggestion.proposedText ?? ""}++}`; + if (suggestion.kind === "deletion") return `{--${suggestion.selectedText}--}`; + return `{~~${suggestion.selectedText}~>${suggestion.proposedText ?? ""}~~}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7bbbdfeb7bf..5ae9d2a0ce9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -88,8 +88,12 @@ export { isSystemIssueDocumentKey, ISSUE_REFERENCE_SOURCE_KINDS, DOCUMENT_ANNOTATION_THREAD_STATUSES, + DOCUMENT_REVIEW_THREAD_STATUSES, DOCUMENT_ANNOTATION_ANCHOR_STATES, DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES, + DOCUMENT_SUGGESTION_KINDS, + DOCUMENT_SUGGESTION_INSERT_POSITIONS, + DOCUMENT_SUGGESTION_STATUSES, DOCUMENT_STATUSES, DOCUMENT_TYPES, DOCUMENT_LINK_TARGET_TYPES, @@ -215,8 +219,12 @@ export { type SystemIssueDocumentKey, type IssueReferenceSourceKind, type DocumentAnnotationThreadStatus, + type DocumentReviewThreadStatus, type DocumentAnnotationAnchorState, type DocumentAnnotationAnchorConfidence, + type DocumentSuggestionKind, + type DocumentSuggestionInsertPosition, + type DocumentSuggestionStatus, type DocumentStatus, type DocumentType, type DocumentLinkTargetType, @@ -499,7 +507,23 @@ export type { DocumentBacklink, DocumentFeedbackCounts, DocumentLink, + AcceptDocumentSuggestionRequest, + CreateDocumentReviewCommentRequest, + CreateDocumentReviewThreadRequest, + CreateDocumentSuggestionCommentRequest, + CreateDocumentSuggestionRequest, + DocumentReviewComment, + DocumentReviewIndex, + DocumentReviewIndexCounts, + DocumentReviewThread, + DocumentReviewThreadWithComments, + DocumentSuggestion, + DocumentSuggestionAnchorRemapSnapshot, + DocumentSuggestionComment, + DocumentSuggestionWithComments, + RejectDocumentSuggestionRequest, UpdateDocumentAnnotationThreadRequest, + UpdateDocumentReviewThreadRequest, AttachmentArtifactWorkProductMetadata, Issue, IssueAssigneeAdapterOverrides, @@ -804,6 +828,12 @@ export { type VerifyDocumentAnchorSelectorInput, type VerifyDocumentAnchorSelectorResult, } from "./document-anchors.js"; +export { + parseCriticMarkup, + renderCriticMarkupSuggestion, + type CriticMarkupSuggestion, + type ParseCriticMarkupResult, +} from "./critic-markup.js"; export { sidebarOrderPreferenceSchema, @@ -965,6 +995,24 @@ export { type CreateDocumentAnnotationComment, type CreateDocumentAnnotationThread, type UpdateDocumentAnnotationThread, + acceptDocumentSuggestionSchema, + createDocumentReviewCommentSchema, + createDocumentReviewThreadSchema, + createDocumentSuggestionCommentSchema, + createDocumentSuggestionSchema, + documentReviewThreadStatusSchema, + documentSuggestionInsertPositionSchema, + documentSuggestionKindSchema, + documentSuggestionStatusSchema, + rejectDocumentSuggestionSchema, + updateDocumentReviewThreadSchema, + type AcceptDocumentSuggestion, + type CreateDocumentReviewComment, + type CreateDocumentReviewThread, + type CreateDocumentSuggestion, + type CreateDocumentSuggestionComment, + type RejectDocumentSuggestion, + type UpdateDocumentReviewThread, companySearchQuerySchema, COMPANY_SEARCH_DEFAULT_LIMIT, COMPANY_SEARCH_MAX_LIMIT, diff --git a/packages/shared/src/types/document-review.ts b/packages/shared/src/types/document-review.ts new file mode 100644 index 00000000000..f9dc0ad00e5 --- /dev/null +++ b/packages/shared/src/types/document-review.ts @@ -0,0 +1,189 @@ +import type { + DocumentAnnotationAnchorConfidence, + DocumentAnnotationAnchorState, + DocumentReviewThreadStatus, + DocumentSuggestionInsertPosition, + DocumentSuggestionKind, + DocumentSuggestionStatus, + IssueCommentAuthorType, +} from "../constants.js"; +import type { + DocumentAnnotationAnchorSelector, + DocumentAnnotationAnchorSnapshot, + DocumentAnnotationComment, + DocumentAnnotationThreadWithComments, +} from "./document-annotation.js"; + +export interface DocumentReviewThread { + id: string; + companyId: string; + issueId: string; + documentId: string; + documentKey: string; + status: DocumentReviewThreadStatus; + createdByAgentId: string | null; + createdByUserId: string | null; + resolvedByAgentId: string | null; + resolvedByUserId: string | null; + resolvedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface DocumentReviewComment { + id: string; + companyId: string; + threadId: string; + issueId: string; + documentId: string; + body: string; + authorType: IssueCommentAuthorType; + authorAgentId: string | null; + authorUserId: string | null; + createdByRunId: string | null; + issueCommentId?: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface DocumentReviewThreadWithComments extends DocumentReviewThread { + comments: DocumentReviewComment[]; +} + +export interface DocumentSuggestion { + id: string; + companyId: string; + issueId: string; + documentId: string; + documentKey: string; + kind: DocumentSuggestionKind; + status: DocumentSuggestionStatus; + anchorState: DocumentAnnotationAnchorState; + anchorConfidence: DocumentAnnotationAnchorConfidence; + originalRevisionId: string | null; + originalRevisionNumber: number; + currentRevisionId: string | null; + currentRevisionNumber: number; + selectedText: string; + proposedText: string | null; + insertionPosition: DocumentSuggestionInsertPosition | null; + prefixText: string; + suffixText: string; + normalizedStart: number; + normalizedEnd: number; + markdownStart: number; + markdownEnd: number; + anchorSelector: DocumentAnnotationAnchorSelector; + createdByAgentId: string | null; + createdByUserId: string | null; + acceptedByAgentId: string | null; + acceptedByUserId: string | null; + acceptedAt: Date | null; + acceptedRevisionId: string | null; + rejectedByAgentId: string | null; + rejectedByUserId: string | null; + rejectedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface DocumentSuggestionComment { + id: string; + companyId: string; + suggestionId: string; + issueId: string; + documentId: string; + body: string; + authorType: IssueCommentAuthorType; + authorAgentId: string | null; + authorUserId: string | null; + createdByRunId: string | null; + issueCommentId?: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface DocumentSuggestionWithComments extends DocumentSuggestion { + comments: DocumentSuggestionComment[]; +} + +export interface DocumentSuggestionAnchorRemapSnapshot { + id: string; + companyId: string; + suggestionId: string; + documentId: string; + fromRevisionId: string | null; + fromRevisionNumber: number | null; + toRevisionId: string | null; + toRevisionNumber: number; + previousAnchor: DocumentAnnotationAnchorSnapshot; + nextAnchor: DocumentAnnotationAnchorSnapshot | null; + anchorState: DocumentAnnotationAnchorState; + anchorConfidence: DocumentAnnotationAnchorConfidence; + failureReason: string | null; + createdAt: Date; +} + +export interface DocumentReviewIndexCounts { + unresolved: number; + openAnchoredThreads: number; + openReviewThreads: number; + pendingSuggestions: number; + resolvedAnchoredThreads: number; + resolvedReviewThreads: number; + acceptedSuggestions: number; + rejectedSuggestions: number; + staleAnchors: number; + orphanedAnchors: number; +} + +export interface DocumentReviewIndex { + issueId: string; + documentId: string; + documentKey: string; + latestRevisionId: string | null; + latestRevisionNumber: number; + counts: DocumentReviewIndexCounts; + annotationThreads: DocumentAnnotationThreadWithComments[]; + reviewThreads: DocumentReviewThreadWithComments[]; + suggestions: DocumentSuggestionWithComments[]; +} + +export interface CreateDocumentReviewThreadRequest { + body: string; + issueCommentId?: string | null; +} + +export interface CreateDocumentReviewCommentRequest { + body: string; + issueCommentId?: string | null; +} + +export interface UpdateDocumentReviewThreadRequest { + status?: DocumentReviewThreadStatus; +} + +export interface CreateDocumentSuggestionRequest { + baseRevisionId: string; + baseRevisionNumber: number; + kind: DocumentSuggestionKind; + selector: DocumentAnnotationAnchorSelector; + proposedText?: string | null; + insertionPosition?: DocumentSuggestionInsertPosition | null; + body?: string | null; + issueCommentId?: string | null; +} + +export interface CreateDocumentSuggestionCommentRequest { + body: string; + issueCommentId?: string | null; +} + +export interface AcceptDocumentSuggestionRequest { + baseRevisionId: string; + changeSummary?: string | null; +} + +export interface RejectDocumentSuggestionRequest { + reason?: string | null; +} diff --git a/packages/shared/src/types/document.ts b/packages/shared/src/types/document.ts index 96d8625095e..28a0705bb24 100644 --- a/packages/shared/src/types/document.ts +++ b/packages/shared/src/types/document.ts @@ -23,6 +23,11 @@ export interface DocumentBacklink { export interface DocumentFeedbackCounts { openComments: number; resolvedComments: number; + openReviewThreads: number; + resolvedReviewThreads: number; + pendingSuggestions: number; + acceptedSuggestions: number; + rejectedSuggestions: number; staleAnchors: number; orphanedAnchors: number; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ad18100998d..065dc982163 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -175,6 +175,24 @@ export type { DocumentFeedbackCounts, DocumentLink, } from "./document.js"; +export type { + AcceptDocumentSuggestionRequest, + CreateDocumentReviewCommentRequest, + CreateDocumentReviewThreadRequest, + CreateDocumentSuggestionCommentRequest, + CreateDocumentSuggestionRequest, + DocumentReviewComment, + DocumentReviewIndex, + DocumentReviewIndexCounts, + DocumentReviewThread, + DocumentReviewThreadWithComments, + DocumentSuggestion, + DocumentSuggestionAnchorRemapSnapshot, + DocumentSuggestionComment, + DocumentSuggestionWithComments, + RejectDocumentSuggestionRequest, + UpdateDocumentReviewThreadRequest, +} from "./document-review.js"; export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { CompanySearchHighlight, diff --git a/packages/shared/src/validators/document-review.ts b/packages/shared/src/validators/document-review.ts new file mode 100644 index 00000000000..53fe18d34de --- /dev/null +++ b/packages/shared/src/validators/document-review.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { + DOCUMENT_REVIEW_THREAD_STATUSES, + DOCUMENT_SUGGESTION_INSERT_POSITIONS, + DOCUMENT_SUGGESTION_KINDS, + DOCUMENT_SUGGESTION_STATUSES, +} from "../constants.js"; +import { documentAnnotationAnchorSelectorSchema } from "./document-annotation.js"; +import { multilineTextSchema } from "./text.js"; + +export const documentReviewThreadStatusSchema = z.enum(DOCUMENT_REVIEW_THREAD_STATUSES); +export const documentSuggestionKindSchema = z.enum(DOCUMENT_SUGGESTION_KINDS); +export const documentSuggestionInsertPositionSchema = z.enum(DOCUMENT_SUGGESTION_INSERT_POSITIONS); +export const documentSuggestionStatusSchema = z.enum(DOCUMENT_SUGGESTION_STATUSES); + +const reviewBodySchema = multilineTextSchema.pipe(z.string().min(1).max(20_000)); +const suggestedTextSchema = z.string().max(100_000); + +export const createDocumentReviewThreadSchema = z.object({ + body: reviewBodySchema, + issueCommentId: z.string().uuid().nullable().optional(), +}).strict(); + +export const createDocumentReviewCommentSchema = z.object({ + body: reviewBodySchema, + issueCommentId: z.string().uuid().nullable().optional(), +}).strict(); + +export const updateDocumentReviewThreadSchema = z.object({ + status: documentReviewThreadStatusSchema.optional(), +}).strict().refine((value) => value.status != null, { + message: "At least one field must be provided", +}); + +export const createDocumentSuggestionSchema = z.object({ + baseRevisionId: z.string().uuid(), + baseRevisionNumber: z.number().int().positive(), + kind: documentSuggestionKindSchema, + selector: documentAnnotationAnchorSelectorSchema, + proposedText: suggestedTextSchema.nullable().optional(), + insertionPosition: documentSuggestionInsertPositionSchema.nullable().optional(), + body: reviewBodySchema.nullable().optional(), + issueCommentId: z.string().uuid().nullable().optional(), +}).strict().superRefine((value, ctx) => { + const proposedText = value.proposedText ?? ""; + if ((value.kind === "insertion" || value.kind === "substitution") && proposedText.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "proposedText is required for insertion and substitution suggestions", + path: ["proposedText"], + }); + } + if (value.kind === "deletion" && proposedText.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "deletion suggestions cannot include proposedText", + path: ["proposedText"], + }); + } + if (value.kind !== "insertion" && value.insertionPosition != null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "insertionPosition is only valid for insertion suggestions", + path: ["insertionPosition"], + }); + } +}); + +export const createDocumentSuggestionCommentSchema = z.object({ + body: reviewBodySchema, + issueCommentId: z.string().uuid().nullable().optional(), +}).strict(); + +export const acceptDocumentSuggestionSchema = z.object({ + baseRevisionId: z.string().uuid(), + changeSummary: z.string().trim().min(1).max(500).nullable().optional(), +}).strict(); + +export const rejectDocumentSuggestionSchema = z.object({ + reason: z.string().trim().min(1).max(2_000).nullable().optional(), +}).strict(); + +export type CreateDocumentReviewThread = z.infer; +export type CreateDocumentReviewComment = z.infer; +export type UpdateDocumentReviewThread = z.infer; +export type CreateDocumentSuggestion = z.infer; +export type CreateDocumentSuggestionComment = z.infer; +export type AcceptDocumentSuggestion = z.infer; +export type RejectDocumentSuggestion = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index eea29cca8d7..994758bd65a 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -229,6 +229,27 @@ export { type UpdateDocumentAnnotationThread, } from "./document-annotation.js"; +export { + acceptDocumentSuggestionSchema, + createDocumentReviewCommentSchema, + createDocumentReviewThreadSchema, + createDocumentSuggestionCommentSchema, + createDocumentSuggestionSchema, + documentReviewThreadStatusSchema, + documentSuggestionInsertPositionSchema, + documentSuggestionKindSchema, + documentSuggestionStatusSchema, + rejectDocumentSuggestionSchema, + updateDocumentReviewThreadSchema, + type AcceptDocumentSuggestion, + type CreateDocumentReviewComment, + type CreateDocumentReviewThread, + type CreateDocumentSuggestion, + type CreateDocumentSuggestionComment, + type RejectDocumentSuggestion, + type UpdateDocumentReviewThread, +} from "./document-review.js"; + export { createIssueSchema, createIssueInputSchema, diff --git a/server/src/__tests__/document-review-service.test.ts b/server/src/__tests__/document-review-service.test.ts new file mode 100644 index 00000000000..33f3a61ebea --- /dev/null +++ b/server/src/__tests__/document-review-service.test.ts @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + documentAnnotationAnchorSnapshots, + documentAnnotationComments, + documentAnnotationThreads, + documentReviewComments, + documentReviewThreads, + documentRevisions, + documentSuggestionAnchorSnapshots, + documentSuggestionComments, + documentSuggestions, + documents, + issueComments, + issueDocuments, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { documentReviewService } from "../services/document-review.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 review service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("documentReviewService", () => { + let db!: ReturnType; + let docs!: ReturnType; + let review!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-document-review-"); + db = createDb(tempDb.connectionString); + docs = documentService(db); + review = documentReviewService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(documentSuggestionAnchorSnapshots); + await db.delete(documentSuggestionComments); + await db.delete(documentSuggestions); + await db.delete(documentReviewComments); + await db.delete(documentReviewThreads); + await db.delete(documentAnnotationAnchorSnapshots); + await db.delete(documentAnnotationComments); + await db.delete(documentAnnotationThreads); + await db.delete(documentRevisions); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issueComments); + await db.delete(issues); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createIssueWithDocument(body = "Alpha selected text omega") { + const companyId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + identifier: "PAP-10523", + title: "Review backend", + description: "Validate document review primitives", + status: "in_progress", + priority: "medium", + }); + + const created = await docs.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body, + }); + + return { companyId, issueId, document: created.document }; + } + + const selectedTextSelector = { + quote: { exact: "selected text", prefix: "Alpha ", suffix: " omega" }, + position: { normalizedStart: 6, normalizedEnd: 19, markdownStart: 6, markdownEnd: 19 }, + }; + + it("indexes document-level review threads and pending suggestions for agents", async () => { + const { issueId, document } = await createIssueWithDocument(); + + await review.createReviewThread( + issueId, + "plan", + { body: "Overall: tighten this document." }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + const suggestion = await review.createSuggestion( + issueId, + "plan", + { + baseRevisionId: document.latestRevisionId!, + baseRevisionNumber: document.latestRevisionNumber, + kind: "substitution", + selector: selectedTextSelector, + proposedText: "better text", + body: "Suggested replacement.", + }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + + const index = await review.getReviewIndex(issueId, "plan", { includeComments: true }); + + expect(index.counts.unresolved).toBe(2); + expect(index.counts.openReviewThreads).toBe(1); + expect(index.counts.pendingSuggestions).toBe(1); + expect(index.reviewThreads[0]?.comments[0]?.body).toBe("Overall: tighten this document."); + expect(index.suggestions[0]?.id).toBe(suggestion.id); + expect(index.suggestions[0]?.comments[0]?.body).toBe("Suggested replacement."); + }); + + it("accepts a suggestion by creating an auditable document revision", async () => { + const { issueId, document } = await createIssueWithDocument(); + const suggestion = await review.createSuggestion( + issueId, + "plan", + { + baseRevisionId: document.latestRevisionId!, + baseRevisionNumber: document.latestRevisionNumber, + kind: "substitution", + selector: selectedTextSelector, + proposedText: "better text", + }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + + const accepted = await review.acceptSuggestion( + issueId, + "plan", + suggestion.id, + { baseRevisionId: document.latestRevisionId!, changeSummary: "Accept suggested wording" }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + + expect(accepted.document.body).toBe("Alpha better text omega"); + expect(accepted.suggestion.status).toBe("accepted"); + expect(accepted.suggestion.acceptedRevisionId).toBe(accepted.revision.id); + const revisions = await docs.listIssueDocumentRevisions(issueId, "plan"); + expect(revisions.map((revision) => revision.changeSummary)).toContain("Accept suggested wording"); + const index = await review.getReviewIndex(issueId, "plan", { status: "all" }); + expect(index.counts.pendingSuggestions).toBe(0); + expect(index.counts.acceptedSuggestions).toBe(1); + }); + + it("remaps pending suggestions across document revisions before acceptance", async () => { + const { issueId, document } = await createIssueWithDocument(); + const suggestion = await review.createSuggestion( + issueId, + "plan", + { + baseRevisionId: document.latestRevisionId!, + baseRevisionNumber: document.latestRevisionNumber, + kind: "deletion", + selector: selectedTextSelector, + }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + const updated = await docs.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "Beta selected text omega", + baseRevisionId: document.latestRevisionId!, + changeSummary: "Manual edit before suggestion acceptance", + }); + + const remapped = await review.remapOpenSuggestionsForDocument({ + issueId, + key: "plan", + documentId: document.id, + nextRevisionId: updated.document.latestRevisionId, + nextRevisionNumber: updated.document.latestRevisionNumber, + nextBody: updated.document.body, + }); + + expect(remapped).toHaveLength(1); + expect(remapped[0]?.suggestion.id).toBe(suggestion.id); + expect(remapped[0]?.suggestion.anchorState).toBe("active"); + expect(remapped[0]?.suggestion.markdownStart).toBe(5); + + const accepted = await review.acceptSuggestion( + issueId, + "plan", + suggestion.id, + { baseRevisionId: updated.document.latestRevisionId! }, + { actorType: "user", actorId: "board-user", userId: "board-user" }, + ); + expect(accepted.document.body).toBe("Beta omega"); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 40768a74124..248968ad64c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -20,6 +20,7 @@ import { } from "@paperclipai/db"; import { addIssueCommentSchema, + acceptDocumentSuggestionSchema, acceptIssueThreadInteractionSchema, attachmentArtifactWorkProductMetadataSchema, cancelIssueThreadInteractionSchema, @@ -32,6 +33,10 @@ import { checkoutIssueSchema, createDocumentAnnotationCommentSchema, createDocumentAnnotationThreadSchema, + createDocumentReviewCommentSchema, + createDocumentReviewThreadSchema, + createDocumentSuggestionCommentSchema, + createDocumentSuggestionSchema, createChildIssueSchema, createIssueSchema, resolveCreateIssueStatusDefault, @@ -43,11 +48,13 @@ import { linkIssueApprovalSchema, issueDocumentKeySchema, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + rejectDocumentSuggestionSchema, rejectIssueThreadInteractionSchema, restoreIssueDocumentRevisionSchema, respondIssueThreadInteractionSchema, updateIssueWorkProductSchema, updateDocumentAnnotationThreadSchema, + updateDocumentReviewThreadSchema, upsertIssueDocumentSchema, updateIssueSchema, getClosedIsolatedExecutionWorkspaceMessage, @@ -83,6 +90,7 @@ import { clampIssueListLimit, documentService, documentAnnotationService, + documentReviewService, logActivity, projectService, routineService, @@ -960,6 +968,7 @@ export function issueRoutes( const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); const documentAnnotationsSvc = documentAnnotationService(db); + const documentReviewsSvc = documentReviewService(db); const issueReferencesSvc = issueReferenceService(db); const issueThreadInteractionsSvc = issueThreadInteractionService(db); const routinesSvc = routineService(db, { @@ -3286,6 +3295,414 @@ export function issueRoutes( }, ); + router.get("/issues/:id/documents/:key/review-index", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const index = await documentReviewsSvc.getReviewIndex(issue.id, keyParsed.data, { + status: req.query.status === "all" ? "all" : "open", + includeComments: parseBooleanQuery(req.query.includeComments), + }); + res.json(index); + }); + + router.post( + "/issues/:id/documents/:key/review-comments", + validate(createDocumentReviewThreadSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentReviewsSvc.createReviewThread(issue.id, keyParsed.data, req.body, annotationActor); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_review_thread_created", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + commentId: thread.comments[0]?.id ?? null, + }, + }); + res.status(201).json(thread); + }, + ); + + router.post( + "/issues/:id/documents/:key/review-comments/:threadId/comments", + validate(createDocumentReviewCommentSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const comment = await documentReviewsSvc.addReviewComment( + issue.id, + keyParsed.data, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_review_comment_added", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: keyParsed.data, + threadId: comment.threadId, + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + }, + }); + res.status(201).json(comment); + }, + ); + + router.patch( + "/issues/:id/documents/:key/review-comments/:threadId", + validate(updateDocumentReviewThreadSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentReviewsSvc.updateReviewThread( + issue.id, + keyParsed.data, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: thread.status === "resolved" + ? "issue.document_review_thread_resolved" + : "issue.document_review_thread_reopened", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + status: thread.status, + }, + }); + res.json(thread); + }, + ); + + router.post( + "/issues/:id/documents/:key/suggestions", + validate(createDocumentSuggestionSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const suggestion = await documentReviewsSvc.createSuggestion(issue.id, keyParsed.data, req.body, annotationActor); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_created", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: suggestion.documentKey, + documentId: suggestion.documentId, + suggestionId: suggestion.id, + kind: suggestion.kind, + revisionNumber: suggestion.currentRevisionNumber, + quote: suggestion.selectedText.slice(0, 240), + }, + }); + res.status(201).json(suggestion); + }, + ); + + router.post( + "/issues/:id/documents/:key/suggestions/:suggestionId/comments", + validate(createDocumentSuggestionCommentSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const comment = await documentReviewsSvc.addSuggestionComment( + issue.id, + keyParsed.data, + req.params.suggestionId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_comment_added", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: keyParsed.data, + suggestionId: comment.suggestionId, + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + }, + }); + res.status(201).json(comment); + }, + ); + + router.post( + "/issues/:id/documents/:key/suggestions/:suggestionId/reject", + validate(rejectDocumentSuggestionSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + const { actor, annotationActor } = annotationActorInput(req); + const suggestion = await documentReviewsSvc.rejectSuggestion( + issue.id, + keyParsed.data, + req.params.suggestionId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_rejected", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: suggestion.documentKey, + documentId: suggestion.documentId, + suggestionId: suggestion.id, + kind: suggestion.kind, + reason: req.body.reason ?? null, + }, + }); + res.json(suggestion); + }, + ); + + router.post( + "/issues/:id/documents/:key/suggestions/:suggestionId/accept", + validate(acceptDocumentSuggestionSchema), + async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; + const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); + if (!keyParsed.success) { + res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); + return; + } + + const actor = getActorInfo(req); + const result = await documentReviewsSvc.acceptSuggestion( + issue.id, + keyParsed.data, + req.params.suggestionId as string, + req.body, + { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }, + ); + await issueReferencesSvc.syncDocument(result.document.id); + const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({ + issueId: issue.id, + key: result.document.key, + documentId: result.document.id, + nextRevisionId: result.document.latestRevisionId, + nextRevisionNumber: result.document.latestRevisionNumber, + nextBody: result.document.body, + }); + const remappedSuggestions = await documentReviewsSvc.remapOpenSuggestionsForDocument({ + issueId: issue.id, + key: result.document.key, + documentId: result.document.id, + nextRevisionId: result.document.latestRevisionId, + nextRevisionNumber: result.document.latestRevisionNumber, + nextBody: result.document.body, + }); + + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_accepted", + entityType: "issue", + entityId: issue.id, + details: { + documentKey: result.document.key, + documentId: result.document.id, + suggestionId: result.suggestion.id, + kind: result.suggestion.kind, + acceptedRevisionId: result.revision.id, + revisionNumber: result.revision.revisionNumber, + }, + }); + + for (const remap of remappedAnnotations) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_annotation_remapped", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + threadId: remap.thread.id, + revisionNumber: result.document.latestRevisionNumber, + anchorState: remap.thread.anchorState, + anchorConfidence: remap.thread.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } + for (const remap of remappedSuggestions) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_remapped", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + suggestionId: remap.suggestion.id, + revisionNumber: result.document.latestRevisionNumber, + anchorState: remap.suggestion.anchorState, + anchorConfidence: remap.suggestion.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } + + await revalidateActiveSourceRecoveryAfterCommittedWrite({ + issue, + trigger: "document", + actor, + documentChanged: true, + }); + + res.json(result); + }, + ); + router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -3335,6 +3752,16 @@ export function issueRoutes( nextRevisionNumber: doc.latestRevisionNumber, nextBody: doc.body, }); + const remappedSuggestions = result.created + ? [] + : await documentReviewsSvc.remapOpenSuggestionsForDocument({ + issueId: issue.id, + key: doc.key, + documentId: doc.id, + nextRevisionId: doc.latestRevisionId, + nextRevisionNumber: doc.latestRevisionNumber, + nextBody: doc.body, + }); await logActivity(db, { companyId: issue.companyId, @@ -3381,6 +3808,27 @@ export function issueRoutes( }, }); } + for (const remap of remappedSuggestions) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_remapped", + entityType: "issue", + entityId: issue.id, + details: { + key: doc.key, + documentId: doc.id, + suggestionId: remap.suggestion.id, + revisionNumber: doc.latestRevisionNumber, + anchorState: remap.suggestion.anchorState, + anchorConfidence: remap.suggestion.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } if (!result.created) { const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( @@ -3561,6 +4009,14 @@ export function issueRoutes( nextRevisionNumber: result.document.latestRevisionNumber, nextBody: result.document.body, }); + const remappedSuggestions = await documentReviewsSvc.remapOpenSuggestionsForDocument({ + issueId: issue.id, + key: result.document.key, + documentId: result.document.id, + nextRevisionId: result.document.latestRevisionId, + nextRevisionNumber: result.document.latestRevisionNumber, + nextBody: result.document.body, + }); await logActivity(db, { companyId: issue.companyId, @@ -3608,6 +4064,27 @@ export function issueRoutes( }, }); } + for (const remap of remappedSuggestions) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.document_suggestion_remapped", + entityType: "issue", + entityId: issue.id, + details: { + key: result.document.key, + documentId: result.document.id, + suggestionId: remap.suggestion.id, + revisionNumber: result.document.latestRevisionNumber, + anchorState: remap.suggestion.anchorState, + anchorConfidence: remap.suggestion.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument( issue, diff --git a/server/src/services/document-review.ts b/server/src/services/document-review.ts new file mode 100644 index 00000000000..68f7af82816 --- /dev/null +++ b/server/src/services/document-review.ts @@ -0,0 +1,832 @@ +import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + documentAnnotationComments, + documentAnnotationThreads, + documentRevisions, + documentReviewComments, + documentReviewThreads, + documentSuggestionAnchorSnapshots, + documentSuggestionComments, + documentSuggestions, + documents, + issueComments, + issueDocuments, +} from "@paperclipai/db"; +import { + anchorSnapshotToSelector, + remapDocumentAnchor, + selectorToAnchorSnapshot, + verifyDocumentAnchorSelector, + type AcceptDocumentSuggestion, + type CreateDocumentReviewComment, + type CreateDocumentReviewThread, + type CreateDocumentSuggestion, + type CreateDocumentSuggestionComment, + type DocumentAnnotationAnchorSnapshot, + type DocumentAnnotationComment, + type DocumentAnnotationThread, + type DocumentReviewComment, + type DocumentReviewIndex, + type DocumentReviewThread, + type DocumentSuggestion, + type DocumentSuggestionComment, + type RejectDocumentSuggestion, + type UpdateDocumentReviewThread, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +type ActorInput = { + actorType: "agent" | "user"; + actorId: string; + agentId?: string | null; + userId?: string | null; + runId?: string | null; +}; + +type IssueDocumentRow = { + issueId: string; + companyId: string; + documentId: string; + documentKey: string; + title: string | null; + format: string; + latestBody: string; + latestRevisionId: string | null; + latestRevisionNumber: number; +}; + +const annotationThreadSelect = { + id: documentAnnotationThreads.id, + companyId: documentAnnotationThreads.companyId, + issueId: documentAnnotationThreads.issueId, + documentId: documentAnnotationThreads.documentId, + documentKey: documentAnnotationThreads.documentKey, + status: documentAnnotationThreads.status, + anchorState: documentAnnotationThreads.anchorState, + anchorConfidence: documentAnnotationThreads.anchorConfidence, + originalRevisionId: documentAnnotationThreads.originalRevisionId, + originalRevisionNumber: documentAnnotationThreads.originalRevisionNumber, + currentRevisionId: documentAnnotationThreads.currentRevisionId, + currentRevisionNumber: documentAnnotationThreads.currentRevisionNumber, + selectedText: documentAnnotationThreads.selectedText, + prefixText: documentAnnotationThreads.prefixText, + suffixText: documentAnnotationThreads.suffixText, + normalizedStart: documentAnnotationThreads.normalizedStart, + normalizedEnd: documentAnnotationThreads.normalizedEnd, + markdownStart: documentAnnotationThreads.markdownStart, + markdownEnd: documentAnnotationThreads.markdownEnd, + anchorSelector: documentAnnotationThreads.anchorSelector, + createdByAgentId: documentAnnotationThreads.createdByAgentId, + createdByUserId: documentAnnotationThreads.createdByUserId, + resolvedByAgentId: documentAnnotationThreads.resolvedByAgentId, + resolvedByUserId: documentAnnotationThreads.resolvedByUserId, + resolvedAt: documentAnnotationThreads.resolvedAt, + createdAt: documentAnnotationThreads.createdAt, + updatedAt: documentAnnotationThreads.updatedAt, +}; + +const annotationCommentSelect = { + id: documentAnnotationComments.id, + companyId: documentAnnotationComments.companyId, + threadId: documentAnnotationComments.threadId, + issueId: documentAnnotationComments.issueId, + documentId: documentAnnotationComments.documentId, + body: documentAnnotationComments.body, + authorType: documentAnnotationComments.authorType, + authorAgentId: documentAnnotationComments.authorAgentId, + authorUserId: documentAnnotationComments.authorUserId, + createdByRunId: documentAnnotationComments.createdByRunId, + issueCommentId: documentAnnotationComments.issueCommentId, + createdAt: documentAnnotationComments.createdAt, + updatedAt: documentAnnotationComments.updatedAt, +}; + +const reviewThreadSelect = { + id: documentReviewThreads.id, + companyId: documentReviewThreads.companyId, + issueId: documentReviewThreads.issueId, + documentId: documentReviewThreads.documentId, + documentKey: documentReviewThreads.documentKey, + status: documentReviewThreads.status, + createdByAgentId: documentReviewThreads.createdByAgentId, + createdByUserId: documentReviewThreads.createdByUserId, + resolvedByAgentId: documentReviewThreads.resolvedByAgentId, + resolvedByUserId: documentReviewThreads.resolvedByUserId, + resolvedAt: documentReviewThreads.resolvedAt, + createdAt: documentReviewThreads.createdAt, + updatedAt: documentReviewThreads.updatedAt, +}; + +const reviewCommentSelect = { + id: documentReviewComments.id, + companyId: documentReviewComments.companyId, + threadId: documentReviewComments.threadId, + issueId: documentReviewComments.issueId, + documentId: documentReviewComments.documentId, + body: documentReviewComments.body, + authorType: documentReviewComments.authorType, + authorAgentId: documentReviewComments.authorAgentId, + authorUserId: documentReviewComments.authorUserId, + createdByRunId: documentReviewComments.createdByRunId, + issueCommentId: documentReviewComments.issueCommentId, + createdAt: documentReviewComments.createdAt, + updatedAt: documentReviewComments.updatedAt, +}; + +const suggestionSelect = { + id: documentSuggestions.id, + companyId: documentSuggestions.companyId, + issueId: documentSuggestions.issueId, + documentId: documentSuggestions.documentId, + documentKey: documentSuggestions.documentKey, + kind: documentSuggestions.kind, + status: documentSuggestions.status, + anchorState: documentSuggestions.anchorState, + anchorConfidence: documentSuggestions.anchorConfidence, + originalRevisionId: documentSuggestions.originalRevisionId, + originalRevisionNumber: documentSuggestions.originalRevisionNumber, + currentRevisionId: documentSuggestions.currentRevisionId, + currentRevisionNumber: documentSuggestions.currentRevisionNumber, + selectedText: documentSuggestions.selectedText, + proposedText: documentSuggestions.proposedText, + insertionPosition: documentSuggestions.insertionPosition, + prefixText: documentSuggestions.prefixText, + suffixText: documentSuggestions.suffixText, + normalizedStart: documentSuggestions.normalizedStart, + normalizedEnd: documentSuggestions.normalizedEnd, + markdownStart: documentSuggestions.markdownStart, + markdownEnd: documentSuggestions.markdownEnd, + anchorSelector: documentSuggestions.anchorSelector, + createdByAgentId: documentSuggestions.createdByAgentId, + createdByUserId: documentSuggestions.createdByUserId, + acceptedByAgentId: documentSuggestions.acceptedByAgentId, + acceptedByUserId: documentSuggestions.acceptedByUserId, + acceptedAt: documentSuggestions.acceptedAt, + acceptedRevisionId: documentSuggestions.acceptedRevisionId, + rejectedByAgentId: documentSuggestions.rejectedByAgentId, + rejectedByUserId: documentSuggestions.rejectedByUserId, + rejectedAt: documentSuggestions.rejectedAt, + createdAt: documentSuggestions.createdAt, + updatedAt: documentSuggestions.updatedAt, +}; + +const suggestionCommentSelect = { + id: documentSuggestionComments.id, + companyId: documentSuggestionComments.companyId, + suggestionId: documentSuggestionComments.suggestionId, + issueId: documentSuggestionComments.issueId, + documentId: documentSuggestionComments.documentId, + body: documentSuggestionComments.body, + authorType: documentSuggestionComments.authorType, + authorAgentId: documentSuggestionComments.authorAgentId, + authorUserId: documentSuggestionComments.authorUserId, + createdByRunId: documentSuggestionComments.createdByRunId, + issueCommentId: documentSuggestionComments.issueCommentId, + createdAt: documentSuggestionComments.createdAt, + updatedAt: documentSuggestionComments.updatedAt, +}; + +function snapshotFromSuggestion(suggestion: Pick): DocumentAnnotationAnchorSnapshot { + return { + selectedText: suggestion.selectedText, + prefixText: suggestion.prefixText, + suffixText: suggestion.suffixText, + normalizedStart: suggestion.normalizedStart, + normalizedEnd: suggestion.normalizedEnd, + markdownStart: suggestion.markdownStart, + markdownEnd: suggestion.markdownEnd, + }; +} + +function applySuggestionToBody(body: string, suggestion: DocumentSuggestion) { + if (suggestion.anchorState === "orphaned") { + throw conflict("Cannot accept an orphaned suggestion", { suggestionId: suggestion.id }); + } + if (suggestion.kind === "insertion") { + const insertionText = suggestion.proposedText ?? ""; + if (!insertionText) throw unprocessable("Insertion suggestion is missing proposedText"); + const position = suggestion.insertionPosition === "before" ? suggestion.markdownStart : suggestion.markdownEnd; + return `${body.slice(0, position)}${insertionText}${body.slice(position)}`; + } + if (suggestion.kind === "deletion") { + return `${body.slice(0, suggestion.markdownStart)}${body.slice(suggestion.markdownEnd)}`; + } + const replacementText = suggestion.proposedText ?? ""; + if (!replacementText) throw unprocessable("Substitution suggestion is missing proposedText"); + return `${body.slice(0, suggestion.markdownStart)}${replacementText}${body.slice(suggestion.markdownEnd)}`; +} + +export function documentReviewService(db: Db) { + async function getIssueDocument(issueId: string, key: string, dbOrTx: any = db): Promise { + return dbOrTx + .select({ + issueId: issueDocuments.issueId, + companyId: documents.companyId, + documentId: documents.id, + documentKey: issueDocuments.key, + title: documents.title, + format: documents.format, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))) + .then((rows: IssueDocumentRow[]) => rows[0] ?? null); + } + + async function assertLinkedIssueComment(issueId: string, commentId: string | null | undefined, dbOrTx: any = db) { + if (!commentId) return null; + const comment = await dbOrTx + .select({ id: issueComments.id, issueId: issueComments.issueId }) + .from(issueComments) + .where(and(eq(issueComments.id, commentId), isNull(issueComments.deletedAt))) + .then((rows: Array<{ id: string; issueId: string }>) => rows[0] ?? null); + if (!comment || comment.issueId !== issueId) { + throw unprocessable("Linked issue comment must belong to this issue"); + } + return comment; + } + + async function commentsForReviewThreads(threadIds: string[], dbOrTx: any = db): Promise { + if (threadIds.length === 0) return []; + return dbOrTx + .select(reviewCommentSelect) + .from(documentReviewComments) + .where(inArray(documentReviewComments.threadId, threadIds)) + .orderBy(asc(documentReviewComments.createdAt), asc(documentReviewComments.id)); + } + + async function commentsForSuggestions(suggestionIds: string[], dbOrTx: any = db): Promise { + if (suggestionIds.length === 0) return []; + return dbOrTx + .select(suggestionCommentSelect) + .from(documentSuggestionComments) + .where(inArray(documentSuggestionComments.suggestionId, suggestionIds)) + .orderBy(asc(documentSuggestionComments.createdAt), asc(documentSuggestionComments.id)); + } + + async function commentsForAnnotationThreads(threadIds: string[], dbOrTx: any = db): Promise { + if (threadIds.length === 0) return []; + return dbOrTx + .select(annotationCommentSelect) + .from(documentAnnotationComments) + .where(inArray(documentAnnotationComments.threadId, threadIds)) + .orderBy(asc(documentAnnotationComments.createdAt), asc(documentAnnotationComments.id)); + } + + async function getReviewThreadForIssue(issueId: string, documentKey: string, threadId: string, dbOrTx: any = db) { + return dbOrTx + .select(reviewThreadSelect) + .from(documentReviewThreads) + .where(and( + eq(documentReviewThreads.id, threadId), + eq(documentReviewThreads.issueId, issueId), + eq(documentReviewThreads.documentKey, documentKey), + )) + .then((rows: DocumentReviewThread[]) => rows[0] ?? null); + } + + async function getSuggestionForIssue(issueId: string, documentKey: string, suggestionId: string, dbOrTx: any = db) { + return dbOrTx + .select(suggestionSelect) + .from(documentSuggestions) + .where(and( + eq(documentSuggestions.id, suggestionId), + eq(documentSuggestions.issueId, issueId), + eq(documentSuggestions.documentKey, documentKey), + )) + .then((rows: DocumentSuggestion[]) => rows[0] ?? null); + } + + return { + getReviewIndex: async ( + issueId: string, + key: string, + options: { status?: "open" | "all"; includeComments?: boolean } = {}, + ): Promise => { + const doc = await getIssueDocument(issueId, key); + if (!doc) throw notFound("Document not found"); + const openOnly = options.status !== "all"; + const annotationConditions = [ + eq(documentAnnotationThreads.issueId, issueId), + eq(documentAnnotationThreads.documentId, doc.documentId), + ]; + const reviewConditions = [ + eq(documentReviewThreads.issueId, issueId), + eq(documentReviewThreads.documentId, doc.documentId), + ]; + const suggestionConditions = [ + eq(documentSuggestions.issueId, issueId), + eq(documentSuggestions.documentId, doc.documentId), + ]; + if (openOnly) { + annotationConditions.push(eq(documentAnnotationThreads.status, "open")); + reviewConditions.push(eq(documentReviewThreads.status, "open")); + suggestionConditions.push(eq(documentSuggestions.status, "pending")); + } + + const [ + annotationThreads, + reviewThreads, + suggestions, + annotationCountRows, + reviewCountRows, + suggestionCountRows, + ] = await Promise.all([ + db.select(annotationThreadSelect).from(documentAnnotationThreads) + .where(and(...annotationConditions)) + .orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id)), + db.select(reviewThreadSelect).from(documentReviewThreads) + .where(and(...reviewConditions)) + .orderBy(desc(documentReviewThreads.updatedAt), desc(documentReviewThreads.id)), + db.select(suggestionSelect).from(documentSuggestions) + .where(and(...suggestionConditions)) + .orderBy(desc(documentSuggestions.updatedAt), desc(documentSuggestions.id)), + db.select({ + openAnchoredThreads: sql`count(*) filter (where ${documentAnnotationThreads.status} = 'open')`.mapWith(Number), + resolvedAnchoredThreads: sql`count(*) filter (where ${documentAnnotationThreads.status} = 'resolved')`.mapWith(Number), + staleAnchors: sql`count(*) filter (where ${documentAnnotationThreads.status} = 'open' and ${documentAnnotationThreads.anchorState} = 'stale')`.mapWith(Number), + orphanedAnchors: sql`count(*) filter (where ${documentAnnotationThreads.status} = 'open' and ${documentAnnotationThreads.anchorState} = 'orphaned')`.mapWith(Number), + }).from(documentAnnotationThreads).where(and( + eq(documentAnnotationThreads.issueId, issueId), + eq(documentAnnotationThreads.documentId, doc.documentId), + )), + db.select({ + openReviewThreads: sql`count(*) filter (where ${documentReviewThreads.status} = 'open')`.mapWith(Number), + resolvedReviewThreads: sql`count(*) filter (where ${documentReviewThreads.status} = 'resolved')`.mapWith(Number), + }).from(documentReviewThreads).where(and( + eq(documentReviewThreads.issueId, issueId), + eq(documentReviewThreads.documentId, doc.documentId), + )), + db.select({ + pendingSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number), + acceptedSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number), + rejectedSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number), + staleAnchors: sql`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'stale')`.mapWith(Number), + orphanedAnchors: sql`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'orphaned')`.mapWith(Number), + }).from(documentSuggestions).where(and( + eq(documentSuggestions.issueId, issueId), + eq(documentSuggestions.documentId, doc.documentId), + )), + ]); + + const annotationCounts = annotationCountRows[0] ?? { + openAnchoredThreads: 0, + resolvedAnchoredThreads: 0, + staleAnchors: 0, + orphanedAnchors: 0, + }; + const reviewCounts = reviewCountRows[0] ?? { + openReviewThreads: 0, + resolvedReviewThreads: 0, + }; + const suggestionCounts = suggestionCountRows[0] ?? { + pendingSuggestions: 0, + acceptedSuggestions: 0, + rejectedSuggestions: 0, + staleAnchors: 0, + orphanedAnchors: 0, + }; + const countsBase = { + openAnchoredThreads: annotationCounts.openAnchoredThreads, + resolvedAnchoredThreads: annotationCounts.resolvedAnchoredThreads, + openReviewThreads: reviewCounts.openReviewThreads, + resolvedReviewThreads: reviewCounts.resolvedReviewThreads, + pendingSuggestions: suggestionCounts.pendingSuggestions, + acceptedSuggestions: suggestionCounts.acceptedSuggestions, + rejectedSuggestions: suggestionCounts.rejectedSuggestions, + staleAnchors: annotationCounts.staleAnchors + suggestionCounts.staleAnchors, + orphanedAnchors: annotationCounts.orphanedAnchors + suggestionCounts.orphanedAnchors, + }; + const [annotationComments, reviewComments, suggestionComments] = options.includeComments + ? await Promise.all([ + commentsForAnnotationThreads(annotationThreads.map((thread) => thread.id)), + commentsForReviewThreads(reviewThreads.map((thread) => thread.id)), + commentsForSuggestions(suggestions.map((suggestion) => suggestion.id)), + ]) + : [[], [], []] as const; + const annotationCommentsByThread = new Map(); + for (const comment of annotationComments) { + annotationCommentsByThread.set(comment.threadId, [...(annotationCommentsByThread.get(comment.threadId) ?? []), comment]); + } + const reviewCommentsByThread = new Map(); + for (const comment of reviewComments) { + reviewCommentsByThread.set(comment.threadId, [...(reviewCommentsByThread.get(comment.threadId) ?? []), comment]); + } + const commentsBySuggestion = new Map(); + for (const comment of suggestionComments) { + commentsBySuggestion.set(comment.suggestionId, [...(commentsBySuggestion.get(comment.suggestionId) ?? []), comment]); + } + + return { + issueId, + documentId: doc.documentId, + documentKey: doc.documentKey, + latestRevisionId: doc.latestRevisionId, + latestRevisionNumber: doc.latestRevisionNumber, + counts: { + ...countsBase, + unresolved: countsBase.openAnchoredThreads + countsBase.openReviewThreads + countsBase.pendingSuggestions, + }, + annotationThreads: annotationThreads.map((thread) => ({ + ...thread, + comments: annotationCommentsByThread.get(thread.id) ?? [], + })), + reviewThreads: reviewThreads.map((thread) => ({ + ...thread, + comments: reviewCommentsByThread.get(thread.id) ?? [], + })), + suggestions: suggestions.map((suggestion) => ({ + ...suggestion, + comments: commentsBySuggestion.get(suggestion.id) ?? [], + })), + }; + }, + + createReviewThread: async ( + issueId: string, + key: string, + input: CreateDocumentReviewThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const doc = await getIssueDocument(issueId, key, tx); + if (!doc) throw notFound("Document not found"); + const now = new Date(); + const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx); + const [thread] = await tx.insert(documentReviewThreads).values({ + companyId: doc.companyId, + issueId, + documentId: doc.documentId, + documentKey: doc.documentKey, + status: "open", + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdAt: now, + updatedAt: now, + }).returning(reviewThreadSelect); + const [comment] = await tx.insert(documentReviewComments).values({ + companyId: doc.companyId, + threadId: thread.id, + issueId, + documentId: doc.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: linkedIssueComment?.id ?? null, + createdAt: now, + updatedAt: now, + }).returning(reviewCommentSelect); + return { ...thread, comments: [comment] }; + }), + + addReviewComment: async ( + issueId: string, + key: string, + threadId: string, + input: CreateDocumentReviewComment, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getReviewThreadForIssue(issueId, key, threadId, tx); + if (!thread) throw notFound("Review thread not found"); + const now = new Date(); + const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx); + const [comment] = await tx.insert(documentReviewComments).values({ + companyId: thread.companyId, + threadId: thread.id, + issueId: thread.issueId, + documentId: thread.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: linkedIssueComment?.id ?? null, + createdAt: now, + updatedAt: now, + }).returning(reviewCommentSelect); + await tx.update(documentReviewThreads).set({ updatedAt: now }).where(eq(documentReviewThreads.id, thread.id)); + return comment; + }), + + updateReviewThread: async ( + issueId: string, + key: string, + threadId: string, + input: UpdateDocumentReviewThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const thread = await getReviewThreadForIssue(issueId, key, threadId, tx); + if (!thread) throw notFound("Review thread not found"); + if (!input.status || input.status === thread.status) return thread; + const now = new Date(); + const [updated] = await tx.update(documentReviewThreads) + .set(input.status === "resolved" + ? { + status: "resolved", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + } + : { + status: "open", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + updatedAt: now, + }) + .where(eq(documentReviewThreads.id, thread.id)) + .returning(reviewThreadSelect); + return updated; + }), + + createSuggestion: async ( + issueId: string, + key: string, + input: CreateDocumentSuggestion, + actor: ActorInput, + ) => db.transaction(async (tx) => { + await tx.execute(sql` + select ${documents.id} + from ${issueDocuments} + inner join ${documents} on ${issueDocuments.documentId} = ${documents.id} + where ${and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))} + for update of ${documents} + `); + const doc = await getIssueDocument(issueId, key, tx); + if (!doc) throw notFound("Document not found"); + if (input.baseRevisionId !== doc.latestRevisionId || input.baseRevisionNumber !== doc.latestRevisionNumber) { + throw conflict("Suggestion anchor requires the current document revision", { + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + }); + } + const verification = verifyDocumentAnchorSelector({ markdown: doc.latestBody, selector: input.selector }); + if (!verification.ok || !verification.anchor) { + throw unprocessable("Suggestion anchor does not match the current document revision", { + reason: verification.reason, + }); + } + const now = new Date(); + const [suggestion] = await tx.insert(documentSuggestions).values({ + companyId: doc.companyId, + issueId, + documentId: doc.documentId, + documentKey: doc.documentKey, + kind: input.kind, + status: "pending", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: doc.latestRevisionId, + originalRevisionNumber: doc.latestRevisionNumber, + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + selectedText: verification.anchor.selectedText, + proposedText: input.kind === "deletion" ? null : input.proposedText ?? null, + insertionPosition: input.kind === "insertion" ? input.insertionPosition ?? "after" : null, + prefixText: verification.anchor.prefixText, + suffixText: verification.anchor.suffixText, + normalizedStart: verification.anchor.normalizedStart, + normalizedEnd: verification.anchor.normalizedEnd, + markdownStart: verification.anchor.markdownStart, + markdownEnd: verification.anchor.markdownEnd, + anchorSelector: input.selector, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdAt: now, + updatedAt: now, + }).returning(suggestionSelect); + const comments = []; + if (input.body) { + const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx); + const [comment] = await tx.insert(documentSuggestionComments).values({ + companyId: doc.companyId, + suggestionId: suggestion.id, + issueId, + documentId: doc.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: linkedIssueComment?.id ?? null, + createdAt: now, + updatedAt: now, + }).returning(suggestionCommentSelect); + comments.push(comment); + } + return { ...suggestion, comments }; + }), + + addSuggestionComment: async ( + issueId: string, + key: string, + suggestionId: string, + input: CreateDocumentSuggestionComment, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const suggestion = await getSuggestionForIssue(issueId, key, suggestionId, tx); + if (!suggestion) throw notFound("Suggestion not found"); + const now = new Date(); + const linkedIssueComment = await assertLinkedIssueComment(issueId, input.issueCommentId, tx); + const [comment] = await tx.insert(documentSuggestionComments).values({ + companyId: suggestion.companyId, + suggestionId: suggestion.id, + issueId: suggestion.issueId, + documentId: suggestion.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: linkedIssueComment?.id ?? null, + createdAt: now, + updatedAt: now, + }).returning(suggestionCommentSelect); + await tx.update(documentSuggestions).set({ updatedAt: now }).where(eq(documentSuggestions.id, suggestion.id)); + return comment; + }), + + rejectSuggestion: async ( + issueId: string, + key: string, + suggestionId: string, + _input: RejectDocumentSuggestion, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const suggestion = await getSuggestionForIssue(issueId, key, suggestionId, tx); + if (!suggestion) throw notFound("Suggestion not found"); + if (suggestion.status !== "pending") { + throw conflict("Only pending suggestions can be rejected", { status: suggestion.status }); + } + const now = new Date(); + const [updated] = await tx.update(documentSuggestions).set({ + status: "rejected", + rejectedByAgentId: actor.agentId ?? null, + rejectedByUserId: actor.userId ?? null, + rejectedAt: now, + updatedAt: now, + }).where(eq(documentSuggestions.id, suggestion.id)).returning(suggestionSelect); + return updated; + }), + + acceptSuggestion: async ( + issueId: string, + key: string, + suggestionId: string, + input: AcceptDocumentSuggestion, + actor: ActorInput, + ) => db.transaction(async (tx) => { + await tx.execute(sql` + select ${documents.id} + from ${issueDocuments} + inner join ${documents} on ${issueDocuments.documentId} = ${documents.id} + where ${and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key))} + for update of ${documents} + `); + const doc = await getIssueDocument(issueId, key, tx); + if (!doc) throw notFound("Document not found"); + const suggestion = await getSuggestionForIssue(issueId, key, suggestionId, tx); + if (!suggestion || suggestion.documentId !== doc.documentId) throw notFound("Suggestion not found"); + if (suggestion.status !== "pending") { + throw conflict("Only pending suggestions can be accepted", { status: suggestion.status }); + } + if (doc.latestRevisionId !== input.baseRevisionId) { + throw conflict("Document was updated by someone else", { currentRevisionId: doc.latestRevisionId }); + } + if (suggestion.currentRevisionId !== doc.latestRevisionId || suggestion.currentRevisionNumber !== doc.latestRevisionNumber) { + throw conflict("Suggestion must be remapped to the current document revision before acceptance", { + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + suggestionRevisionId: suggestion.currentRevisionId, + suggestionRevisionNumber: suggestion.currentRevisionNumber, + }); + } + const verification = verifyDocumentAnchorSelector({ markdown: doc.latestBody, selector: suggestion.anchorSelector }); + if (!verification.ok) { + throw conflict("Suggestion anchor no longer matches the current document revision", { reason: verification.reason }); + } + + const now = new Date(); + const nextBody = applySuggestionToBody(doc.latestBody, suggestion); + const nextRevisionNumber = doc.latestRevisionNumber + 1; + const [revision] = await tx.insert(documentRevisions).values({ + companyId: doc.companyId, + documentId: doc.documentId, + revisionNumber: nextRevisionNumber, + title: doc.title, + format: doc.format, + body: nextBody, + changeSummary: input.changeSummary ?? `Accepted ${suggestion.kind} suggestion ${suggestion.id.slice(0, 8)}`, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + createdAt: now, + }).returning(); + await tx.update(documents).set({ + latestBody: nextBody, + latestRevisionId: revision.id, + latestRevisionNumber: nextRevisionNumber, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: now, + }).where(eq(documents.id, doc.documentId)); + await tx.update(issueDocuments).set({ updatedAt: now }).where(eq(issueDocuments.documentId, doc.documentId)); + const [updatedSuggestion] = await tx.update(documentSuggestions).set({ + status: "accepted", + acceptedByAgentId: actor.agentId ?? null, + acceptedByUserId: actor.userId ?? null, + acceptedAt: now, + acceptedRevisionId: revision.id, + updatedAt: now, + }).where(eq(documentSuggestions.id, suggestion.id)).returning(suggestionSelect); + + return { + suggestion: updatedSuggestion, + document: { + id: doc.documentId, + companyId: doc.companyId, + issueId, + key: doc.documentKey, + title: doc.title, + format: doc.format, + body: nextBody, + latestRevisionId: revision.id, + latestRevisionNumber: nextRevisionNumber, + }, + revision, + }; + }), + + remapOpenSuggestionsForDocument: async (input: { + issueId: string; + key: string; + documentId: string; + nextRevisionId: string | null; + nextRevisionNumber: number; + nextBody: string; + }) => db.transaction(async (tx) => { + const suggestions: DocumentSuggestion[] = await tx.select(suggestionSelect).from(documentSuggestions) + .where(and( + eq(documentSuggestions.issueId, input.issueId), + eq(documentSuggestions.documentId, input.documentId), + eq(documentSuggestions.status, "pending"), + )); + const changed = []; + const now = new Date(); + for (const suggestion of suggestions) { + if (suggestion.currentRevisionId === input.nextRevisionId) continue; + const previousAnchor = snapshotFromSuggestion(suggestion); + const remap = remapDocumentAnchor({ + previousAnchor, + nextMarkdown: input.nextBody, + }); + const nextAnchor = remap.anchor; + const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : suggestion.anchorSelector; + const [updated] = await tx.update(documentSuggestions).set({ + currentRevisionId: input.nextRevisionId, + currentRevisionNumber: input.nextRevisionNumber, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + ...(nextAnchor + ? { + selectedText: nextAnchor.selectedText, + prefixText: nextAnchor.prefixText, + suffixText: nextAnchor.suffixText, + normalizedStart: nextAnchor.normalizedStart, + normalizedEnd: nextAnchor.normalizedEnd, + markdownStart: nextAnchor.markdownStart, + markdownEnd: nextAnchor.markdownEnd, + } + : {}), + anchorSelector: nextSelector, + updatedAt: now, + }).where(eq(documentSuggestions.id, suggestion.id)).returning(suggestionSelect); + const [snapshot] = await tx.insert(documentSuggestionAnchorSnapshots).values({ + companyId: suggestion.companyId, + suggestionId: suggestion.id, + documentId: suggestion.documentId, + fromRevisionId: suggestion.currentRevisionId, + fromRevisionNumber: suggestion.currentRevisionNumber, + toRevisionId: input.nextRevisionId, + toRevisionNumber: input.nextRevisionNumber, + previousAnchor, + nextAnchor, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + failureReason: remap.anchor ? null : remap.reason, + createdAt: now, + }).returning(); + changed.push({ suggestion: updated, snapshot }); + } + return changed; + }), + + selectorToAnchorSnapshot, + }; +} diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 05ff5d5f090..47abe294635 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -6,6 +6,8 @@ import { documentAnnotationThreads, documentLinks, documentRevisions, + documentReviewThreads, + documentSuggestions, documents, goals, heartbeatRuns, @@ -223,6 +225,11 @@ function emptyFeedbackCounts(): DocumentFeedbackCounts { return { openComments: 0, resolvedComments: 0, + openReviewThreads: 0, + resolvedReviewThreads: 0, + pendingSuggestions: 0, + acceptedSuggestions: 0, + rejectedSuggestions: 0, staleAnchors: 0, orphanedAnchors: 0, }; @@ -258,12 +265,62 @@ export function documentService(db: Db) { for (const row of rows) { result.set(row.documentId, { + ...emptyFeedbackCounts(), openComments: row.openComments, resolvedComments: row.resolvedComments, staleAnchors: row.staleAnchors, orphanedAnchors: row.orphanedAnchors, }); } + const reviewRows = await db + .select({ + documentId: documentReviewThreads.documentId, + openReviewThreads: sql`count(*) filter (where ${documentReviewThreads.status} = 'open')`.mapWith(Number), + resolvedReviewThreads: sql`count(*) filter (where ${documentReviewThreads.status} = 'resolved')`.mapWith(Number), + }) + .from(documentReviewThreads) + .where(and( + eq(documentReviewThreads.companyId, companyId), + inArray(documentReviewThreads.documentId, documentIds), + )) + .groupBy(documentReviewThreads.documentId); + + for (const row of reviewRows) { + const existing = result.get(row.documentId) ?? emptyFeedbackCounts(); + result.set(row.documentId, { + ...existing, + openReviewThreads: row.openReviewThreads, + resolvedReviewThreads: row.resolvedReviewThreads, + }); + } + + const suggestionRows = await db + .select({ + documentId: documentSuggestions.documentId, + pendingSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number), + acceptedSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number), + rejectedSuggestions: sql`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number), + staleAnchors: sql`count(*) filter (where ${documentSuggestions.anchorState} = 'stale' and ${documentSuggestions.status} = 'pending')`.mapWith(Number), + orphanedAnchors: sql`count(*) filter (where ${documentSuggestions.anchorState} = 'orphaned' and ${documentSuggestions.status} = 'pending')`.mapWith(Number), + }) + .from(documentSuggestions) + .where(and( + eq(documentSuggestions.companyId, companyId), + inArray(documentSuggestions.documentId, documentIds), + )) + .groupBy(documentSuggestions.documentId); + + for (const row of suggestionRows) { + const existing = result.get(row.documentId) ?? emptyFeedbackCounts(); + result.set(row.documentId, { + ...existing, + pendingSuggestions: row.pendingSuggestions, + acceptedSuggestions: row.acceptedSuggestions, + rejectedSuggestions: row.rejectedSuggestions, + staleAnchors: existing.staleAnchors + row.staleAnchors, + orphanedAnchors: existing.orphanedAnchors + row.orphanedAnchors, + }); + } return result; } @@ -461,12 +518,22 @@ export function documentService(db: Db) { )`); } if (query.hasOpenFeedback) { - conditions.push(sql`exists ( + conditions.push(sql`(exists ( select 1 from ${documentAnnotationThreads} where ${documentAnnotationThreads.companyId} = ${documents.companyId} and ${documentAnnotationThreads.documentId} = ${documents.id} and ${documentAnnotationThreads.status} = 'open' - )`); + ) or exists ( + select 1 from ${documentReviewThreads} + where ${documentReviewThreads.companyId} = ${documents.companyId} + and ${documentReviewThreads.documentId} = ${documents.id} + and ${documentReviewThreads.status} = 'open' + ) or exists ( + select 1 from ${documentSuggestions} + where ${documentSuggestions.companyId} = ${documents.companyId} + and ${documentSuggestions.documentId} = ${documents.id} + and ${documentSuggestions.status} = 'pending' + ))`); } const rows = await db diff --git a/server/src/services/index.ts b/server/src/services/index.ts index c0ac9f4708b..758532272c4 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -8,6 +8,7 @@ export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } fr export { assetService } from "./assets.js"; export { documentService, extractLegacyPlanBody } from "./documents.js"; export { documentAnnotationService } from "./document-annotations.js"; +export { documentReviewService } from "./document-review.js"; export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, buildContinuationSummaryMarkdown,