From d21117af8899ad7b304ea3aad778b9bd8e405ad2 Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Tue, 28 Jul 2026 22:34:46 +1000 Subject: [PATCH] feat: detection, control and process improvement register linked to incidents Add an organisation-wide improvement register (issue #66) distinct from lightweight post-incident review proposals (#64). Records cover detection, logging, integration, playbook, control, process, training, and documentation gaps with ownership, due dates, multi-incident links, recurrence, validation on close, reopen history, and bounded external ticket refs. - Migration 0039: improvement_register_items/links/events + enums - Core: create (case/review/manual), promote from #64 proposal, link/unlink, close with validation, reopen preserving prior closure events, ticket sync that cannot replace ownership/links/audit, similarity suggestions, dashboard - API under /api/v1/improvement-register with improvements:read|write scopes - Case-linked authorizeCase; sensitive evidence redacted without view_sensitive - Tests: suggestions, linking, recurrence, validation, sync conflicts, auth, tenant isolation Closes #66 --- docs/api.md | 53 + drizzle/0040_improvement_register.sql | 91 + drizzle/meta/_journal.json | 7 + package.json | 3 +- scripts/test-improvement-register.ts | 655 +++++++ .../improvement-register/[id]/close/route.ts | 56 + .../improvement-register/[id]/events/route.ts | 44 + .../[id]/links/[linkId]/route.ts | 36 + .../improvement-register/[id]/links/route.ts | 56 + .../improvement-register/[id]/reopen/route.ts | 55 + .../api/v1/improvement-register/[id]/route.ts | 107 ++ .../[id]/ticket-sync/route.ts | 63 + .../improvement-register/dashboard/route.ts | 30 + .../from-proposal/route.ts | 65 + src/app/api/v1/improvement-register/route.ts | 132 ++ .../improvement-register/suggestions/route.ts | 72 + src/db/schema.ts | 257 +++ src/lib/improvement-register/core.ts | 1611 +++++++++++++++++ src/lib/improvement-register/index.ts | 3 + src/lib/improvement-register/similarity.ts | 149 ++ src/lib/improvement-register/types.ts | 162 ++ src/lib/scopes.ts | 10 + 22 files changed, 3716 insertions(+), 1 deletion(-) create mode 100644 drizzle/0040_improvement_register.sql create mode 100644 scripts/test-improvement-register.ts create mode 100644 src/app/api/v1/improvement-register/[id]/close/route.ts create mode 100644 src/app/api/v1/improvement-register/[id]/events/route.ts create mode 100644 src/app/api/v1/improvement-register/[id]/links/[linkId]/route.ts create mode 100644 src/app/api/v1/improvement-register/[id]/links/route.ts create mode 100644 src/app/api/v1/improvement-register/[id]/reopen/route.ts create mode 100644 src/app/api/v1/improvement-register/[id]/route.ts create mode 100644 src/app/api/v1/improvement-register/[id]/ticket-sync/route.ts create mode 100644 src/app/api/v1/improvement-register/dashboard/route.ts create mode 100644 src/app/api/v1/improvement-register/from-proposal/route.ts create mode 100644 src/app/api/v1/improvement-register/route.ts create mode 100644 src/app/api/v1/improvement-register/suggestions/route.ts create mode 100644 src/lib/improvement-register/core.ts create mode 100644 src/lib/improvement-register/index.ts create mode 100644 src/lib/improvement-register/similarity.ts create mode 100644 src/lib/improvement-register/types.ts diff --git a/docs/api.md b/docs/api.md index 3d1deab..b00dba5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1253,3 +1253,56 @@ Saves the result as case evidence, preserving command name/version, redacted par ``` Link result to org-scoped entities/alerts. Scope: `investigation:execute`. +## Improvement register + +Durable detection, control, and process improvements linked to cases and post-incident reviews (issue #66). Distinct from lightweight review improvement proposals under `/api/v1/reviews/{id}/improvements` — promote those into the register with `POST /api/v1/improvement-register/from-proposal`. + +Types: `detection_gap`, `logging_gap`, `integration_defect`, `playbook_defect`, `security_control_gap`, `process_failure`, `training_need`, `documentation_gap`. + +Statuses: `open` → `in_review` → `accepted` → `in_progress` → `validated` → `closed`, plus `reopened`, `rejected`, `deferred`. Closing requires a validation method + evidence; reopen preserves prior closure history in events. + +### `GET|POST /api/v1/improvement-register` +List (filters: `status`, `type`, `ownerId`, `caseId`, `overdueOnly`, `limit`) or create. Create may set immutable `caseId` / `reviewId` source links. Scope: `improvements:read` / `improvements:write`. When case-linked, case compartment access is required for create/edit. + +### `GET|PATCH /api/v1/improvement-register/{id}` +Read or update fields (title, description, evidence, severity, residual risk, status, owner, due date, playbook). Closing and reopening use dedicated endpoints. Sensitive evidence is redacted unless the actor has `view_sensitive` on a linked case. + +### `POST /api/v1/improvement-register/{id}/links` / `DELETE .../links/{linkId}` +Link additional cases, reviews, proposals, or playbooks. Multiple cases raise `recurrenceCount` (distinct case links). Immutable source links (`isSource: true`) cannot be deleted. + +### `POST /api/v1/improvement-register/{id}/close` +```json +{ "validationMethod": "retest", "validationEvidence": "Detection rule shipped; retest case CASE-123 clean." } +``` +Validation methods: `retest`, `monitoring`, `peer_review`, `document_review`, `exercise`, `other`. Records actor + timestamp. + +### `POST /api/v1/improvement-register/{id}/reopen` +```json +{ "reason": "Recurred in CASE-456" } +``` +Clears current validation fields but retains prior closure/validation in the event log (`priorClosure` payload). + +### `POST /api/v1/improvement-register/{id}/ticket-sync` +Bounded external ticket reference only: +```json +{ "externalTicketRef": "ENG-42", "externalTicketUrl": "https://…", "syncState": "synced" } +``` +or `{ "conflict": true, "error": "…" }`. Never replaces Kelpie owner, links, status, recurrence, or audit history. + +### `GET /api/v1/improvement-register/{id}/events` +Append-only lifecycle history (created, linked, validated, closed, reopened, ticket_synced, ticket_conflict, …). + +### `POST /api/v1/improvement-register/suggestions` +```json +{ "type": "detection_gap", "title": "Missing EDR telemetry on Linux fleet", "description": "…" } +``` +Returns ranked candidates with `matchedFields` explanations. Response always includes `"autoMerge": false` — suggestions never merge records. + +### `GET /api/v1/improvement-register/dashboard` +Recurring categories (`byType`, `highRecurrence`), severity, owners, overdue actions, validation-pending work, and totals. + +### `POST /api/v1/improvement-register/from-proposal` +```json +{ "proposalId": "pir_imp_…", "severity": "high", "dueAt": "2026-08-01T00:00:00.000Z" } +``` +Promotes a #64 review improvement proposal into the register with immutable source links. Idempotent on `proposalId`. Marks the proposal `accepted` when still proposed/deferred. diff --git a/drizzle/0040_improvement_register.sql b/drizzle/0040_improvement_register.sql new file mode 100644 index 0000000..9e6f9d2 --- /dev/null +++ b/drizzle/0040_improvement_register.sql @@ -0,0 +1,91 @@ +CREATE TYPE "public"."improvement_register_type" AS ENUM('detection_gap', 'logging_gap', 'integration_defect', 'playbook_defect', 'security_control_gap', 'process_failure', 'training_need', 'documentation_gap');--> statement-breakpoint +CREATE TYPE "public"."improvement_register_status" AS ENUM('open', 'in_review', 'accepted', 'in_progress', 'validated', 'closed', 'reopened', 'rejected', 'deferred');--> statement-breakpoint +CREATE TYPE "public"."improvement_register_severity" AS ENUM('low', 'medium', 'high', 'critical');--> statement-breakpoint +CREATE TYPE "public"."improvement_link_kind" AS ENUM('case', 'review', 'review_proposal', 'playbook');--> statement-breakpoint +CREATE TYPE "public"."improvement_source_kind" AS ENUM('case', 'review', 'review_proposal', 'manual');--> statement-breakpoint +CREATE TYPE "public"."improvement_validation_method" AS ENUM('retest', 'monitoring', 'peer_review', 'document_review', 'exercise', 'other');--> statement-breakpoint +CREATE TYPE "public"."improvement_ticket_sync_state" AS ENUM('none', 'linked', 'pending', 'synced', 'conflict', 'failed');--> statement-breakpoint +CREATE TYPE "public"."improvement_register_event_type" AS ENUM('created', 'updated', 'status_changed', 'linked', 'unlinked', 'assigned', 'validated', 'closed', 'reopened', 'ticket_synced', 'ticket_conflict');--> statement-breakpoint +CREATE TABLE "improvement_register_items" ( + "id" text PRIMARY KEY NOT NULL, + "organisation_id" text NOT NULL, + "type" "improvement_register_type" NOT NULL, + "title" text NOT NULL, + "description" text, + "evidence" jsonb DEFAULT '{}'::jsonb NOT NULL, + "sensitive_evidence" jsonb, + "severity" "improvement_register_severity" DEFAULT 'medium' NOT NULL, + "residual_risk" text, + "status" "improvement_register_status" DEFAULT 'open' NOT NULL, + "owner_id" text, + "due_at" timestamp with time zone, + "recurrence_count" integer DEFAULT 0 NOT NULL, + "linked_playbook_id" text, + "external_ticket_ref" text, + "external_ticket_url" text, + "external_ticket_sync_state" "improvement_ticket_sync_state" DEFAULT 'none' NOT NULL, + "external_ticket_synced_at" timestamp with time zone, + "external_ticket_sync_error" text, + "validation_method" "improvement_validation_method", + "validation_evidence" text, + "validated_by" text, + "validated_at" timestamp with time zone, + "closed_by" text, + "closed_at" timestamp with time zone, + "source_kind" "improvement_source_kind" DEFAULT 'manual' NOT NULL, + "source_case_id" text, + "source_review_id" text, + "source_proposal_id" text, + "created_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "improvement_register_links" ( + "id" text PRIMARY KEY NOT NULL, + "organisation_id" text NOT NULL, + "improvement_id" text NOT NULL, + "link_kind" "improvement_link_kind" NOT NULL, + "target_id" text NOT NULL, + "is_source" boolean DEFAULT false NOT NULL, + "created_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "improvement_register_events" ( + "id" text PRIMARY KEY NOT NULL, + "organisation_id" text NOT NULL, + "improvement_id" text NOT NULL, + "event_type" "improvement_register_event_type" NOT NULL, + "from_status" "improvement_register_status", + "to_status" "improvement_register_status", + "actor_id" text, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_linked_playbook_id_playbooks_id_fk" FOREIGN KEY ("linked_playbook_id") REFERENCES "public"."playbooks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_validated_by_users_id_fk" FOREIGN KEY ("validated_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_closed_by_users_id_fk" FOREIGN KEY ("closed_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_source_case_id_cases_id_fk" FOREIGN KEY ("source_case_id") REFERENCES "public"."cases"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_source_review_id_case_post_incident_reviews_id_fk" FOREIGN KEY ("source_review_id") REFERENCES "public"."case_post_incident_reviews"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_source_proposal_id_review_improvement_proposals_id_fk" FOREIGN KEY ("source_proposal_id") REFERENCES "public"."review_improvement_proposals"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_items" ADD CONSTRAINT "improvement_register_items_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_links" ADD CONSTRAINT "improvement_register_links_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_links" ADD CONSTRAINT "improvement_register_links_improvement_id_improvement_register_items_id_fk" FOREIGN KEY ("improvement_id") REFERENCES "public"."improvement_register_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_links" ADD CONSTRAINT "improvement_register_links_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_events" ADD CONSTRAINT "improvement_register_events_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_events" ADD CONSTRAINT "improvement_register_events_improvement_id_improvement_register_items_id_fk" FOREIGN KEY ("improvement_id") REFERENCES "public"."improvement_register_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "improvement_register_events" ADD CONSTRAINT "improvement_register_events_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "improvement_register_items_org_status_idx" ON "improvement_register_items" USING btree ("organisation_id","status");--> statement-breakpoint +CREATE INDEX "improvement_register_items_org_type_idx" ON "improvement_register_items" USING btree ("organisation_id","type");--> statement-breakpoint +CREATE INDEX "improvement_register_items_org_owner_due_idx" ON "improvement_register_items" USING btree ("organisation_id","owner_id","due_at");--> statement-breakpoint +CREATE INDEX "improvement_register_items_source_case_idx" ON "improvement_register_items" USING btree ("source_case_id");--> statement-breakpoint +CREATE INDEX "improvement_register_items_source_proposal_idx" ON "improvement_register_items" USING btree ("source_proposal_id");--> statement-breakpoint +CREATE INDEX "improvement_register_links_improvement_idx" ON "improvement_register_links" USING btree ("improvement_id");--> statement-breakpoint +CREATE INDEX "improvement_register_links_org_kind_target_idx" ON "improvement_register_links" USING btree ("organisation_id","link_kind","target_id");--> statement-breakpoint +CREATE UNIQUE INDEX "improvement_register_links_unique_idx" ON "improvement_register_links" USING btree ("improvement_id","link_kind","target_id");--> statement-breakpoint +CREATE INDEX "improvement_register_events_improvement_idx" ON "improvement_register_events" USING btree ("improvement_id","created_at");--> statement-breakpoint +CREATE INDEX "improvement_register_events_org_type_idx" ON "improvement_register_events" USING btree ("organisation_id","event_type"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3c00ece..5e994f7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1785242050949, "tag": "0039_stakeholder_portal", "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1785242267207, + "tag": "0040_improvement_register", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 41dc8ea..eaa62c8 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "test:post-incident-review": "tsx scripts/test-post-incident-review.ts", "test:investigation-graph": "tsx scripts/test-investigation-graph.ts", "test:investigation-console": "tsx scripts/test-investigation-console.ts", - "test:stakeholder-portal": "tsx scripts/test-stakeholder-portal.ts" + "test:stakeholder-portal": "tsx scripts/test-stakeholder-portal.ts", + "test:improvement-register": "tsx scripts/test-improvement-register.ts" }, "dependencies": { "@aws-sdk/client-sesv2": "^3.1095.0", diff --git a/scripts/test-improvement-register.ts b/scripts/test-improvement-register.ts new file mode 100644 index 0000000..8436c30 --- /dev/null +++ b/scripts/test-improvement-register.ts @@ -0,0 +1,655 @@ +/** + * Coverage for the detection/control/process improvement register (issue #66): + * - scopes fail closed + * - similarity suggestions explain fields and never auto-merge + * - create from case / review with immutable source links + * - multi-case linking + recurrence count + * - close requires validation; reopen preserves prior history + * - external ticket sync does not replace ownership/links/audit + * - promote #64 review improvement proposals + * - authorization + tenant isolation + * - sensitive evidence redaction + * + * Uses real Postgres via DATABASE_URL. + */ +import assert from "node:assert/strict"; +import { eq } from "drizzle-orm"; +import { db } from "../src/db"; +import { + cases, + improvementRegisterEvents, + improvementRegisterItems, + organisations, + playbooks, + reviewImprovementProposals, + users, +} from "../src/db/schema"; +import { newId } from "../src/lib/utils"; +import { + SENSITIVE_SCOPES, + legacyDefaultScopes, + tokenHasScope, +} from "../src/lib/scopes"; +import { + PROPOSAL_KIND_TO_REGISTER_TYPE, + ImprovementRegisterError, + closeImprovementCore, + createFromProposalCore, + createImprovementCore, + getImprovementCore, + improvementDashboardCore, + jaccard, + linkImprovementCore, + listImprovementEventsCore, + listImprovementsCore, + rankSimilarImprovements, + reopenImprovementCore, + suggestSimilarImprovementsCore, + syncExternalTicketCore, + tokenise, + unlinkImprovementCore, + updateImprovementCore, +} from "../src/lib/improvement-register"; +import { + createImprovementCore as createReviewProposalCore, + createReviewCore, + seedBaselineReviewTemplates, +} from "../src/lib/post-incident-review"; +import { setCaseVisibility } from "../src/lib/access"; + +const runId = newId("i66").slice("i66_".length).slice(0, 10); +const orgAId = `org_i66a_${runId}`; +const orgBId = `org_i66b_${runId}`; +const userAId = `user_i66a_${runId}`; +const userA2Id = `user_i66a2_${runId}`; +const userBId = `user_i66b_${runId}`; +let caseA1 = ""; +let caseA2 = ""; +let caseRestricted = ""; +let caseB = ""; +let playbookA = ""; + +async function setup() { + await db.insert(organisations).values([ + { id: orgAId, name: "ImpReg Org A", slug: `i66a-${runId}` }, + { id: orgBId, name: "ImpReg Org B", slug: `i66b-${runId}` }, + ]); + await db.insert(users).values([ + { + id: userAId, + name: "Analyst A", + email: `i66a-${runId}@example.com`, + organisationId: orgAId, + role: "admin", + }, + { + id: userA2Id, + name: "Analyst A2 non-member", + email: `i66a2-${runId}@example.com`, + organisationId: orgAId, + role: "analyst", + }, + { + id: userBId, + name: "Analyst B", + email: `i66b-${runId}@example.com`, + organisationId: orgBId, + role: "admin", + }, + ]); + caseA1 = newId("case"); + caseA2 = newId("case"); + caseRestricted = newId("case"); + caseB = newId("case"); + await db.insert(cases).values([ + { + id: caseA1, + organisationId: orgAId, + caseNumber: `I66A1-${runId}`, + title: "Missing EDR on Linux fleet", + severity: "high", + }, + { + id: caseA2, + organisationId: orgAId, + caseNumber: `I66A2-${runId}`, + title: "Linux EDR gap recurrence", + severity: "critical", + }, + { + id: caseRestricted, + organisationId: orgAId, + caseNumber: `I66R-${runId}`, + title: "Restricted insider", + severity: "high", + }, + { + id: caseB, + organisationId: orgBId, + caseNumber: `I66B-${runId}`, + title: "Other org case", + severity: "high", + }, + ]); + playbookA = newId("pb"); + await db.insert(playbooks).values({ + id: playbookA, + organisationId: orgAId, + name: "Ransomware baseline", + classification: "malware", + }); + await seedBaselineReviewTemplates(orgAId, userAId); +} + +async function cleanup() { + await db.delete(organisations).where(eq(organisations.id, orgAId)); + await db.delete(organisations).where(eq(organisations.id, orgBId)); +} + +// ── pure: scopes ─────────────────────────────────────────────────────── + +{ + assert.equal( + tokenHasScope([], "improvements:read"), + false, + "empty scopes fail closed", + ); + assert.equal( + tokenHasScope(["improvements:read"], "improvements:write"), + false, + ); + assert.equal( + tokenHasScope(["improvements:read"], "improvements:read"), + true, + ); + assert.ok( + legacyDefaultScopes().includes("improvements:read"), + "legacy defaults include improvements:read", + ); + assert.ok( + legacyDefaultScopes().includes("improvements:write"), + "legacy defaults include improvements:write", + ); + assert.ok( + !(SENSITIVE_SCOPES as readonly string[]).includes("improvements:write"), + "improvements:write is not a sensitive scope", + ); + console.log("ok: scopes fail closed + improvements scopes present"); +} + +// ── pure: proposal kind mapping ──────────────────────────────────────── + +{ + assert.equal( + PROPOSAL_KIND_TO_REGISTER_TYPE.detection_improvement, + "detection_gap", + ); + assert.equal( + PROPOSAL_KIND_TO_REGISTER_TYPE.playbook_revision, + "playbook_defect", + ); + assert.equal( + PROPOSAL_KIND_TO_REGISTER_TYPE.control_gap, + "security_control_gap", + ); + console.log("ok: proposal kind → register type mapping"); +} + +// ── pure: similarity ─────────────────────────────────────────────────── + +{ + const a = tokenise("Missing EDR telemetry on Linux servers"); + const b = tokenise("Linux EDR telemetry gap for servers"); + assert.ok(jaccard(a, b) > 0.3, "related titles share tokens"); + + const ranked = rankSimilarImprovements( + { + type: "detection_gap", + title: "Missing EDR telemetry on Linux fleet", + description: "No sysmon or falcon sensors on production Linux hosts", + }, + [ + { + id: "imp_1", + type: "detection_gap", + title: "EDR telemetry missing on Linux hosts", + description: "Production Linux fleet lacks falcon sensors", + status: "open", + severity: "high", + recurrenceCount: 2, + }, + { + id: "imp_2", + type: "process_failure", + title: "Password reset SOP outdated", + description: "Helpdesk uses old script", + status: "open", + severity: "low", + recurrenceCount: 0, + }, + ], + ); + assert.ok(ranked.length >= 1); + assert.equal(ranked[0]!.improvement.id, "imp_1"); + assert.ok( + ranked[0]!.matchedFields.some((f) => f.field === "type"), + "type match explained", + ); + assert.ok( + ranked[0]!.matchedFields.some((f) => f.field === "title"), + "title match explained", + ); + assert.ok( + !ranked.some((r) => r.improvement.id === "imp_2" && r.score > 0.5), + "unrelated process item not top-ranked", + ); + console.log("ok: similarity explains matched fields, no auto-merge"); +} + +async function main() { + await setup(); + try { + // ── create from case with immutable source ───────────────────────── + const created = await createImprovementCore(orgAId, userAId, { + type: "detection_gap", + title: "Missing EDR telemetry on Linux fleet", + description: "No sensors on production Linux hosts", + evidence: { hostsSampled: 12, sensor: "none" }, + sensitiveEvidence: { assetTags: ["finance-db-01"] }, + severity: "high", + residualRisk: "Undetected lateral movement on Linux", + ownerId: userAId, + dueAt: new Date(Date.now() - 86_400_000).toISOString(), // overdue + linkedPlaybookId: playbookA, + caseId: caseA1, + }); + assert.equal(created.status, "open"); + assert.equal(created.sourceKind, "case"); + assert.equal(created.sourceCaseId, caseA1); + assert.equal(created.recurrenceCount, 1); + assert.ok(created.links.some((l) => l.linkKind === "case" && l.isSource)); + assert.ok( + created.links.some((l) => l.linkKind === "playbook" && l.targetId === playbookA), + ); + assert.equal(created.ownerId, userAId); + console.log("ok: create from case with immutable source links"); + + // Source fields immutable via update path (not exposed in patch — verify stored) + const reloaded = await getImprovementCore(orgAId, created.id, userAId); + assert.ok(reloaded); + assert.equal(reloaded!.sourceCaseId, caseA1); + + // ── suggestions ──────────────────────────────────────────────────── + const suggestions = await suggestSimilarImprovementsCore( + orgAId, + userAId, + { + type: "detection_gap", + title: "EDR telemetry gap Linux production", + description: "hosts lack falcon", + }, + ); + assert.ok(suggestions.some((s) => s.improvement.id === created.id)); + assert.ok( + suggestions[0]!.matchedFields.length > 0, + "suggestions explain matches", + ); + console.log("ok: suggestions return existing similar item"); + + // ── link second case → recurrence ────────────────────────────────── + const linked = await linkImprovementCore(orgAId, created.id, userAId, { + linkKind: "case", + targetId: caseA2, + }); + assert.equal(linked.recurrenceCount, 2, "recurrence from distinct cases"); + assert.ok( + linked.links.filter((l) => l.linkKind === "case").length === 2, + ); + console.log("ok: multi-case link raises recurrence count"); + + // Cannot unlink source link + const sourceLink = linked.links.find((l) => l.isSource)!; + await assert.rejects( + () => unlinkImprovementCore(orgAId, created.id, userAId, sourceLink.id), + (err: unknown) => + err instanceof ImprovementRegisterError && + err.message.includes("Immutable source"), + ); + console.log("ok: immutable source link cannot be removed"); + + // Unlink non-source case drops recurrence + const extraLink = linked.links.find( + (l) => l.linkKind === "case" && !l.isSource, + )!; + const unlinked = await unlinkImprovementCore( + orgAId, + created.id, + userAId, + extraLink.id, + ); + assert.equal(unlinked.recurrenceCount, 1); + // re-link for rest of tests + await linkImprovementCore(orgAId, created.id, userAId, { + linkKind: "case", + targetId: caseA2, + }); + console.log("ok: unlink non-source adjusts recurrence"); + + // ── close requires validation ────────────────────────────────────── + await assert.rejects( + () => + closeImprovementCore(orgAId, created.id, userAId, { + validationMethod: "retest", + validationEvidence: " ", + }), + (err: unknown) => err instanceof ImprovementRegisterError, + ); + await assert.rejects( + () => + updateImprovementCore(orgAId, created.id, userAId, { + status: "closed", + }), + (err: unknown) => + err instanceof ImprovementRegisterError && + err.message.includes("close endpoint"), + ); + + const closed = await closeImprovementCore(orgAId, created.id, userAId, { + validationMethod: "retest", + validationEvidence: "EDR rolled out; retest case clean", + }); + assert.equal(closed.status, "closed"); + assert.equal(closed.validationMethod, "retest"); + assert.equal(closed.validatedBy, userAId); + assert.ok(closed.validatedAt); + assert.equal(closed.closedBy, userAId); + assert.ok(closed.closedAt); + console.log("ok: close requires validation method + evidence + actor/ts"); + + // ── reopen preserves prior validation history ────────────────────── + const reopened = await reopenImprovementCore( + orgAId, + created.id, + userAId, + "Recurred on second case", + ); + assert.equal(reopened.status, "reopened"); + assert.equal(reopened.validationMethod, null); + assert.equal(reopened.closedAt, null); + + const events = await listImprovementEventsCore( + orgAId, + created.id, + userAId, + ); + const reopenEvt = events.find((e) => e.eventType === "reopened"); + assert.ok(reopenEvt); + const prior = (reopenEvt!.payload as { priorClosure?: Record }) + .priorClosure; + assert.ok(prior); + assert.equal(prior!.validationMethod, "retest"); + assert.ok( + String(prior!.validationEvidence).includes("EDR rolled out"), + "prior validation evidence retained in history", + ); + assert.ok(events.some((e) => e.eventType === "closed")); + assert.ok(events.some((e) => e.eventType === "validated")); + console.log("ok: reopen preserves prior validation/closure history"); + + // ── external ticket sync bounded ─────────────────────────────────── + const ownerBefore = reopened.ownerId; + const statusBefore = reopened.status; + const recurrenceBefore = reopened.recurrenceCount; + const synced = await syncExternalTicketCore( + orgAId, + created.id, + userAId, + { + externalTicketRef: "ENG-42", + externalTicketUrl: "https://tickets.example/ENG-42", + syncState: "synced", + }, + ); + assert.equal(synced.externalTicketRef, "ENG-42"); + assert.equal(synced.externalTicketSyncState, "synced"); + assert.equal(synced.ownerId, ownerBefore, "sync must not change owner"); + assert.equal(synced.status, statusBefore, "sync must not change status"); + assert.equal( + synced.recurrenceCount, + recurrenceBefore, + "sync must not change recurrence", + ); + assert.equal(synced.sourceCaseId, caseA1, "sync must not change source"); + + const conflicted = await syncExternalTicketCore( + orgAId, + created.id, + userAId, + { conflict: true, error: "Remote ticket closed unexpectedly" }, + ); + assert.equal(conflicted.externalTicketSyncState, "conflict"); + assert.equal(conflicted.ownerId, ownerBefore); + assert.equal(conflicted.externalTicketRef, "ENG-42", "conflict keeps ref"); + const syncEvents = await listImprovementEventsCore( + orgAId, + created.id, + userAId, + ); + assert.ok(syncEvents.some((e) => e.eventType === "ticket_synced")); + assert.ok(syncEvents.some((e) => e.eventType === "ticket_conflict")); + console.log("ok: ticket sync bounded; conflicts recorded without overwrite"); + + // ── dashboard ────────────────────────────────────────────────────── + const dash = await improvementDashboardCore(orgAId, userAId); + assert.ok(dash.byType.some((t) => t.type === "detection_gap")); + assert.ok(dash.totals.openWork >= 1); + assert.ok( + dash.highRecurrence.some((h) => h.id === created.id), + "recurrence ≥2 appears on dashboard", + ); + // due date was yesterday and status is reopened (open work) → overdue + assert.ok( + dash.overdue.some((o) => o.id === created.id), + "overdue open work listed", + ); + console.log("ok: dashboard themes, severity, owners, overdue, validation"); + + // ── from case block without review ───────────────────────────────── + const manual = await createImprovementCore(orgAId, userAId, { + type: "process_failure", + title: "Escalation ladder not followed", + caseId: caseA2, + severity: "medium", + }); + assert.equal(manual.sourceKind, "case"); + assert.equal(manual.sourceCaseId, caseA2); + + // ── promote #64 proposal ─────────────────────────────────────────── + const review = await createReviewCore(orgAId, caseA1, userAId, { + title: "PIR for EDR gap", + }); + const proposal = await createReviewProposalCore( + orgAId, + review.id, + userAId, + { + kind: "detection_improvement", + title: "Add Linux EDR detection rules", + description: "Cover T1021 lateral movement", + linkedPlaybookId: playbookA, + ownerId: userAId, + }, + ); + assert.equal(proposal.status, "proposed"); + + const fromProp = await createFromProposalCore( + orgAId, + userAId, + proposal.id, + { severity: "critical", dueAt: new Date("2026-09-01T00:00:00.000Z").toISOString() }, + ); + assert.equal(fromProp.sourceKind, "review_proposal"); + assert.equal(fromProp.sourceProposalId, proposal.id); + assert.equal(fromProp.sourceReviewId, review.id); + assert.equal(fromProp.sourceCaseId, caseA1); + assert.equal(fromProp.type, "detection_gap"); + assert.equal(fromProp.severity, "critical"); + assert.ok( + fromProp.links.some( + (l) => l.linkKind === "review_proposal" && l.isSource, + ), + ); + assert.ok(fromProp.links.some((l) => l.linkKind === "review" && l.isSource)); + + const [proposalAfter] = await db + .select() + .from(reviewImprovementProposals) + .where(eq(reviewImprovementProposals.id, proposal.id)) + .limit(1); + assert.equal(proposalAfter!.status, "accepted"); + + // Idempotent promote + const again = await createFromProposalCore(orgAId, userAId, proposal.id); + assert.equal(again.id, fromProp.id, "idempotent on proposalId"); + console.log("ok: promote #64 proposal with immutable sources + idempotent"); + + // Create from review directly + const fromReview = await createImprovementCore(orgAId, userAId, { + type: "security_control_gap", + title: "No MFA on privileged VPN", + reviewId: review.id, + severity: "high", + }); + assert.equal(fromReview.sourceKind, "review"); + assert.equal(fromReview.sourceReviewId, review.id); + assert.equal(fromReview.sourceCaseId, caseA1); + console.log("ok: create from post-incident review"); + + // ── tenant isolation ─────────────────────────────────────────────── + const orgAList = await listImprovementsCore(orgAId, userAId); + assert.ok(orgAList.every((i) => i.id.startsWith("imp_"))); + assert.ok(orgAList.some((i) => i.id === created.id)); + + const orgBList = await listImprovementsCore(orgBId, userBId); + assert.equal(orgBList.length, 0, "org B sees no org A improvements"); + + await assert.rejects( + () => getImprovementCore(orgBId, created.id, userBId).then((r) => { + // get returns null when org filter misses + assert.equal(r, null); + throw new ImprovementRegisterError("not found cross-tenant", 404); + }), + (err: unknown) => + err instanceof ImprovementRegisterError && err.status === 404, + ); + + // Direct get with wrong org + const cross = await getImprovementCore(orgBId, created.id, userBId); + assert.equal(cross, null, "cross-tenant get returns null"); + + await assert.rejects( + () => + linkImprovementCore(orgAId, created.id, userAId, { + linkKind: "case", + targetId: caseB, + }), + (err: unknown) => + err instanceof ImprovementRegisterError && err.status === 404, + "cannot link other org case", + ); + + await assert.rejects( + () => + createImprovementCore(orgAId, userAId, { + type: "detection_gap", + title: "Cross owner", + ownerId: userBId, + caseId: caseA1, + }), + (err: unknown) => err instanceof ImprovementRegisterError, + "cross-org owner rejected", + ); + console.log("ok: tenant isolation + cross-org link/owner rejected"); + + // ── list filter by case ──────────────────────────────────────────── + const forCase = await listImprovementsCore(orgAId, userAId, { + caseId: caseA1, + }); + assert.ok(forCase.some((i) => i.id === created.id)); + assert.ok(forCase.every((i) => + i.sourceCaseId === caseA1 || + i.links.some((l) => l.linkKind === "case" && l.targetId === caseA1), + )); + console.log("ok: list filter by caseId"); + + // ── sensitive evidence redaction (restricted source case) ────────── + // Create while case is organisation-visible, then tighten compartment so + // non-member analysts cannot read sensitive source evidence from the + // broadly listed register item. + const withSensitive = await createImprovementCore(orgAId, userAId, { + type: "logging_gap", + title: "SIEM not ingesting auth logs (restricted source)", + caseId: caseRestricted, + sensitiveEvidence: { secretHost: "dc01.internal" }, + }); + await setCaseVisibility( + orgAId, + { + organisationId: orgAId, + userId: userAId, + role: "admin", + teamIds: [], + }, + caseRestricted, + { + visibilityMode: "restricted", + reason: "Improvement register compartment test", + }, + ); + + const asNonMember = await getImprovementCore( + orgAId, + withSensitive.id, + userA2Id, + ); + assert.ok(asNonMember, "register item remains listable org-wide"); + assert.equal( + asNonMember!.sensitiveEvidenceRedacted, + true, + "non-member cannot read sensitive source evidence from restricted case", + ); + assert.ok( + !( + asNonMember!.sensitiveEvidence && + typeof asNonMember!.sensitiveEvidence === "object" && + "secretHost" in (asNonMember!.sensitiveEvidence as object) + ), + "secret host must not leak", + ); + console.log("ok: sensitive evidence redacted without view_sensitive"); + + // Event history non-empty for created item + const allEvents = await db + .select() + .from(improvementRegisterEvents) + .where(eq(improvementRegisterEvents.organisationId, orgAId)); + assert.ok(allEvents.length >= 5); + + const allItems = await db + .select() + .from(improvementRegisterItems) + .where(eq(improvementRegisterItems.organisationId, orgAId)); + assert.ok(allItems.length >= 3); + + console.log("\nAll improvement register tests passed."); + } finally { + await cleanup(); + } +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + cleanup().finally(() => process.exit(1)); + }); diff --git a/src/app/api/v1/improvement-register/[id]/close/route.ts b/src/app/api/v1/improvement-register/[id]/close/route.ts new file mode 100644 index 0000000..9e0a1c3 --- /dev/null +++ b/src/app/api/v1/improvement-register/[id]/close/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + IMPROVEMENT_VALIDATION_METHODS, + ImprovementRegisterError, + closeImprovementCore, + serializeImprovement, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +const closeSchema = z.object({ + validationMethod: z.enum(IMPROVEMENT_VALIDATION_METHODS), + validationEvidence: z.string().trim().min(1).max(10_000), +}); + +export async function POST(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id } = await context.params; + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = closeSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + try { + const improvement = await closeImprovementCore( + auth.token.organisationId, + id, + auth.token.createdBy, + parsed.data, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/[id]/events/route.ts b/src/app/api/v1/improvement-register/[id]/events/route.ts new file mode 100644 index 0000000..8b4c008 --- /dev/null +++ b/src/app/api/v1/improvement-register/[id]/events/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + ImprovementRegisterError, + listImprovementEventsCore, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:read"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id } = await context.params; + try { + const events = await listImprovementEventsCore( + auth.token.organisationId, + id, + auth.token.createdBy, + ); + return NextResponse.json( + { + events: events.map((e) => ({ + id: e.id, + eventType: e.eventType, + fromStatus: e.fromStatus, + toStatus: e.toStatus, + actorId: e.actorId, + payload: e.payload, + createdAt: e.createdAt.toISOString(), + })), + }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/[id]/links/[linkId]/route.ts b/src/app/api/v1/improvement-register/[id]/links/[linkId]/route.ts new file mode 100644 index 0000000..4fe86fd --- /dev/null +++ b/src/app/api/v1/improvement-register/[id]/links/[linkId]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + ImprovementRegisterError, + serializeImprovement, + unlinkImprovementCore, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string; linkId: string }> }; + +export async function DELETE(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id, linkId } = await context.params; + try { + const improvement = await unlinkImprovementCore( + auth.token.organisationId, + id, + auth.token.createdBy, + linkId, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/[id]/links/route.ts b/src/app/api/v1/improvement-register/[id]/links/route.ts new file mode 100644 index 0000000..1249fd5 --- /dev/null +++ b/src/app/api/v1/improvement-register/[id]/links/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + IMPROVEMENT_LINK_KINDS, + ImprovementRegisterError, + linkImprovementCore, + serializeImprovement, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +const linkSchema = z.object({ + linkKind: z.enum(IMPROVEMENT_LINK_KINDS), + targetId: z.string().trim().min(1).max(100), +}); + +export async function POST(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id } = await context.params; + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = linkSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + try { + const improvement = await linkImprovementCore( + auth.token.organisationId, + id, + auth.token.createdBy, + parsed.data, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/[id]/reopen/route.ts b/src/app/api/v1/improvement-register/[id]/reopen/route.ts new file mode 100644 index 0000000..3e5126d --- /dev/null +++ b/src/app/api/v1/improvement-register/[id]/reopen/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + ImprovementRegisterError, + reopenImprovementCore, + serializeImprovement, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +const reopenSchema = z.object({ + reason: z.string().trim().max(5_000).nullable().optional(), +}); + +export async function POST(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id } = await context.params; + let body: unknown = {}; + try { + const text = await req.text(); + if (text.trim()) body = JSON.parse(text); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = reopenSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + try { + const improvement = await reopenImprovementCore( + auth.token.organisationId, + id, + auth.token.createdBy, + parsed.data.reason, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/[id]/route.ts b/src/app/api/v1/improvement-register/[id]/route.ts new file mode 100644 index 0000000..5db8b67 --- /dev/null +++ b/src/app/api/v1/improvement-register/[id]/route.ts @@ -0,0 +1,107 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + IMPROVEMENT_REGISTER_SEVERITIES, + IMPROVEMENT_REGISTER_STATUSES, + ImprovementRegisterError, + getImprovementCore, + serializeImprovement, + updateImprovementCore, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +const patchSchema = z.object({ + title: z.string().trim().min(1).max(500).optional(), + description: z.string().trim().max(20_000).nullable().optional(), + evidence: z.record(z.string(), z.unknown()).nullable().optional(), + sensitiveEvidence: z.record(z.string(), z.unknown()).nullable().optional(), + severity: z.enum(IMPROVEMENT_REGISTER_SEVERITIES).optional(), + residualRisk: z.string().trim().max(5_000).nullable().optional(), + status: z + .enum( + IMPROVEMENT_REGISTER_STATUSES.filter( + (s) => s !== "closed" && s !== "reopened", + ) as [ + "open", + "in_review", + "accepted", + "in_progress", + "validated", + "rejected", + "deferred", + ], + ) + .optional(), + ownerId: z.string().trim().min(1).max(100).nullable().optional(), + dueAt: z.string().datetime().nullable().optional(), + linkedPlaybookId: z.string().trim().min(1).max(100).nullable().optional(), +}); + +export async function GET(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:read"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id } = await context.params; + try { + const improvement = await getImprovementCore( + auth.token.organisationId, + id, + auth.token.createdBy, + ); + if (!improvement) { + return NextResponse.json({ error: "Improvement not found" }, { status: 404 }); + } + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} + +export async function PATCH(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id } = await context.params; + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = patchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + try { + const improvement = await updateImprovementCore( + auth.token.organisationId, + id, + auth.token.createdBy, + parsed.data, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/[id]/ticket-sync/route.ts b/src/app/api/v1/improvement-register/[id]/ticket-sync/route.ts new file mode 100644 index 0000000..e1b75ae --- /dev/null +++ b/src/app/api/v1/improvement-register/[id]/ticket-sync/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + IMPROVEMENT_TICKET_SYNC_STATES, + ImprovementRegisterError, + serializeImprovement, + syncExternalTicketCore, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** + * Bounded external ticket sync. Only ticket reference fields and sync state + * may change — ownership, links, status, recurrence, and history are preserved. + */ +const syncSchema = z.object({ + externalTicketRef: z.string().trim().max(200).nullable().optional(), + externalTicketUrl: z.string().trim().max(2048).nullable().optional(), + syncState: z.enum(IMPROVEMENT_TICKET_SYNC_STATES).optional(), + conflict: z.boolean().optional(), + error: z.string().trim().max(2_000).nullable().optional(), +}); + +export async function POST(req: Request, context: Params) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const { id } = await context.params; + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = syncSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + try { + const improvement = await syncExternalTicketCore( + auth.token.organisationId, + id, + auth.token.createdBy, + parsed.data, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/dashboard/route.ts b/src/app/api/v1/improvement-register/dashboard/route.ts new file mode 100644 index 0000000..be3ae12 --- /dev/null +++ b/src/app/api/v1/improvement-register/dashboard/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + ImprovementRegisterError, + improvementDashboardCore, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + const auth = await authenticateApiTokenWithScope(req, "improvements:read"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + try { + const dashboard = await improvementDashboardCore( + auth.token.organisationId, + auth.token.createdBy, + ); + return NextResponse.json( + { dashboard }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/from-proposal/route.ts b/src/app/api/v1/improvement-register/from-proposal/route.ts new file mode 100644 index 0000000..05f9834 --- /dev/null +++ b/src/app/api/v1/improvement-register/from-proposal/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + IMPROVEMENT_REGISTER_SEVERITIES, + IMPROVEMENT_REGISTER_TYPES, + ImprovementRegisterError, + createFromProposalCore, + serializeImprovement, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + proposalId: z.string().trim().min(1).max(100), + type: z.enum(IMPROVEMENT_REGISTER_TYPES).optional(), + title: z.string().trim().min(1).max(500).optional(), + description: z.string().trim().max(20_000).nullable().optional(), + severity: z.enum(IMPROVEMENT_REGISTER_SEVERITIES).optional(), + ownerId: z.string().trim().min(1).max(100).nullable().optional(), + dueAt: z.string().datetime().nullable().optional(), +}); + +/** + * Promote a post-incident review improvement proposal (#64) into the durable + * register. Immutable source links are stored; the proposal is accepted but + * not deleted. Idempotent on proposalId. + */ +export async function POST(req: Request) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + const { proposalId, ...overrides } = parsed.data; + try { + const improvement = await createFromProposalCore( + auth.token.organisationId, + auth.token.createdBy, + proposalId, + overrides, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { status: 201, headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/route.ts b/src/app/api/v1/improvement-register/route.ts new file mode 100644 index 0000000..b45de89 --- /dev/null +++ b/src/app/api/v1/improvement-register/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + IMPROVEMENT_REGISTER_SEVERITIES, + IMPROVEMENT_REGISTER_STATUSES, + IMPROVEMENT_REGISTER_TYPES, + ImprovementRegisterError, + createImprovementCore, + listImprovementsCore, + serializeImprovement, + type ImprovementRegisterStatus, + type ImprovementRegisterType, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +const createSchema = z.object({ + type: z.enum(IMPROVEMENT_REGISTER_TYPES), + title: z.string().trim().min(1).max(500), + description: z.string().trim().max(20_000).nullable().optional(), + evidence: z.record(z.string(), z.unknown()).nullable().optional(), + sensitiveEvidence: z.record(z.string(), z.unknown()).nullable().optional(), + severity: z.enum(IMPROVEMENT_REGISTER_SEVERITIES).optional(), + residualRisk: z.string().trim().max(5_000).nullable().optional(), + ownerId: z.string().trim().min(1).max(100).nullable().optional(), + dueAt: z.string().datetime().nullable().optional(), + linkedPlaybookId: z.string().trim().min(1).max(100).nullable().optional(), + externalTicketRef: z.string().trim().max(200).nullable().optional(), + externalTicketUrl: z.string().trim().max(2048).nullable().optional(), + caseId: z.string().trim().min(1).max(100).nullable().optional(), + reviewId: z.string().trim().min(1).max(100).nullable().optional(), +}); + +export async function GET(req: Request) { + const auth = await authenticateApiTokenWithScope(req, "improvements:read"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + const url = new URL(req.url); + const statusParam = url.searchParams.get("status"); + const typeParam = url.searchParams.get("type"); + const ownerId = url.searchParams.get("ownerId"); + const caseId = url.searchParams.get("caseId"); + const overdueOnly = url.searchParams.get("overdueOnly") === "true"; + const limitRaw = url.searchParams.get("limit"); + const limit = limitRaw ? Number(limitRaw) : undefined; + + let status: ImprovementRegisterStatus | ImprovementRegisterStatus[] | undefined; + if (statusParam) { + const parts = statusParam.split(",").map((s) => s.trim()); + const valid = parts.filter((p) => + (IMPROVEMENT_REGISTER_STATUSES as readonly string[]).includes(p), + ) as ImprovementRegisterStatus[]; + if (valid.length === 0) { + return NextResponse.json({ error: "Invalid status filter" }, { status: 400 }); + } + status = valid.length === 1 ? valid[0] : valid; + } + + let type: ImprovementRegisterType | ImprovementRegisterType[] | undefined; + if (typeParam) { + const parts = typeParam.split(",").map((s) => s.trim()); + const valid = parts.filter((p) => + (IMPROVEMENT_REGISTER_TYPES as readonly string[]).includes(p), + ) as ImprovementRegisterType[]; + if (valid.length === 0) { + return NextResponse.json({ error: "Invalid type filter" }, { status: 400 }); + } + type = valid.length === 1 ? valid[0] : valid; + } + + try { + const improvements = await listImprovementsCore( + auth.token.organisationId, + auth.token.createdBy, + { + status, + type, + ownerId: ownerId || undefined, + caseId: caseId || undefined, + overdueOnly, + limit: Number.isFinite(limit) ? limit : undefined, + }, + ); + return NextResponse.json( + { improvements: improvements.map(serializeImprovement) }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} + +export async function POST(req: Request) { + const auth = await authenticateApiTokenWithScope(req, "improvements:write"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = createSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + try { + const improvement = await createImprovementCore( + auth.token.organisationId, + auth.token.createdBy, + parsed.data, + ); + return NextResponse.json( + { improvement: serializeImprovement(improvement) }, + { status: 201, headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/app/api/v1/improvement-register/suggestions/route.ts b/src/app/api/v1/improvement-register/suggestions/route.ts new file mode 100644 index 0000000..637b3fe --- /dev/null +++ b/src/app/api/v1/improvement-register/suggestions/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { authenticateApiTokenWithScope } from "@/lib/api-tokens"; +import { + IMPROVEMENT_REGISTER_TYPES, + ImprovementRegisterError, + suggestSimilarImprovementsCore, +} from "@/lib/improvement-register"; + +export const dynamic = "force-dynamic"; + +const bodySchema = z.object({ + type: z.enum(IMPROVEMENT_REGISTER_TYPES).optional(), + title: z.string().trim().min(1).max(500), + description: z.string().trim().max(20_000).nullable().optional(), + limit: z.number().int().min(1).max(20).optional(), +}); + +/** + * Similarity suggestions before create. Explains matching fields; never + * auto-merges. Accepts POST body so long titles/descriptions fit. + */ +export async function POST(req: Request) { + const auth = await authenticateApiTokenWithScope(req, "improvements:read"); + if (!auth.ok) { + return NextResponse.json({ error: auth.reason }, { status: auth.status }); + } + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join("; ") }, + { status: 400 }, + ); + } + try { + const suggestions = await suggestSimilarImprovementsCore( + auth.token.organisationId, + auth.token.createdBy, + parsed.data, + ); + return NextResponse.json( + { + suggestions: suggestions.map((s) => ({ + score: Math.round(s.score * 1000) / 1000, + matchedFields: s.matchedFields, + improvement: { + id: s.improvement.id, + type: s.improvement.type, + title: s.improvement.title, + description: s.improvement.description, + status: s.improvement.status, + severity: s.improvement.severity, + recurrenceCount: s.improvement.recurrenceCount, + }, + })), + autoMerge: false, + }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (err) { + if (err instanceof ImprovementRegisterError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + throw err; + } +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 27a7263..0a8b34d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -6251,6 +6251,263 @@ export type IntegrationSyncWrite = typeof integrationSyncWrites.$inferSelect; export type User = typeof users.$inferSelect; export type TwoFactor = typeof twoFactors.$inferSelect; +export const improvementRegisterTypeEnum = pgEnum("improvement_register_type", [ + "detection_gap", + "logging_gap", + "integration_defect", + "playbook_defect", + "security_control_gap", + "process_failure", + "training_need", + "documentation_gap", +]); + +export const improvementRegisterStatusEnum = pgEnum( + "improvement_register_status", + [ + "open", + "in_review", + "accepted", + "in_progress", + "validated", + "closed", + "reopened", + "rejected", + "deferred", + ], +); + +export const improvementRegisterSeverityEnum = pgEnum( + "improvement_register_severity", + ["low", "medium", "high", "critical"], +); + +export const improvementLinkKindEnum = pgEnum("improvement_link_kind", [ + "case", + "review", + "review_proposal", + "playbook", +]); + +export const improvementSourceKindEnum = pgEnum("improvement_source_kind", [ + "case", + "review", + "review_proposal", + "manual", +]); + +export const improvementValidationMethodEnum = pgEnum( + "improvement_validation_method", + [ + "retest", + "monitoring", + "peer_review", + "document_review", + "exercise", + "other", + ], +); + +export const improvementTicketSyncStateEnum = pgEnum( + "improvement_ticket_sync_state", + ["none", "linked", "pending", "synced", "conflict", "failed"], +); + +export const improvementRegisterEventTypeEnum = pgEnum( + "improvement_register_event_type", + [ + "created", + "updated", + "status_changed", + "linked", + "unlinked", + "assigned", + "validated", + "closed", + "reopened", + "ticket_synced", + "ticket_conflict", + ], +); + +export const improvementRegisterItems = pgTable( + "improvement_register_items", + { + id: text("id").primaryKey(), + organisationId: text("organisation_id") + .notNull() + .references(() => organisations.id, { onDelete: "cascade" }), + type: improvementRegisterTypeEnum("type").notNull(), + title: text("title").notNull(), + description: text("description"), + /** Non-sensitive structured evidence (checklist, refs, metrics). */ + evidence: jsonb("evidence").notNull().default(sql`'{}'::jsonb`), + /** + * Optional sensitive notes from restricted source cases. Redacted for + * actors lacking view_sensitive on the source case. + */ + sensitiveEvidence: jsonb("sensitive_evidence"), + severity: improvementRegisterSeverityEnum("severity") + .notNull() + .default("medium"), + residualRisk: text("residual_risk"), + status: improvementRegisterStatusEnum("status").notNull().default("open"), + ownerId: text("owner_id").references(() => users.id, { + onDelete: "set null", + }), + dueAt: timestamp("due_at", { withTimezone: true }), + /** Distinct linked cases (excluding pure proposal/playbook-only links). */ + recurrenceCount: integer("recurrence_count").notNull().default(0), + linkedPlaybookId: text("linked_playbook_id").references( + () => playbooks.id, + { onDelete: "set null" }, + ), + externalTicketRef: text("external_ticket_ref"), + externalTicketUrl: text("external_ticket_url"), + externalTicketSyncState: improvementTicketSyncStateEnum( + "external_ticket_sync_state", + ) + .notNull() + .default("none"), + externalTicketSyncedAt: timestamp("external_ticket_synced_at", { + withTimezone: true, + }), + externalTicketSyncError: text("external_ticket_sync_error"), + validationMethod: improvementValidationMethodEnum("validation_method"), + validationEvidence: text("validation_evidence"), + validatedBy: text("validated_by").references(() => users.id, { + onDelete: "set null", + }), + validatedAt: timestamp("validated_at", { withTimezone: true }), + closedBy: text("closed_by").references(() => users.id, { + onDelete: "set null", + }), + closedAt: timestamp("closed_at", { withTimezone: true }), + sourceKind: improvementSourceKindEnum("source_kind") + .notNull() + .default("manual"), + /** Immutable origin case (set on create; never overwritten). */ + sourceCaseId: text("source_case_id").references(() => cases.id, { + onDelete: "set null", + }), + /** Immutable origin post-incident review. */ + sourceReviewId: text("source_review_id").references( + () => casePostIncidentReviews.id, + { onDelete: "set null" }, + ), + /** Immutable origin #64 review improvement proposal. */ + sourceProposalId: text("source_proposal_id").references( + () => reviewImprovementProposals.id, + { onDelete: "set null" }, + ), + createdBy: text("created_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("improvement_register_items_org_status_idx").on( + t.organisationId, + t.status, + ), + index("improvement_register_items_org_type_idx").on( + t.organisationId, + t.type, + ), + index("improvement_register_items_org_owner_due_idx").on( + t.organisationId, + t.ownerId, + t.dueAt, + ), + index("improvement_register_items_source_case_idx").on(t.sourceCaseId), + index("improvement_register_items_source_proposal_idx").on( + t.sourceProposalId, + ), + ], +); + +export const improvementRegisterLinks = pgTable( + "improvement_register_links", + { + id: text("id").primaryKey(), + organisationId: text("organisation_id") + .notNull() + .references(() => organisations.id, { onDelete: "cascade" }), + improvementId: text("improvement_id") + .notNull() + .references(() => improvementRegisterItems.id, { onDelete: "cascade" }), + linkKind: improvementLinkKindEnum("link_kind").notNull(), + targetId: text("target_id").notNull(), + isSource: boolean("is_source").notNull().default(false), + createdBy: text("created_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("improvement_register_links_improvement_idx").on(t.improvementId), + index("improvement_register_links_org_kind_target_idx").on( + t.organisationId, + t.linkKind, + t.targetId, + ), + uniqueIndex("improvement_register_links_unique_idx").on( + t.improvementId, + t.linkKind, + t.targetId, + ), + ], +); + +export const improvementRegisterEvents = pgTable( + "improvement_register_events", + { + id: text("id").primaryKey(), + organisationId: text("organisation_id") + .notNull() + .references(() => organisations.id, { onDelete: "cascade" }), + improvementId: text("improvement_id") + .notNull() + .references(() => improvementRegisterItems.id, { onDelete: "cascade" }), + eventType: improvementRegisterEventTypeEnum("event_type").notNull(), + fromStatus: improvementRegisterStatusEnum("from_status"), + toStatus: improvementRegisterStatusEnum("to_status"), + actorId: text("actor_id").references(() => users.id, { + onDelete: "set null", + }), + payload: jsonb("payload").notNull().default(sql`'{}'::jsonb`), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + index("improvement_register_events_improvement_idx").on( + t.improvementId, + t.createdAt, + ), + index("improvement_register_events_org_type_idx").on( + t.organisationId, + t.eventType, + ), + ], +); + +export type ImprovementRegisterItem = + typeof improvementRegisterItems.$inferSelect; + +export type ImprovementRegisterLink = + typeof improvementRegisterLinks.$inferSelect; + +export type ImprovementRegisterEvent = + typeof improvementRegisterEvents.$inferSelect; + export type Case = typeof cases.$inferSelect; export type CaseTask = typeof caseTasks.$inferSelect; export type Observable = typeof observables.$inferSelect; diff --git a/src/lib/improvement-register/core.ts b/src/lib/improvement-register/core.ts new file mode 100644 index 0000000..c52c772 --- /dev/null +++ b/src/lib/improvement-register/core.ts @@ -0,0 +1,1611 @@ +/** + * Improvement register core (issue #66). + * + * Durable records for detection gaps, control weaknesses, process failures, + * and related systemic work. Consumes #64 review improvement proposals without + * replacing them. External ticket fields are bounded references only. + */ + +import { and, asc, desc, eq, inArray, isNotNull, lt, ne } from "drizzle-orm"; +import { db } from "@/db"; +import { + casePostIncidentReviews, + cases, + improvementRegisterEvents, + improvementRegisterItems, + improvementRegisterLinks, + playbooks, + reviewImprovementProposals, + users, + type ImprovementRegisterEvent, + type ImprovementRegisterItem, + type ImprovementRegisterLink, +} from "@/db/schema"; +import { + authorizeCase, + hasPermission, + REDACTED_PLACEHOLDER, + resolveUserActor, + type AccessActor, + type AccessPermission, +} from "@/lib/access"; +import { newId } from "@/lib/utils"; +import { + rankSimilarImprovements, + type SimilarityMatch, +} from "./similarity"; +import { + CLOSABLE_STATUSES, + OPEN_WORK_STATUSES, + PROPOSAL_KIND_TO_REGISTER_TYPE, + type ImprovementLinkKind, + type ImprovementRegisterSeverity, + type ImprovementRegisterStatus, + type ImprovementRegisterType, + type ImprovementSourceKind, + type ImprovementTicketSyncState, + type ImprovementValidationMethod, +} from "./types"; + +export class ImprovementRegisterError extends Error { + status: number; + constructor(message: string, status = 400) { + super(message); + this.name = "ImprovementRegisterError"; + this.status = status; + } +} + +export type CreateImprovementInput = { + type: ImprovementRegisterType; + title: string; + description?: string | null; + evidence?: Record | null; + sensitiveEvidence?: Record | null; + severity?: ImprovementRegisterSeverity; + residualRisk?: string | null; + ownerId?: string | null; + dueAt?: Date | string | null; + linkedPlaybookId?: string | null; + externalTicketRef?: string | null; + externalTicketUrl?: string | null; + /** Create from a case (immutable source). */ + caseId?: string | null; + /** Create from a post-incident review (immutable source). */ + reviewId?: string | null; +}; + +export type ImprovementView = ImprovementRegisterItem & { + links: ImprovementRegisterLink[]; + /** True when sensitiveEvidence was redacted for this actor. */ + sensitiveEvidenceRedacted?: boolean; +}; + +async function resolveActor( + organisationId: string, + actorUserId: string | null, +): Promise { + if (actorUserId) { + const resolved = await resolveUserActor(organisationId, actorUserId); + if (!resolved) { + throw new ImprovementRegisterError("Not found", 404); + } + return resolved; + } + return { + organisationId, + userId: null, + role: "system", + teamIds: [], + }; +} + +async function requireCaseAccess( + organisationId: string, + caseId: string, + actorUserId: string | null, + required: AccessPermission, +): Promise<{ actor: AccessActor; permissions: Set }> { + const actor = await resolveActor(organisationId, actorUserId); + const result = await authorizeCase(organisationId, caseId, actor, required); + if (!result.ok) { + throw new ImprovementRegisterError(result.error, result.status); + } + return { actor, permissions: result.permissions }; +} + +async function assertOwnerInOrg( + organisationId: string, + ownerId: string | null | undefined, +): Promise { + if (!ownerId) return; + const [row] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, ownerId), eq(users.organisationId, organisationId))) + .limit(1); + if (!row) { + throw new ImprovementRegisterError("Owner not found in organisation", 400); + } +} + +async function assertPlaybookInOrg( + organisationId: string, + playbookId: string | null | undefined, +): Promise { + if (!playbookId) return; + const [row] = await db + .select({ id: playbooks.id }) + .from(playbooks) + .where( + and( + eq(playbooks.id, playbookId), + eq(playbooks.organisationId, organisationId), + ), + ) + .limit(1); + if (!row) { + throw new ImprovementRegisterError("Playbook not found", 404); + } +} + +async function assertCaseInOrg( + organisationId: string, + caseId: string, +): Promise<{ id: string }> { + const [row] = await db + .select({ id: cases.id }) + .from(cases) + .where(and(eq(cases.id, caseId), eq(cases.organisationId, organisationId))) + .limit(1); + if (!row) throw new ImprovementRegisterError("Case not found", 404); + return row; +} + +async function assertReviewInOrg( + organisationId: string, + reviewId: string, +): Promise<{ id: string; caseId: string }> { + const [row] = await db + .select({ + id: casePostIncidentReviews.id, + caseId: casePostIncidentReviews.caseId, + }) + .from(casePostIncidentReviews) + .where( + and( + eq(casePostIncidentReviews.id, reviewId), + eq(casePostIncidentReviews.organisationId, organisationId), + ), + ) + .limit(1); + if (!row) throw new ImprovementRegisterError("Review not found", 404); + return row; +} + +async function appendEvent(input: { + organisationId: string; + improvementId: string; + eventType: ImprovementRegisterEvent["eventType"]; + fromStatus?: ImprovementRegisterStatus | null; + toStatus?: ImprovementRegisterStatus | null; + actorId: string | null; + payload?: Record; +}): Promise { + await db.insert(improvementRegisterEvents).values({ + id: newId("imp_evt"), + organisationId: input.organisationId, + improvementId: input.improvementId, + eventType: input.eventType, + fromStatus: input.fromStatus ?? null, + toStatus: input.toStatus ?? null, + actorId: input.actorId, + payload: input.payload ?? {}, + }); +} + +async function recomputeRecurrence( + organisationId: string, + improvementId: string, +): Promise { + const rows = await db + .select({ targetId: improvementRegisterLinks.targetId }) + .from(improvementRegisterLinks) + .where( + and( + eq(improvementRegisterLinks.organisationId, organisationId), + eq(improvementRegisterLinks.improvementId, improvementId), + eq(improvementRegisterLinks.linkKind, "case"), + ), + ); + const distinct = new Set(rows.map((r) => r.targetId)); + const count = distinct.size; + await db + .update(improvementRegisterItems) + .set({ recurrenceCount: count, updatedAt: new Date() }) + .where( + and( + eq(improvementRegisterItems.id, improvementId), + eq(improvementRegisterItems.organisationId, organisationId), + ), + ); + return count; +} + +async function insertLink(input: { + organisationId: string; + improvementId: string; + linkKind: ImprovementLinkKind; + targetId: string; + isSource: boolean; + actorUserId: string | null; +}): Promise { + const id = newId("imp_lnk"); + try { + await db.insert(improvementRegisterLinks).values({ + id, + organisationId: input.organisationId, + improvementId: input.improvementId, + linkKind: input.linkKind, + targetId: input.targetId, + isSource: input.isSource, + createdBy: input.actorUserId, + }); + } catch (err) { + // Unique violation → already linked; return existing. + const message = err instanceof Error ? err.message : String(err); + if (message.includes("improvement_register_links_unique") || message.includes("unique")) { + const [existing] = await db + .select() + .from(improvementRegisterLinks) + .where( + and( + eq(improvementRegisterLinks.improvementId, input.improvementId), + eq(improvementRegisterLinks.linkKind, input.linkKind), + eq(improvementRegisterLinks.targetId, input.targetId), + ), + ) + .limit(1); + if (existing) return existing; + } + throw err; + } + const [row] = await db + .select() + .from(improvementRegisterLinks) + .where(eq(improvementRegisterLinks.id, id)) + .limit(1); + if (!row) throw new ImprovementRegisterError("Failed to create link", 500); + return row; +} + +async function loadItem( + organisationId: string, + improvementId: string, +): Promise { + const [row] = await db + .select() + .from(improvementRegisterItems) + .where( + and( + eq(improvementRegisterItems.id, improvementId), + eq(improvementRegisterItems.organisationId, organisationId), + ), + ) + .limit(1); + return row ?? null; +} + +async function loadLinks( + organisationId: string, + improvementId: string, +): Promise { + return db + .select() + .from(improvementRegisterLinks) + .where( + and( + eq(improvementRegisterLinks.organisationId, organisationId), + eq(improvementRegisterLinks.improvementId, improvementId), + ), + ) + .orderBy(asc(improvementRegisterLinks.createdAt)); +} + +/** + * Redact sensitiveEvidence when actor lacks view_sensitive on the source case + * (or any linked case that may have contributed restricted notes). + */ +async function applySensitiveRedaction( + organisationId: string, + item: ImprovementRegisterItem, + actorUserId: string | null, +): Promise { + const links = await loadLinks(organisationId, item.id); + const base: ImprovementView = { ...item, links, sensitiveEvidenceRedacted: false }; + + if (item.sensitiveEvidence == null) { + return base; + } + + // Prefer source case; else any linked case. + const caseIds: string[] = []; + if (item.sourceCaseId) caseIds.push(item.sourceCaseId); + for (const l of links) { + if (l.linkKind === "case" && !caseIds.includes(l.targetId)) { + caseIds.push(l.targetId); + } + } + + if (caseIds.length === 0) { + // No case context — keep redacted for non-admin actors for safety. + const actor = await resolveActor(organisationId, actorUserId); + if (actor.role !== "admin") { + return { + ...base, + sensitiveEvidence: { [REDACTED_PLACEHOLDER]: true }, + sensitiveEvidenceRedacted: true, + }; + } + return base; + } + + let canView = false; + for (const caseId of caseIds) { + try { + const { permissions } = await requireCaseAccess( + organisationId, + caseId, + actorUserId, + "view_metadata", + ); + if (hasPermission(permissions, "view_sensitive")) { + canView = true; + break; + } + } catch { + // cannot know case — ignore + } + } + + if (!canView) { + return { + ...base, + sensitiveEvidence: { [REDACTED_PLACEHOLDER]: true }, + sensitiveEvidenceRedacted: true, + }; + } + return base; +} + +function parseDueAt(value: Date | string | null | undefined): Date | null { + if (value == null) return null; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + throw new ImprovementRegisterError("Invalid dueAt", 400); + } + return value; + } + const d = new Date(value); + if (Number.isNaN(d.getTime())) { + throw new ImprovementRegisterError("Invalid dueAt", 400); + } + return d; +} + +export async function createImprovementCore( + organisationId: string, + actorUserId: string | null, + input: CreateImprovementInput, +): Promise { + const title = input.title?.trim(); + if (!title) throw new ImprovementRegisterError("Title is required"); + await assertOwnerInOrg(organisationId, input.ownerId); + await assertPlaybookInOrg(organisationId, input.linkedPlaybookId); + + let sourceKind: ImprovementSourceKind = "manual"; + let sourceCaseId: string | null = null; + let sourceReviewId: string | null = null; + + if (input.reviewId) { + const review = await assertReviewInOrg(organisationId, input.reviewId); + await requireCaseAccess( + organisationId, + review.caseId, + actorUserId, + "edit", + ); + sourceKind = "review"; + sourceReviewId = review.id; + sourceCaseId = review.caseId; + } else if (input.caseId) { + await assertCaseInOrg(organisationId, input.caseId); + await requireCaseAccess( + organisationId, + input.caseId, + actorUserId, + "edit", + ); + sourceKind = "case"; + sourceCaseId = input.caseId; + } + + const id = newId("imp"); + const dueAt = parseDueAt(input.dueAt); + + await db.insert(improvementRegisterItems).values({ + id, + organisationId, + type: input.type, + title: title.slice(0, 500), + description: input.description?.trim() || null, + evidence: input.evidence ?? {}, + sensitiveEvidence: input.sensitiveEvidence ?? null, + severity: input.severity ?? "medium", + residualRisk: input.residualRisk?.trim() || null, + status: "open", + ownerId: input.ownerId ?? null, + dueAt, + recurrenceCount: 0, + linkedPlaybookId: input.linkedPlaybookId ?? null, + externalTicketRef: input.externalTicketRef?.trim() || null, + externalTicketUrl: input.externalTicketUrl?.trim() || null, + externalTicketSyncState: input.externalTicketRef ? "linked" : "none", + sourceKind, + sourceCaseId, + sourceReviewId, + sourceProposalId: null, + createdBy: actorUserId, + }); + + if (sourceCaseId) { + await insertLink({ + organisationId, + improvementId: id, + linkKind: "case", + targetId: sourceCaseId, + isSource: true, + actorUserId, + }); + } + if (sourceReviewId) { + await insertLink({ + organisationId, + improvementId: id, + linkKind: "review", + targetId: sourceReviewId, + isSource: true, + actorUserId, + }); + } + if (input.linkedPlaybookId) { + await insertLink({ + organisationId, + improvementId: id, + linkKind: "playbook", + targetId: input.linkedPlaybookId, + isSource: false, + actorUserId, + }); + } + + await recomputeRecurrence(organisationId, id); + await appendEvent({ + organisationId, + improvementId: id, + eventType: "created", + toStatus: "open", + actorId: actorUserId, + payload: { + sourceKind, + sourceCaseId, + sourceReviewId, + type: input.type, + }, + }); + + const item = await loadItem(organisationId, id); + if (!item) throw new ImprovementRegisterError("Failed to create improvement", 500); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +/** + * Promote a #64 review_improvement_proposal into the durable register. + * Does not delete or overwrite the proposal; marks it accepted and links + * immutably via source_proposal_id. + */ +export async function createFromProposalCore( + organisationId: string, + actorUserId: string | null, + proposalId: string, + overrides: { + type?: ImprovementRegisterType; + title?: string; + description?: string | null; + severity?: ImprovementRegisterSeverity; + ownerId?: string | null; + dueAt?: Date | string | null; + } = {}, +): Promise { + const [proposal] = await db + .select() + .from(reviewImprovementProposals) + .where( + and( + eq(reviewImprovementProposals.id, proposalId), + eq(reviewImprovementProposals.organisationId, organisationId), + ), + ) + .limit(1); + if (!proposal) { + throw new ImprovementRegisterError("Improvement proposal not found", 404); + } + + // Idempotent: if already promoted, return the existing register item. + const [existing] = await db + .select() + .from(improvementRegisterItems) + .where( + and( + eq(improvementRegisterItems.organisationId, organisationId), + eq(improvementRegisterItems.sourceProposalId, proposalId), + ), + ) + .limit(1); + if (existing) { + return applySensitiveRedaction(organisationId, existing, actorUserId); + } + + await requireCaseAccess( + organisationId, + proposal.caseId, + actorUserId, + "edit", + ); + + const mappedType = + overrides.type ?? + PROPOSAL_KIND_TO_REGISTER_TYPE[proposal.kind] ?? + "documentation_gap"; + + const title = (overrides.title ?? proposal.title).trim(); + if (!title) throw new ImprovementRegisterError("Title is required"); + + const ownerId = overrides.ownerId !== undefined ? overrides.ownerId : proposal.ownerId; + await assertOwnerInOrg(organisationId, ownerId); + if (proposal.linkedPlaybookId) { + await assertPlaybookInOrg(organisationId, proposal.linkedPlaybookId); + } + + const id = newId("imp"); + const dueAt = parseDueAt(overrides.dueAt); + + await db.insert(improvementRegisterItems).values({ + id, + organisationId, + type: mappedType, + title: title.slice(0, 500), + description: + overrides.description !== undefined + ? overrides.description?.trim() || null + : proposal.description, + evidence: { + fromProposal: true, + proposalKind: proposal.kind, + proposalId: proposal.id, + }, + sensitiveEvidence: null, + severity: overrides.severity ?? "medium", + residualRisk: null, + status: "open", + ownerId: ownerId ?? null, + dueAt, + recurrenceCount: 0, + linkedPlaybookId: proposal.linkedPlaybookId, + externalTicketRef: proposal.externalTicketRef, + externalTicketUrl: proposal.externalTicketUrl, + externalTicketSyncState: proposal.externalTicketRef ? "linked" : "none", + sourceKind: "review_proposal", + sourceCaseId: proposal.caseId, + sourceReviewId: proposal.reviewId, + sourceProposalId: proposal.id, + createdBy: actorUserId, + }); + + await insertLink({ + organisationId, + improvementId: id, + linkKind: "case", + targetId: proposal.caseId, + isSource: true, + actorUserId, + }); + await insertLink({ + organisationId, + improvementId: id, + linkKind: "review", + targetId: proposal.reviewId, + isSource: true, + actorUserId, + }); + await insertLink({ + organisationId, + improvementId: id, + linkKind: "review_proposal", + targetId: proposal.id, + isSource: true, + actorUserId, + }); + if (proposal.linkedPlaybookId) { + await insertLink({ + organisationId, + improvementId: id, + linkKind: "playbook", + targetId: proposal.linkedPlaybookId, + isSource: false, + actorUserId, + }); + } + + // Accept the proposal without rewriting its audit fields beyond status. + if (proposal.status === "proposed" || proposal.status === "deferred") { + await db + .update(reviewImprovementProposals) + .set({ status: "accepted", updatedAt: new Date() }) + .where( + and( + eq(reviewImprovementProposals.id, proposal.id), + eq(reviewImprovementProposals.organisationId, organisationId), + ), + ); + } + + await recomputeRecurrence(organisationId, id); + await appendEvent({ + organisationId, + improvementId: id, + eventType: "created", + toStatus: "open", + actorId: actorUserId, + payload: { + sourceKind: "review_proposal", + sourceProposalId: proposal.id, + sourceCaseId: proposal.caseId, + sourceReviewId: proposal.reviewId, + type: mappedType, + }, + }); + + const item = await loadItem(organisationId, id); + if (!item) throw new ImprovementRegisterError("Failed to create improvement", 500); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +export async function getImprovementCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, +): Promise { + const item = await loadItem(organisationId, improvementId); + if (!item) return null; + // If source case is restricted and actor cannot know it exists, still allow + // reading the register record but redact sensitive fields (done below). + if (item.sourceCaseId) { + try { + await requireCaseAccess( + organisationId, + item.sourceCaseId, + actorUserId, + "know_exists", + ); + } catch { + // Register items are org-scoped; do not 404 solely on source case. + } + } + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +export async function listImprovementsCore( + organisationId: string, + actorUserId: string | null, + filters: { + status?: ImprovementRegisterStatus | ImprovementRegisterStatus[]; + type?: ImprovementRegisterType | ImprovementRegisterType[]; + ownerId?: string | null; + caseId?: string | null; + overdueOnly?: boolean; + limit?: number; + } = {}, +): Promise { + // Touch actor resolution for fail-closed org membership when a user is set. + await resolveActor(organisationId, actorUserId); + + const conditions = [eq(improvementRegisterItems.organisationId, organisationId)]; + + if (filters.status) { + const statuses = Array.isArray(filters.status) + ? filters.status + : [filters.status]; + if (statuses.length === 1) { + conditions.push(eq(improvementRegisterItems.status, statuses[0]!)); + } else if (statuses.length > 1) { + conditions.push(inArray(improvementRegisterItems.status, statuses)); + } + } + if (filters.type) { + const types = Array.isArray(filters.type) ? filters.type : [filters.type]; + if (types.length === 1) { + conditions.push(eq(improvementRegisterItems.type, types[0]!)); + } else if (types.length > 1) { + conditions.push(inArray(improvementRegisterItems.type, types)); + } + } + if (filters.ownerId) { + conditions.push(eq(improvementRegisterItems.ownerId, filters.ownerId)); + } + if (filters.overdueOnly) { + conditions.push(isNotNull(improvementRegisterItems.dueAt)); + conditions.push(lt(improvementRegisterItems.dueAt, new Date())); + conditions.push( + inArray(improvementRegisterItems.status, [...OPEN_WORK_STATUSES]), + ); + } + + let query = db + .select() + .from(improvementRegisterItems) + .where(and(...conditions)) + .orderBy(desc(improvementRegisterItems.updatedAt)) + .limit(Math.min(filters.limit ?? 100, 500)); + + let rows = await query; + + if (filters.caseId) { + await assertCaseInOrg(organisationId, filters.caseId); + await requireCaseAccess( + organisationId, + filters.caseId, + actorUserId, + "know_exists", + ); + const linked = await db + .select({ improvementId: improvementRegisterLinks.improvementId }) + .from(improvementRegisterLinks) + .where( + and( + eq(improvementRegisterLinks.organisationId, organisationId), + eq(improvementRegisterLinks.linkKind, "case"), + eq(improvementRegisterLinks.targetId, filters.caseId), + ), + ); + const ids = new Set(linked.map((l) => l.improvementId)); + rows = rows.filter((r) => ids.has(r.id) || r.sourceCaseId === filters.caseId); + } + + const out: ImprovementView[] = []; + for (const row of rows) { + out.push(await applySensitiveRedaction(organisationId, row, actorUserId)); + } + return out; +} + +export async function updateImprovementCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, + input: { + title?: string; + description?: string | null; + evidence?: Record | null; + sensitiveEvidence?: Record | null; + severity?: ImprovementRegisterSeverity; + residualRisk?: string | null; + status?: ImprovementRegisterStatus; + ownerId?: string | null; + dueAt?: Date | string | null; + linkedPlaybookId?: string | null; + }, +): Promise { + const existing = await loadItem(organisationId, improvementId); + if (!existing) { + throw new ImprovementRegisterError("Improvement not found", 404); + } + + if (existing.sourceCaseId) { + await requireCaseAccess( + organisationId, + existing.sourceCaseId, + actorUserId, + "edit", + ); + } else { + await resolveActor(organisationId, actorUserId); + } + + // Status transitions to closed must use closeImprovementCore (validation). + if (input.status === "closed") { + throw new ImprovementRegisterError( + "Use the close endpoint with validation method and evidence", + 400, + ); + } + if (input.status === "reopened") { + throw new ImprovementRegisterError( + "Use the reopen endpoint to reopen a closed improvement", + 400, + ); + } + + const patch: Partial = { + updatedAt: new Date(), + }; + const events: Array<{ + eventType: ImprovementRegisterEvent["eventType"]; + fromStatus?: ImprovementRegisterStatus | null; + toStatus?: ImprovementRegisterStatus | null; + payload?: Record; + }> = []; + + if (input.title !== undefined) { + const title = input.title.trim(); + if (!title) throw new ImprovementRegisterError("Title is required"); + patch.title = title.slice(0, 500); + } + if (input.description !== undefined) { + patch.description = input.description?.trim() || null; + } + if (input.evidence !== undefined) { + patch.evidence = input.evidence ?? {}; + } + if (input.sensitiveEvidence !== undefined) { + patch.sensitiveEvidence = input.sensitiveEvidence; + } + if (input.severity !== undefined) patch.severity = input.severity; + if (input.residualRisk !== undefined) { + patch.residualRisk = input.residualRisk?.trim() || null; + } + if (input.dueAt !== undefined) { + patch.dueAt = parseDueAt(input.dueAt); + } + if (input.ownerId !== undefined) { + await assertOwnerInOrg(organisationId, input.ownerId); + patch.ownerId = input.ownerId; + events.push({ + eventType: "assigned", + payload: { ownerId: input.ownerId }, + }); + } + if (input.linkedPlaybookId !== undefined) { + await assertPlaybookInOrg(organisationId, input.linkedPlaybookId); + patch.linkedPlaybookId = input.linkedPlaybookId; + if (input.linkedPlaybookId) { + await insertLink({ + organisationId, + improvementId, + linkKind: "playbook", + targetId: input.linkedPlaybookId, + isSource: false, + actorUserId, + }); + } + } + if (input.status !== undefined && input.status !== existing.status) { + patch.status = input.status; + events.push({ + eventType: "status_changed", + fromStatus: existing.status as ImprovementRegisterStatus, + toStatus: input.status, + }); + } else { + events.push({ eventType: "updated", payload: { fields: Object.keys(input) } }); + } + + await db + .update(improvementRegisterItems) + .set(patch) + .where( + and( + eq(improvementRegisterItems.id, improvementId), + eq(improvementRegisterItems.organisationId, organisationId), + ), + ); + + for (const e of events) { + await appendEvent({ + organisationId, + improvementId, + eventType: e.eventType, + fromStatus: e.fromStatus, + toStatus: e.toStatus, + actorId: actorUserId, + payload: e.payload, + }); + } + + const item = await loadItem(organisationId, improvementId); + if (!item) throw new ImprovementRegisterError("Improvement not found", 404); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +export async function linkImprovementCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, + input: { linkKind: ImprovementLinkKind; targetId: string }, +): Promise { + const existing = await loadItem(organisationId, improvementId); + if (!existing) { + throw new ImprovementRegisterError("Improvement not found", 404); + } + + const targetId = input.targetId.trim(); + if (!targetId) throw new ImprovementRegisterError("targetId is required"); + + if (input.linkKind === "case") { + await assertCaseInOrg(organisationId, targetId); + await requireCaseAccess(organisationId, targetId, actorUserId, "edit"); + } else if (input.linkKind === "review") { + const review = await assertReviewInOrg(organisationId, targetId); + await requireCaseAccess( + organisationId, + review.caseId, + actorUserId, + "edit", + ); + } else if (input.linkKind === "playbook") { + await assertPlaybookInOrg(organisationId, targetId); + await resolveActor(organisationId, actorUserId); + } else if (input.linkKind === "review_proposal") { + const [proposal] = await db + .select({ id: reviewImprovementProposals.id, caseId: reviewImprovementProposals.caseId }) + .from(reviewImprovementProposals) + .where( + and( + eq(reviewImprovementProposals.id, targetId), + eq(reviewImprovementProposals.organisationId, organisationId), + ), + ) + .limit(1); + if (!proposal) { + throw new ImprovementRegisterError("Improvement proposal not found", 404); + } + await requireCaseAccess( + organisationId, + proposal.caseId, + actorUserId, + "edit", + ); + } + + await insertLink({ + organisationId, + improvementId, + linkKind: input.linkKind, + targetId, + isSource: false, + actorUserId, + }); + + // When linking a review, also link its case for recurrence accuracy. + if (input.linkKind === "review") { + const review = await assertReviewInOrg(organisationId, targetId); + await insertLink({ + organisationId, + improvementId, + linkKind: "case", + targetId: review.caseId, + isSource: false, + actorUserId, + }); + } + + const recurrence = await recomputeRecurrence(organisationId, improvementId); + await appendEvent({ + organisationId, + improvementId, + eventType: "linked", + actorId: actorUserId, + payload: { + linkKind: input.linkKind, + targetId, + recurrenceCount: recurrence, + }, + }); + + const item = await loadItem(organisationId, improvementId); + if (!item) throw new ImprovementRegisterError("Improvement not found", 404); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +export async function unlinkImprovementCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, + linkId: string, +): Promise { + const existing = await loadItem(organisationId, improvementId); + if (!existing) { + throw new ImprovementRegisterError("Improvement not found", 404); + } + if (existing.sourceCaseId) { + await requireCaseAccess( + organisationId, + existing.sourceCaseId, + actorUserId, + "edit", + ); + } else { + await resolveActor(organisationId, actorUserId); + } + + const [link] = await db + .select() + .from(improvementRegisterLinks) + .where( + and( + eq(improvementRegisterLinks.id, linkId), + eq(improvementRegisterLinks.improvementId, improvementId), + eq(improvementRegisterLinks.organisationId, organisationId), + ), + ) + .limit(1); + if (!link) throw new ImprovementRegisterError("Link not found", 404); + if (link.isSource) { + throw new ImprovementRegisterError( + "Immutable source links cannot be removed", + 400, + ); + } + + await db + .delete(improvementRegisterLinks) + .where(eq(improvementRegisterLinks.id, linkId)); + + const recurrence = await recomputeRecurrence(organisationId, improvementId); + await appendEvent({ + organisationId, + improvementId, + eventType: "unlinked", + actorId: actorUserId, + payload: { + linkKind: link.linkKind, + targetId: link.targetId, + recurrenceCount: recurrence, + }, + }); + + const item = await loadItem(organisationId, improvementId); + if (!item) throw new ImprovementRegisterError("Improvement not found", 404); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +export async function closeImprovementCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, + input: { + validationMethod: ImprovementValidationMethod; + validationEvidence: string; + }, +): Promise { + const existing = await loadItem(organisationId, improvementId); + if (!existing) { + throw new ImprovementRegisterError("Improvement not found", 404); + } + if (existing.sourceCaseId) { + await requireCaseAccess( + organisationId, + existing.sourceCaseId, + actorUserId, + "edit", + ); + } else { + await resolveActor(organisationId, actorUserId); + } + + if (existing.status === "closed") { + throw new ImprovementRegisterError("Improvement is already closed", 400); + } + if ( + !(CLOSABLE_STATUSES as readonly string[]).includes(existing.status) && + existing.status !== "deferred" && + existing.status !== "rejected" + ) { + throw new ImprovementRegisterError( + `Cannot close improvement in status ${existing.status}`, + 400, + ); + } + + const evidence = input.validationEvidence?.trim(); + if (!evidence) { + throw new ImprovementRegisterError( + "Closing requires validation evidence or reference", + 400, + ); + } + if (!input.validationMethod) { + throw new ImprovementRegisterError( + "Closing requires a validation method", + 400, + ); + } + + const now = new Date(); + const fromStatus = existing.status as ImprovementRegisterStatus; + + await db + .update(improvementRegisterItems) + .set({ + status: "closed", + validationMethod: input.validationMethod, + validationEvidence: evidence.slice(0, 10_000), + validatedBy: actorUserId, + validatedAt: now, + closedBy: actorUserId, + closedAt: now, + updatedAt: now, + }) + .where( + and( + eq(improvementRegisterItems.id, improvementId), + eq(improvementRegisterItems.organisationId, organisationId), + ), + ); + + await appendEvent({ + organisationId, + improvementId, + eventType: "validated", + fromStatus, + toStatus: "closed", + actorId: actorUserId, + payload: { + validationMethod: input.validationMethod, + validationEvidence: evidence.slice(0, 10_000), + validatedAt: now.toISOString(), + }, + }); + await appendEvent({ + organisationId, + improvementId, + eventType: "closed", + fromStatus, + toStatus: "closed", + actorId: actorUserId, + payload: { + closedAt: now.toISOString(), + validationMethod: input.validationMethod, + }, + }); + + const item = await loadItem(organisationId, improvementId); + if (!item) throw new ImprovementRegisterError("Improvement not found", 404); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +/** + * Reopen a closed improvement. Prior validation/closure history remains in + * events; current validation fields are cleared for a fresh cycle but the + * history payload retains the previous proof. + */ +export async function reopenImprovementCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, + reason?: string | null, +): Promise { + const existing = await loadItem(organisationId, improvementId); + if (!existing) { + throw new ImprovementRegisterError("Improvement not found", 404); + } + if (existing.sourceCaseId) { + await requireCaseAccess( + organisationId, + existing.sourceCaseId, + actorUserId, + "edit", + ); + } else { + await resolveActor(organisationId, actorUserId); + } + + if (existing.status !== "closed") { + throw new ImprovementRegisterError( + "Only closed improvements can be reopened", + 400, + ); + } + + const prior = { + validationMethod: existing.validationMethod, + validationEvidence: existing.validationEvidence, + validatedBy: existing.validatedBy, + validatedAt: existing.validatedAt?.toISOString() ?? null, + closedBy: existing.closedBy, + closedAt: existing.closedAt?.toISOString() ?? null, + }; + + await db + .update(improvementRegisterItems) + .set({ + status: "reopened", + validationMethod: null, + validationEvidence: null, + validatedBy: null, + validatedAt: null, + closedBy: null, + closedAt: null, + updatedAt: new Date(), + }) + .where( + and( + eq(improvementRegisterItems.id, improvementId), + eq(improvementRegisterItems.organisationId, organisationId), + ), + ); + + await appendEvent({ + organisationId, + improvementId, + eventType: "reopened", + fromStatus: "closed", + toStatus: "reopened", + actorId: actorUserId, + payload: { + reason: reason?.trim() || null, + priorClosure: prior, + }, + }); + + const item = await loadItem(organisationId, improvementId); + if (!item) throw new ImprovementRegisterError("Improvement not found", 404); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +/** + * Bounded external ticket sync: only updates ticket ref/url/sync state. + * Never replaces owner, links, status (except conflict flag), or history. + */ +export async function syncExternalTicketCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, + input: { + externalTicketRef?: string | null; + externalTicketUrl?: string | null; + syncState?: ImprovementTicketSyncState; + conflict?: boolean; + error?: string | null; + }, +): Promise { + const existing = await loadItem(organisationId, improvementId); + if (!existing) { + throw new ImprovementRegisterError("Improvement not found", 404); + } + await resolveActor(organisationId, actorUserId); + + // Reject attempts to smuggle ownership/status through this path. + const forbiddenKeys = Object.keys(input).filter( + (k) => + ![ + "externalTicketRef", + "externalTicketUrl", + "syncState", + "conflict", + "error", + ].includes(k), + ); + if (forbiddenKeys.length > 0) { + throw new ImprovementRegisterError( + "External ticket sync cannot modify ownership, links, or audit fields", + 400, + ); + } + + const isConflict = input.conflict === true || input.syncState === "conflict"; + const syncState: ImprovementTicketSyncState = isConflict + ? "conflict" + : (input.syncState ?? + (input.externalTicketRef !== undefined + ? input.externalTicketRef + ? "synced" + : "none" + : existing.externalTicketSyncState)); + + const patch: Partial = { + updatedAt: new Date(), + externalTicketSyncState: syncState, + externalTicketSyncedAt: new Date(), + externalTicketSyncError: isConflict + ? (input.error?.trim() || "External ticket sync conflict") + : input.error?.trim() || null, + }; + if (input.externalTicketRef !== undefined) { + patch.externalTicketRef = input.externalTicketRef?.trim() || null; + } + if (input.externalTicketUrl !== undefined) { + patch.externalTicketUrl = input.externalTicketUrl?.trim() || null; + } + + // Preserve ownership, status, links — only ticket fields change. + await db + .update(improvementRegisterItems) + .set(patch) + .where( + and( + eq(improvementRegisterItems.id, improvementId), + eq(improvementRegisterItems.organisationId, organisationId), + ), + ); + + await appendEvent({ + organisationId, + improvementId, + eventType: isConflict ? "ticket_conflict" : "ticket_synced", + actorId: actorUserId, + payload: { + externalTicketRef: + input.externalTicketRef !== undefined + ? input.externalTicketRef + : existing.externalTicketRef, + syncState, + // Snapshot of fields that must never be replaced by ticket systems: + preservedOwnerId: existing.ownerId, + preservedStatus: existing.status, + preservedRecurrenceCount: existing.recurrenceCount, + }, + }); + + const item = await loadItem(organisationId, improvementId); + if (!item) throw new ImprovementRegisterError("Improvement not found", 404); + return applySensitiveRedaction(organisationId, item, actorUserId); +} + +export async function suggestSimilarImprovementsCore( + organisationId: string, + actorUserId: string | null, + query: { + type?: ImprovementRegisterType; + title: string; + description?: string | null; + limit?: number; + }, +): Promise { + await resolveActor(organisationId, actorUserId); + const title = query.title?.trim(); + if (!title) throw new ImprovementRegisterError("Title is required for suggestions"); + + const candidates = await db + .select({ + id: improvementRegisterItems.id, + type: improvementRegisterItems.type, + title: improvementRegisterItems.title, + description: improvementRegisterItems.description, + status: improvementRegisterItems.status, + severity: improvementRegisterItems.severity, + recurrenceCount: improvementRegisterItems.recurrenceCount, + }) + .from(improvementRegisterItems) + .where( + and( + eq(improvementRegisterItems.organisationId, organisationId), + ne(improvementRegisterItems.status, "rejected"), + ), + ) + .limit(500); + + return rankSimilarImprovements( + { + type: query.type, + title, + description: query.description, + }, + candidates.map((c) => ({ + ...c, + type: c.type as ImprovementRegisterType, + })), + { limit: query.limit ?? 5 }, + ); +} + +export async function listImprovementEventsCore( + organisationId: string, + improvementId: string, + actorUserId: string | null, +): Promise { + const item = await loadItem(organisationId, improvementId); + if (!item) throw new ImprovementRegisterError("Improvement not found", 404); + await resolveActor(organisationId, actorUserId); + return db + .select() + .from(improvementRegisterEvents) + .where( + and( + eq(improvementRegisterEvents.organisationId, organisationId), + eq(improvementRegisterEvents.improvementId, improvementId), + ), + ) + .orderBy(asc(improvementRegisterEvents.createdAt)); +} + +export type ImprovementDashboard = { + byType: Array<{ type: string; count: number }>; + bySeverity: Array<{ severity: string; count: number }>; + byStatus: Array<{ status: string; count: number }>; + byOwner: Array<{ ownerId: string | null; count: number }>; + overdue: Array<{ + id: string; + title: string; + type: string; + severity: string; + ownerId: string | null; + dueAt: string; + status: string; + }>; + highRecurrence: Array<{ + id: string; + title: string; + type: string; + recurrenceCount: number; + status: string; + }>; + validationPending: Array<{ + id: string; + title: string; + type: string; + status: string; + ownerId: string | null; + }>; + totals: { + openWork: number; + closed: number; + overdue: number; + withValidation: number; + }; +}; + +export async function improvementDashboardCore( + organisationId: string, + actorUserId: string | null, +): Promise { + await resolveActor(organisationId, actorUserId); + + const rows = await db + .select() + .from(improvementRegisterItems) + .where(eq(improvementRegisterItems.organisationId, organisationId)); + + const byTypeMap = new Map(); + const bySeverityMap = new Map(); + const byStatusMap = new Map(); + const byOwnerMap = new Map(); + const overdue: ImprovementDashboard["overdue"] = []; + const highRecurrence: ImprovementDashboard["highRecurrence"] = []; + const validationPending: ImprovementDashboard["validationPending"] = []; + let openWork = 0; + let closed = 0; + let withValidation = 0; + const now = Date.now(); + + for (const r of rows) { + byTypeMap.set(r.type, (byTypeMap.get(r.type) ?? 0) + 1); + bySeverityMap.set(r.severity, (bySeverityMap.get(r.severity) ?? 0) + 1); + byStatusMap.set(r.status, (byStatusMap.get(r.status) ?? 0) + 1); + byOwnerMap.set(r.ownerId, (byOwnerMap.get(r.ownerId) ?? 0) + 1); + + const isOpen = (OPEN_WORK_STATUSES as readonly string[]).includes(r.status); + if (isOpen) openWork += 1; + if (r.status === "closed") closed += 1; + if (r.validationMethod) withValidation += 1; + + if ( + isOpen && + r.dueAt && + r.dueAt.getTime() < now + ) { + overdue.push({ + id: r.id, + title: r.title, + type: r.type, + severity: r.severity, + ownerId: r.ownerId, + dueAt: r.dueAt.toISOString(), + status: r.status, + }); + } + + if (r.recurrenceCount >= 2) { + highRecurrence.push({ + id: r.id, + title: r.title, + type: r.type, + recurrenceCount: r.recurrenceCount, + status: r.status, + }); + } + + if (r.status === "in_progress" || r.status === "validated") { + if (!r.validationMethod || r.status === "in_progress") { + validationPending.push({ + id: r.id, + title: r.title, + type: r.type, + status: r.status, + ownerId: r.ownerId, + }); + } + } + } + + overdue.sort((a, b) => a.dueAt.localeCompare(b.dueAt)); + highRecurrence.sort((a, b) => b.recurrenceCount - a.recurrenceCount); + + return { + byType: [...byTypeMap.entries()] + .map(([type, count]) => ({ type, count })) + .sort((a, b) => b.count - a.count), + bySeverity: [...bySeverityMap.entries()] + .map(([severity, count]) => ({ severity, count })) + .sort((a, b) => b.count - a.count), + byStatus: [...byStatusMap.entries()] + .map(([status, count]) => ({ status, count })) + .sort((a, b) => b.count - a.count), + byOwner: [...byOwnerMap.entries()] + .map(([ownerId, count]) => ({ ownerId, count })) + .sort((a, b) => b.count - a.count), + overdue: overdue.slice(0, 50), + highRecurrence: highRecurrence.slice(0, 50), + validationPending: validationPending.slice(0, 50), + totals: { + openWork, + closed, + overdue: overdue.length, + withValidation, + }, + }; +} + +export function serializeImprovement(view: ImprovementView) { + return { + id: view.id, + type: view.type, + title: view.title, + description: view.description, + evidence: view.evidence, + sensitiveEvidence: view.sensitiveEvidence, + sensitiveEvidenceRedacted: view.sensitiveEvidenceRedacted ?? false, + severity: view.severity, + residualRisk: view.residualRisk, + status: view.status, + ownerId: view.ownerId, + dueAt: view.dueAt?.toISOString() ?? null, + recurrenceCount: view.recurrenceCount, + linkedPlaybookId: view.linkedPlaybookId, + externalTicketRef: view.externalTicketRef, + externalTicketUrl: view.externalTicketUrl, + externalTicketSyncState: view.externalTicketSyncState, + externalTicketSyncedAt: view.externalTicketSyncedAt?.toISOString() ?? null, + externalTicketSyncError: view.externalTicketSyncError, + validationMethod: view.validationMethod, + validationEvidence: view.validationEvidence, + validatedBy: view.validatedBy, + validatedAt: view.validatedAt?.toISOString() ?? null, + closedBy: view.closedBy, + closedAt: view.closedAt?.toISOString() ?? null, + sourceKind: view.sourceKind, + sourceCaseId: view.sourceCaseId, + sourceReviewId: view.sourceReviewId, + sourceProposalId: view.sourceProposalId, + createdBy: view.createdBy, + createdAt: view.createdAt.toISOString(), + updatedAt: view.updatedAt.toISOString(), + links: view.links.map((l) => ({ + id: l.id, + linkKind: l.linkKind, + targetId: l.targetId, + isSource: l.isSource, + createdAt: l.createdAt.toISOString(), + })), + }; +} diff --git a/src/lib/improvement-register/index.ts b/src/lib/improvement-register/index.ts new file mode 100644 index 0000000..a40bc9b --- /dev/null +++ b/src/lib/improvement-register/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./similarity"; +export * from "./core"; diff --git a/src/lib/improvement-register/similarity.ts b/src/lib/improvement-register/similarity.ts new file mode 100644 index 0000000..9b92ce2 --- /dev/null +++ b/src/lib/improvement-register/similarity.ts @@ -0,0 +1,149 @@ +/** + * Similarity suggestions for the improvement register (issue #66). + * Pure helpers — never auto-merge. Callers present matches with explained fields. + */ + +import type { ImprovementRegisterType } from "./types"; + +const STOP_WORDS = new Set([ + "a", + "an", + "the", + "and", + "or", + "of", + "to", + "for", + "in", + "on", + "at", + "by", + "with", + "from", + "is", + "are", + "was", + "were", + "be", + "been", + "this", + "that", + "these", + "those", + "it", + "its", + "as", + "not", + "no", + "missing", + "lack", + "gap", + "issue", + "problem", +]); + +export function tokenise(text: string): Set { + const tokens = new Set(); + for (const raw of text.toLowerCase().split(/[^a-z0-9_]+/g)) { + if (raw.length < 3) continue; + if (STOP_WORDS.has(raw)) continue; + tokens.add(raw); + } + return tokens; +} + +export function jaccard(a: Set, b: Set): number { + if (a.size === 0 && b.size === 0) return 0; + let inter = 0; + for (const t of a) { + if (b.has(t)) inter += 1; + } + const union = a.size + b.size - inter; + return union === 0 ? 0 : inter / union; +} + +export type SimilarityCandidate = { + id: string; + type: ImprovementRegisterType; + title: string; + description: string | null; + status: string; + severity: string; + recurrenceCount: number; +}; + +export type SimilarityMatch = { + improvement: SimilarityCandidate; + score: number; + matchedFields: Array<{ + field: "type" | "title" | "description"; + detail: string; + }>; +}; + +/** + * Rank existing improvements against a proposed title/description/type. + * Type match is a strong signal; title/description use token Jaccard. + * Never returns a match with score 0; does not merge or mutate records. + */ +export function rankSimilarImprovements( + query: { + type?: ImprovementRegisterType; + title: string; + description?: string | null; + }, + candidates: SimilarityCandidate[], + opts: { limit?: number; minScore?: number } = {}, +): SimilarityMatch[] { + const limit = opts.limit ?? 5; + const minScore = opts.minScore ?? 0.15; + const titleTokens = tokenise(query.title); + const descTokens = tokenise(query.description ?? ""); + + const ranked: SimilarityMatch[] = []; + for (const c of candidates) { + const matchedFields: SimilarityMatch["matchedFields"] = []; + let score = 0; + + if (query.type && c.type === query.type) { + score += 0.35; + matchedFields.push({ + field: "type", + detail: `Same improvement type: ${c.type}`, + }); + } + + const cTitle = tokenise(c.title); + const titleScore = jaccard(titleTokens, cTitle); + if (titleScore > 0) { + score += titleScore * 0.45; + const overlap = [...titleTokens].filter((t) => cTitle.has(t)); + matchedFields.push({ + field: "title", + detail: `Title token overlap (${Math.round(titleScore * 100)}%): ${overlap.slice(0, 8).join(", ")}`, + }); + } + + if (descTokens.size > 0) { + const cDesc = tokenise(c.description ?? ""); + const descScore = jaccard(descTokens, cDesc); + if (descScore > 0) { + score += descScore * 0.2; + const overlap = [...descTokens].filter((t) => cDesc.has(t)); + matchedFields.push({ + field: "description", + detail: `Description token overlap (${Math.round(descScore * 100)}%): ${overlap.slice(0, 8).join(", ")}`, + }); + } + } + + // Cap at 1.0 for readability. + score = Math.min(1, score); + if (score >= minScore && matchedFields.length > 0) { + ranked.push({ improvement: c, score, matchedFields }); + } + } + + ranked.sort((a, b) => b.score - a.score || a.improvement.id.localeCompare(b.improvement.id)); + return ranked.slice(0, limit); +} diff --git a/src/lib/improvement-register/types.ts b/src/lib/improvement-register/types.ts new file mode 100644 index 0000000..9fec779 --- /dev/null +++ b/src/lib/improvement-register/types.ts @@ -0,0 +1,162 @@ +/** + * Detection / control / process improvement register domain types (issue #66). + */ + +export const IMPROVEMENT_REGISTER_TYPES = [ + "detection_gap", + "logging_gap", + "integration_defect", + "playbook_defect", + "security_control_gap", + "process_failure", + "training_need", + "documentation_gap", +] as const; + +export type ImprovementRegisterType = + (typeof IMPROVEMENT_REGISTER_TYPES)[number]; + +export const IMPROVEMENT_REGISTER_STATUSES = [ + "open", + "in_review", + "accepted", + "in_progress", + "validated", + "closed", + "reopened", + "rejected", + "deferred", +] as const; + +export type ImprovementRegisterStatus = + (typeof IMPROVEMENT_REGISTER_STATUSES)[number]; + +export const IMPROVEMENT_REGISTER_SEVERITIES = [ + "low", + "medium", + "high", + "critical", +] as const; + +export type ImprovementRegisterSeverity = + (typeof IMPROVEMENT_REGISTER_SEVERITIES)[number]; + +export const IMPROVEMENT_LINK_KINDS = [ + "case", + "review", + "review_proposal", + "playbook", +] as const; + +export type ImprovementLinkKind = (typeof IMPROVEMENT_LINK_KINDS)[number]; + +export const IMPROVEMENT_SOURCE_KINDS = [ + "case", + "review", + "review_proposal", + "manual", +] as const; + +export type ImprovementSourceKind = (typeof IMPROVEMENT_SOURCE_KINDS)[number]; + +export const IMPROVEMENT_VALIDATION_METHODS = [ + "retest", + "monitoring", + "peer_review", + "document_review", + "exercise", + "other", +] as const; + +export type ImprovementValidationMethod = + (typeof IMPROVEMENT_VALIDATION_METHODS)[number]; + +export const IMPROVEMENT_TICKET_SYNC_STATES = [ + "none", + "linked", + "pending", + "synced", + "conflict", + "failed", +] as const; + +export type ImprovementTicketSyncState = + (typeof IMPROVEMENT_TICKET_SYNC_STATES)[number]; + +export const IMPROVEMENT_REGISTER_EVENT_TYPES = [ + "created", + "updated", + "status_changed", + "linked", + "unlinked", + "assigned", + "validated", + "closed", + "reopened", + "ticket_synced", + "ticket_conflict", +] as const; + +export type ImprovementRegisterEventType = + (typeof IMPROVEMENT_REGISTER_EVENT_TYPES)[number]; + +/** Statuses that may transition to closed (after validation). */ +export const CLOSABLE_STATUSES: readonly ImprovementRegisterStatus[] = [ + "open", + "in_review", + "accepted", + "in_progress", + "validated", + "reopened", +] as const; + +/** Statuses considered open work for dashboard/overdue. */ +export const OPEN_WORK_STATUSES: readonly ImprovementRegisterStatus[] = [ + "open", + "in_review", + "accepted", + "in_progress", + "validated", + "reopened", + "deferred", +] as const; + +/** + * Map #64 review_improvement_kind → register type. + * Communication gaps fold into process_failure; free-form "other" into + * documentation_gap so the register stays within its fixed taxonomy. + */ +export const PROPOSAL_KIND_TO_REGISTER_TYPE: Record< + string, + ImprovementRegisterType +> = { + playbook_revision: "playbook_defect", + detection_improvement: "detection_gap", + integration_backlog: "integration_defect", + control_gap: "security_control_gap", + process_gap: "process_failure", + communication_gap: "process_failure", + other: "documentation_gap", +}; + +export function isImprovementRegisterType( + v: string, +): v is ImprovementRegisterType { + return (IMPROVEMENT_REGISTER_TYPES as readonly string[]).includes(v); +} + +export function isImprovementRegisterStatus( + v: string, +): v is ImprovementRegisterStatus { + return (IMPROVEMENT_REGISTER_STATUSES as readonly string[]).includes(v); +} + +export function isImprovementLinkKind(v: string): v is ImprovementLinkKind { + return (IMPROVEMENT_LINK_KINDS as readonly string[]).includes(v); +} + +export function isImprovementValidationMethod( + v: string, +): v is ImprovementValidationMethod { + return (IMPROVEMENT_VALIDATION_METHODS as readonly string[]).includes(v); +} diff --git a/src/lib/scopes.ts b/src/lib/scopes.ts index b2c3071..cef8cb8 100644 --- a/src/lib/scopes.ts +++ b/src/lib/scopes.ts @@ -145,6 +145,16 @@ export const KNOWN_SCOPES = [ label: "Execute registered investigation commands, cancel in-flight runs, and save results as evidence", }, + { + value: "improvements:read", + label: + "Read the detection/control/process improvement register, suggestions, and dashboard", + }, + { + value: "improvements:write", + label: + "Create, link, validate, close, reopen, and sync external ticket refs on improvement register items", + }, ] as const; export type ScopeValue = (typeof KNOWN_SCOPES)[number]["value"];