mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Implement document review suggestions backend
This commit is contained in:
1 parent
33b6fab3b3
commit
2277a98fe6
22 files changed
+2693
-2
No files matched your search
@@ -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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<IssueCommentAuthorType>().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")),
|
||||
}),
|
||||
);
|
||||
@@ -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<DocumentReviewThreadStatus>().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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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<DocumentAnnotationAnchorSnapshot>().notNull(),
|
||||
nextAnchor: jsonb("next_anchor").$type<DocumentAnnotationAnchorSnapshot | null>(),
|
||||
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull(),
|
||||
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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<IssueCommentAuthorType>().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")),
|
||||
}),
|
||||
);
|
||||
@@ -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<DocumentSuggestionKind>().notNull(),
|
||||
status: text("status").$type<DocumentSuggestionStatus>().notNull().default("pending"),
|
||||
anchorState: text("anchor_state").$type<DocumentAnnotationAnchorState>().notNull().default("active"),
|
||||
anchorConfidence: text("anchor_confidence").$type<DocumentAnnotationAnchorConfidence>().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<DocumentSuggestionInsertPosition>(),
|
||||
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<DocumentAnnotationAnchorSelector>().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),
|
||||
}),
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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~~}");
|
||||
});
|
||||
});
|
||||
@@ -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<CriticMarkupSuggestion, "kind" | "selectedText" | "proposedText">,
|
||||
) {
|
||||
if (suggestion.kind === "insertion") return `{++${suggestion.proposedText ?? ""}++}`;
|
||||
if (suggestion.kind === "deletion") return `{--${suggestion.selectedText}--}`;
|
||||
return `{~~${suggestion.selectedText}~>${suggestion.proposedText ?? ""}~~}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof createDocumentReviewThreadSchema>;
|
||||
export type CreateDocumentReviewComment = z.infer<typeof createDocumentReviewCommentSchema>;
|
||||
export type UpdateDocumentReviewThread = z.infer<typeof updateDocumentReviewThreadSchema>;
|
||||
export type CreateDocumentSuggestion = z.infer<typeof createDocumentSuggestionSchema>;
|
||||
export type CreateDocumentSuggestionComment = z.infer<typeof createDocumentSuggestionCommentSchema>;
|
||||
export type AcceptDocumentSuggestion = z.infer<typeof acceptDocumentSuggestionSchema>;
|
||||
export type RejectDocumentSuggestion = z.infer<typeof rejectDocumentSuggestionSchema>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof createDb>;
|
||||
let docs!: ReturnType<typeof documentService>;
|
||||
let review!: ReturnType<typeof documentReviewService>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DocumentSuggestion, "selectedText" | "prefixText" | "suffixText" | "normalizedStart" | "normalizedEnd" | "markdownStart" | "markdownEnd">): 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<IssueDocumentRow | null> {
|
||||
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<DocumentReviewComment[]> {
|
||||
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<DocumentSuggestionComment[]> {
|
||||
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<DocumentAnnotationComment[]> {
|
||||
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<DocumentReviewIndex> => {
|
||||
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<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'open')`.mapWith(Number),
|
||||
resolvedAnchoredThreads: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'resolved')`.mapWith(Number),
|
||||
staleAnchors: sql<number>`count(*) filter (where ${documentAnnotationThreads.status} = 'open' and ${documentAnnotationThreads.anchorState} = 'stale')`.mapWith(Number),
|
||||
orphanedAnchors: sql<number>`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<number>`count(*) filter (where ${documentReviewThreads.status} = 'open')`.mapWith(Number),
|
||||
resolvedReviewThreads: sql<number>`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<number>`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number),
|
||||
acceptedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number),
|
||||
rejectedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number),
|
||||
staleAnchors: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'pending' and ${documentSuggestions.anchorState} = 'stale')`.mapWith(Number),
|
||||
orphanedAnchors: sql<number>`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<string, DocumentAnnotationComment[]>();
|
||||
for (const comment of annotationComments) {
|
||||
annotationCommentsByThread.set(comment.threadId, [...(annotationCommentsByThread.get(comment.threadId) ?? []), comment]);
|
||||
}
|
||||
const reviewCommentsByThread = new Map<string, DocumentReviewComment[]>();
|
||||
for (const comment of reviewComments) {
|
||||
reviewCommentsByThread.set(comment.threadId, [...(reviewCommentsByThread.get(comment.threadId) ?? []), comment]);
|
||||
}
|
||||
const commentsBySuggestion = new Map<string, DocumentSuggestionComment[]>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<number>`count(*) filter (where ${documentReviewThreads.status} = 'open')`.mapWith(Number),
|
||||
resolvedReviewThreads: sql<number>`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<number>`count(*) filter (where ${documentSuggestions.status} = 'pending')`.mapWith(Number),
|
||||
acceptedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'accepted')`.mapWith(Number),
|
||||
rejectedSuggestions: sql<number>`count(*) filter (where ${documentSuggestions.status} = 'rejected')`.mapWith(Number),
|
||||
staleAnchors: sql<number>`count(*) filter (where ${documentSuggestions.anchorState} = 'stale' and ${documentSuggestions.status} = 'pending')`.mapWith(Number),
|
||||
orphanedAnchors: sql<number>`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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user