From 5c85ae64a0dd5af79996cf32e397dbee588522fb Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:11:03 -0500 Subject: [PATCH] Cases: experimental first-class case object (#9198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board currently uses issues for execution, but longer-lived content work needs a separate object that can survive beyond a single task thread. > - The Cases subsystem adds an experimental, company-scoped record for content artifacts and their supporting metadata. > - The backend needs durable storage, API routes, revision history, issue linkage, and company-boundary enforcement before the UI can depend on Cases. > - The UI needs an opt-in navigation surface, list/detail views, reference chips, and issue-page context so operators can inspect Cases without making them the default workflow. > - The agent-facing skills need a contract for creating and updating Cases so automated content workflows can dogfood the feature. > - This pull request ships that experimental end-to-end path behind the `enableCases` flag. > - The benefit is a first-class place to collect content work, references, attachments, revisions, and related execution threads without polluting the core issue model. ## Linked Issues or Issue Description No public GitHub issue exists for this experimental feature. Feature request fields: ### Problem Content-oriented work such as release notes, announcements, docs, and campaigns can span many execution issues, which makes the final artifact hard to find and reason about after the execution thread moves on. ### Proposed solution Add an experimental Cases object that is company-scoped, linked to issues, queryable through the API, inspectable in the board UI, and writable by agent workflows through documented conventions. ### Alternatives considered Continue encoding content artifacts directly in issues or documents only. That keeps the data model smaller, but it does not give operators a stable artifact-centric view or a clean way to link related execution history. ### Roadmap alignment Checked `ROADMAP.md`; this PR does not duplicate an existing planned core roadmap item. ## What Changed - Added the `cases` data model, migration, schema exports, and experimental `enableCases` instance setting. - Added company-scoped Cases API routes for list/detail/update, issue links, revisions, children, activity events, annotations, attachments, and idempotent agent-oriented upserts. - Scoped case and issue lookup helpers before access checks so inaccessible cross-company identifiers resolve as not found rather than leaking existence. - Fixed case PATCH timestamp handling so non-status updates cannot overwrite `completedAt` from a stale pre-transaction row snapshot. - Moved Cases list type/status/project filters into the server request before the server-side limit is applied, including multi-select filters and no-project filtering. - Added backend route coverage for creation, updates, idempotency, issue linking, attribution, company-boundary enforcement, OpenAPI registration, list filtering, timestamp patch behavior, and inaccessible lookup regressions. - Added the experimental Cases UI surface: sidebar entry, gated routes, list filters/grouping, detail overview, activity, revisions, children, attachments, and issue-page case rail. - Added case reference rendering and company-prefixed case href generation so case links resolve directly inside the active company route. - Added Paperclip skill documentation for agent workflows that create or update Cases. - Wired release-content skills to emit Cases for dogfooding. - Rebased onto current `master` and renumbered the Cases migrations to `0143`/`0144` after the latest upstream migration sequence. ## Verification - Current PR head: `ecc13be0d`. - Rebased on current `master` (`606aa4f266`) and pushed to the existing PR branch. - `git diff --check origin/master...HEAD` — passed before the first update push; subsequent committed diffs were also checked with `git diff --check` before commit. - Guardrails checked: no `pnpm-lock.yaml` changes, no `.github/workflows` changes, and changed-file count is below the Greptile 100-file limit. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/cases-routes.test.ts src/__tests__/instance-settings-service.test.ts src/__tests__/openapi-routes.test.ts` — passed, 3 files / 26 tests before review-fix commits. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/cases-routes.test.ts` — passed after each server-side Greptile fix, latest 1 file / 15 tests. - `pnpm --filter @paperclipai/server typecheck` — passed after the timestamp and lookup fixes. - `pnpm --filter @paperclipai/ui exec vitest run src/pages/Cases.test.tsx src/pages/CaseDetail.test.tsx src/pages/CompanySkills.test.tsx src/App.cases-routing.test.tsx` — passed, 4 files / 30 tests before review-fix commits. - `pnpm --filter @paperclipai/ui exec vitest run src/pages/Cases.test.tsx` — passed after the list-filter fix, 1 file / 12 tests. - `pnpm --filter @paperclipai/ui typecheck` — passed after the list-filter fix. - `pnpm check:token-gates` — passed after UI changes. - Remote PR checks on head `ecc13be0d` are green: Paperclip CI, build, typecheck, test matrix, e2e, Canary Dry Run, policy, commit review, Superagent Security Scan, Socket, Snyk, and Greptile passed; Storybook visual regression is skipped and security-review is neutral. - Greptile Review: 5/5 confidence, zero unresolved Greptile threads. ## Risks - Medium feature risk because this introduces a new experimental domain object across database, server, shared contracts, skills, and UI. - The feature is gated behind `enableCases`, which limits default operator exposure while the model is exercised. - Case links now prefer company-prefixed hrefs; the unprefixed redirect remains for externally entered URLs. - Cases list filtering now sends multi-select filters to the server before limiting; the UI still applies the same local filters as a second pass for ancestor/context rows. - Migrations were renumbered on top of current master; the SQL uses guarded `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS` patterns where relevant for safer replay. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex in the Paperclip local coding environment was used for this PR curation, rebase verification, review-fix implementation, push, and PR description update. The runtime exposes tool use and shell execution; context-window size is not exposed by this Paperclip adapter. Several implementation commits also include AI co-author trailers recorded in git history. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I searched the GitHub PR list for similar PRs and confirmed this is not a duplicate - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Claude Opus 4.8 --- .../SKILL.md | 64 +- .agents/skills/release-changelog/SKILL.md | 65 +- .agents/skills/release/SKILL.md | 76 + .../src/migrations/0143_cases_foundation.sql | 248 +++ .../0144_case_document_annotations.sql | 31 + packages/db/src/migrations/meta/_journal.json | 14 + packages/db/src/schema/cases.ts | 168 ++ .../schema/document_annotation_comments.ts | 9 +- .../src/schema/document_annotation_threads.ts | 9 +- packages/db/src/schema/index.ts | 8 + .../shared/src/types/document-annotation.ts | 2 + packages/shared/src/types/instance.ts | 1 + packages/shared/src/validators/instance.ts | 1 + .../content/release-announcement/SKILL.md | 90 + .../skills-catalog/generated/catalog.json | 8 +- .../release-content-cases-contract.test.ts | 40 + server/src/__tests__/cases-routes.test.ts | 904 ++++++++++ .../instance-settings-service.test.ts | 1 + server/src/__tests__/openapi-routes.test.ts | 2 + server/src/app.ts | 2 + server/src/routes/cases.ts | 1539 +++++++++++++++++ server/src/services/document-annotations.ts | 341 ++++ server/src/services/instance-settings.ts | 2 + skills/paperclip/SKILL.md | 5 + skills/paperclip/references/cases.md | 295 ++++ ui/src/App.cases-routing.test.tsx | 172 ++ ui/src/App.tsx | 13 + ui/src/api/cases.ts | 332 ++++ ui/src/api/document-annotations.ts | 4 + ui/src/components/CaseActivityFeed.test.tsx | 122 ++ ui/src/components/CaseActivityFeed.tsx | 165 ++ ui/src/components/CaseAttachmentsGallery.tsx | 91 + ui/src/components/CaseChildrenTree.test.tsx | 109 ++ ui/src/components/CaseChildrenTree.tsx | 73 + ui/src/components/CaseFieldsPanel.test.tsx | 91 + ui/src/components/CaseFieldsPanel.tsx | 262 +++ ui/src/components/CaseIdentifierKey.tsx | 100 ++ ui/src/components/CaseRevisionRail.test.tsx | 125 ++ ui/src/components/CaseRevisionRail.tsx | 286 +++ ui/src/components/CasesExperimentalGate.tsx | 22 + ui/src/components/DocumentAnnotationPanel.tsx | 10 + ui/src/components/DocumentDiffModal.tsx | 11 +- ui/src/components/IssueCasesPanel.test.tsx | 104 ++ ui/src/components/IssueCasesPanel.tsx | 57 + ui/src/components/IssueChatThread.tsx | 10 +- .../IssueDocumentAnnotations.test.tsx | 30 +- .../components/IssueDocumentAnnotations.tsx | 4 + .../components/IssueDocumentsSection.test.tsx | 84 +- ui/src/components/IssueDocumentsSection.tsx | 245 ++- ui/src/components/IssueProperties.test.tsx | 1 + ui/src/components/MarkdownBody.tsx | 52 +- ui/src/components/Sidebar.tsx | 5 + .../issue-properties/IssueProperties.tsx | 7 + ui/src/context/LiveUpdatesProvider.test.ts | 41 + ui/src/context/LiveUpdatesProvider.tsx | 26 + ui/src/index.css | 3 + ui/src/lib/case-reference.test.ts | 79 + ui/src/lib/case-reference.ts | 109 ++ ui/src/lib/company-routes.ts | 21 + ui/src/lib/queryKeys.ts | 11 + ui/src/lib/router.tsx | 16 +- ui/src/lib/status-colors.ts | 5 + ui/src/pages/CaseDetail.test.tsx | 461 +++++ ui/src/pages/CaseDetail.tsx | 719 ++++++++ ui/src/pages/Cases.test.tsx | 599 +++++++ ui/src/pages/Cases.tsx | 1427 +++++++++++++++ ui/src/pages/CompanySkills.test.tsx | 34 +- ui/src/pages/CompanySkills.tsx | 360 +++- .../InstanceExperimentalSettings.test.tsx | 1 + ui/src/pages/InstanceExperimentalSettings.tsx | 26 + ui/src/pages/IssueDetail.tsx | 6 + 71 files changed, 10342 insertions(+), 114 deletions(-) create mode 100644 packages/db/src/migrations/0143_cases_foundation.sql create mode 100644 packages/db/src/migrations/0144_case_document_annotations.sql create mode 100644 packages/db/src/schema/cases.ts create mode 100644 packages/skills-catalog/src/release-content-cases-contract.test.ts create mode 100644 server/src/__tests__/cases-routes.test.ts create mode 100644 server/src/routes/cases.ts create mode 100644 skills/paperclip/references/cases.md create mode 100644 ui/src/App.cases-routing.test.tsx create mode 100644 ui/src/api/cases.ts create mode 100644 ui/src/components/CaseActivityFeed.test.tsx create mode 100644 ui/src/components/CaseActivityFeed.tsx create mode 100644 ui/src/components/CaseAttachmentsGallery.tsx create mode 100644 ui/src/components/CaseChildrenTree.test.tsx create mode 100644 ui/src/components/CaseChildrenTree.tsx create mode 100644 ui/src/components/CaseFieldsPanel.test.tsx create mode 100644 ui/src/components/CaseFieldsPanel.tsx create mode 100644 ui/src/components/CaseIdentifierKey.tsx create mode 100644 ui/src/components/CaseRevisionRail.test.tsx create mode 100644 ui/src/components/CaseRevisionRail.tsx create mode 100644 ui/src/components/CasesExperimentalGate.tsx create mode 100644 ui/src/components/IssueCasesPanel.test.tsx create mode 100644 ui/src/components/IssueCasesPanel.tsx create mode 100644 ui/src/lib/case-reference.test.ts create mode 100644 ui/src/lib/case-reference.ts create mode 100644 ui/src/pages/CaseDetail.test.tsx create mode 100644 ui/src/pages/CaseDetail.tsx create mode 100644 ui/src/pages/Cases.test.tsx create mode 100644 ui/src/pages/Cases.tsx diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md index df6c85925b..374f3a089d 100644 --- a/.agents/skills/release-changelog-discord-message/SKILL.md +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -43,6 +43,7 @@ current Paperclip work — not invented. A single fenced markdown code block, ready to paste into Discord. Attached as issue document key `discord_announcement` on the release issue, and pasted verbatim into a comment on that issue so the human can copy it out. +When Cases are enabled, also upsert the social child case described below. ```bash PUT /api/issues/{releaseIssueId}/documents/discord_announcement @@ -177,18 +178,71 @@ Mimic this register; do not invent a "professional" tone. 1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`. Use the version and contributor list from that file — never re-derive them. -2. Read the **release issue thread** (the one assigned to you that ran the +2. Resolve the parent `release` case with key `paperclip-release:vYYYY.MDD.P`. + If it does not exist and Cases are enabled, create it using the schema in + `.agents/skills/release-changelog/SKILL.md` before creating child cases. +3. Read the **release issue thread** (the one assigned to you that ran the release routine) — comments + linked issues + recent issues in the company are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes, not invented ones. -3. Re-read the three verbatim examples below — they're the canonical voice. -4. Draft the announcement using the template above. -5. PUT it as the `discord_announcement` document on the release issue (see +4. Re-read the three verbatim examples below — they're the canonical voice. +5. Draft the announcement using the template above. +6. PUT it as the `discord_announcement` document on the release issue (see "Output" above). If updating, send the latest `baseRevisionId`. -6. Post a comment on the release issue that includes the announcement inside a +7. Upsert the `tweet_storm` child case with `parentCaseId` set to the release + case id, then PUT its `body` document to the announcement body. +8. Post a comment on the release issue that includes the announcement inside a single fenced markdown code block, so dotta can copy-paste it into Discord without opening the document. +## Tweet Storm Case Schema + +Use this child case for the Discord/social announcement thread. The key must be +stable so retries update the same child case: + +```http +POST /api/companies/:companyId/cases +{ + "caseType": "tweet_storm", + "key": "paperclip-release:vYYYY.MDD.P:tweet-storm", + "title": "Paperclip vYYYY.MDD.P tweet storm", + "summary": "Social announcement thread for Paperclip vYYYY.MDD.P.", + "status": "in_review", + "parentCaseId": "", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "channel": "x", + "discord_source": true, + "post_count": 1, + "target_audience": ["operators", "contributors", "agent-company builders"], + "links": { + "release_notes": "https://github.com/paperclipai/paperclip/blob/master/releases/vYYYY.MDD.P.md", + "official_account": "https://x.com/papercliping" + }, + "review": { + "needs_human_copy_paste": true, + "approved_by": null + } + } +} +``` + +Then write the body document: + +```http +PUT /api/cases/:tweetStormCaseId/documents/body +{ + "title": "Paperclip vYYYY.MDD.P tweet storm body", + "format": "markdown", + "body": "", + "changeSummary": "Draft social announcement" +} +``` + +If updating an existing document, fetch the case and pass the latest +`baseRevisionId`. + Do not publish to Discord. This skill only prepares the artifact. ## Verbatim previous examples diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md index e9d9177f58..8bf662ed6b 100644 --- a/.agents/skills/release-changelog/SKILL.md +++ b/.agents/skills/release-changelog/SKILL.md @@ -23,6 +23,8 @@ intended release date (UTC) plus the next same-day stable patch slot. Output: - `releases/vYYYY.MDD.P.md` +- a `release` Case, upserted by `(caseType, key)` when Cases are enabled, with a + `body` document revision containing the changelog body Important rules: @@ -188,6 +190,66 @@ List contributors in alphabetical order by GitHub username (case-insensitive). If there are no contributors left after exclusions, then just skip this section and don't mention it. +## Step 5b — Upsert The Release Case + +After writing `releases/vYYYY.MDD.P.md`, emit or refresh the top-level release +case when the run has Paperclip API context. Use `skills/paperclip/references/cases.md` +as the API contract. If the API returns `403 Cases are disabled`, report that +Cases must be enabled and continue with the changelog file only. + +Request: + +```http +POST /api/companies/:companyId/cases +{ + "caseType": "release", + "key": "paperclip-release:vYYYY.MDD.P", + "title": "Paperclip vYYYY.MDD.P release", + "summary": "Stable Paperclip release notes for vYYYY.MDD.P.", + "status": "in_progress", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "release_date": "YYYY-MM-DD", + "release_patch": 0, + "stable": true, + "channels": ["changelog", "blog_post", "tweet_storm"], + "artifacts": { + "changelog_path": "releases/vYYYY.MDD.P.md", + "github_release_url": null + }, + "verification": { + "typecheck": "unknown", + "tests": "unknown", + "build": "unknown", + "smoke": "unknown" + }, + "notes": null + } +} +``` + +This fields schema deliberately exercises every generic field value type: +string, number, boolean, array, object, and null. Keep the keys stable across +runs and send the full object on every upsert because fields are replaced, not +deep-merged. + +Then write the changelog into the case body document: + +```http +PUT /api/cases/:releaseCaseId/documents/body +{ + "title": "Paperclip vYYYY.MDD.P changelog", + "format": "markdown", + "body": "", + "changeSummary": "Initial stable changelog" +} +``` + +If updating an existing body document, fetch the case first and pass the latest +`baseRevisionId`. On `409 stale_base_revision`, refetch, merge intentionally, +and retry once. + ## Step 6 — Review Before Release Before handing it off: @@ -195,6 +257,7 @@ Before handing it off: 1. confirm the H1 heading is `# Paperclip {version}` (e.g. `# Paperclip v2026.618.0`) with the stable version only 2. confirm there is no `-canary` language in the title or filename 3. confirm any breaking changes have an upgrade path -4. present the draft for human sign-off +4. confirm the `release` case exists or explain why Cases were unavailable +5. present the draft for human sign-off This skill never publishes anything. It only prepares the stable changelog artifact. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 8f8e7ca25d..55a72248e2 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -18,6 +18,8 @@ This skill coordinates: - manual stable promotion from a chosen source ref - GitHub Release creation - website / announcement follow-up tasks +- release-content Cases dogfood: a top-level `release` case with child + `blog_post` and `tweet_storm` cases, all linked to the release issue/run ## Trigger @@ -214,6 +216,78 @@ Create or verify follow-up work for: These should reference the stable release, not the canary. +## Step 8 — Emit Release-Content Cases + +When Cases are enabled, every stable release-content run must materialize a +deterministic case tree. This is part of the release dogfood path, not an +optional artifact. If the API returns `403 Cases are disabled`, stop and report +that the operator must enable `experimental.enableCases`. + +Use the current release issue's `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, +`PAPERCLIP_API_KEY`, and `PAPERCLIP_RUN_ID`. Include `X-Paperclip-Run-Id` on +all writes so the case activity feed can attribute the run back to the issue. + +Create or upsert the parent `release` case first: + +```http +POST /api/companies/:companyId/cases +{ + "caseType": "release", + "key": "paperclip-release:vYYYY.MDD.P", + "title": "Paperclip vYYYY.MDD.P release", + "summary": "Stable release content package for Paperclip vYYYY.MDD.P.", + "status": "in_progress", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "release_date": "YYYY-MM-DD", + "source_ref": "git-sha-or-ref", + "stable": true, + "channels": ["changelog", "blog_post", "tweet_storm"], + "artifacts": { + "changelog_path": "releases/vYYYY.MDD.P.md", + "github_release_url": null + }, + "verification": { + "typecheck": "unknown", + "tests": "unknown", + "build": "unknown", + "smoke": "unknown" + }, + "notes": null + } +} +``` + +The `fields` schema intentionally uses all generic JSON value types: strings, +numbers, booleans, arrays, objects, and nulls. Send the complete fields object on +each upsert because case fields replace as a whole object. + +Write the parent body document immediately after the upsert: + +```http +PUT /api/cases/:releaseCaseId/documents/body +{ + "title": "Paperclip vYYYY.MDD.P release body", + "format": "markdown", + "body": "# Paperclip vYYYY.MDD.P\n\nRelease summary and links...", + "changeSummary": "Initial release case body" +} +``` + +Then create or upsert these child cases with `parentCaseId` set to the release +case id: + +- `blog_post`, key `paperclip-release:vYYYY.MDD.P:blog-post`, status + `in_progress`, body document key `body` +- `tweet_storm`, key `paperclip-release:vYYYY.MDD.P:tweet-storm`, status + `in_progress`, body document key `body` + +Use deterministic keys exactly so rerunning the release-content flow upserts the +same three cases instead of duplicating them. After the child body documents are +written, list the resulting case identifiers and links in the release issue and +in the parent acceptance issue when one exists. + ## Failure Handling If the canary is bad: @@ -244,4 +318,6 @@ When the skill completes, provide: - smoke-test status - git tag / GitHub Release status - website / announcement follow-up status +- release-content case tree links: parent `release` case plus `blog_post` and + `tweet_storm` children - rollback recommendation if anything is still partially complete diff --git a/packages/db/src/migrations/0143_cases_foundation.sql b/packages/db/src/migrations/0143_cases_foundation.sql new file mode 100644 index 0000000000..4b88cc97a7 --- /dev/null +++ b/packages/db/src/migrations/0143_cases_foundation.sql @@ -0,0 +1,248 @@ +CREATE TABLE IF NOT EXISTS "cases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "project_id" uuid, + "case_number" integer NOT NULL, + "identifier" text NOT NULL, + "case_type" text NOT NULL, + "key" text, + "title" text NOT NULL, + "summary" text, + "status" text DEFAULT 'draft' NOT NULL, + "fields" jsonb DEFAULT '{}'::jsonb NOT NULL, + "parent_case_id" uuid, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "cases_status_check" CHECK ("cases"."status" in ('draft', 'in_progress', 'in_review', 'approved', 'done', 'cancelled')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_issue_links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "role" text NOT NULL, + "created_by_run_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "case_issue_links_role_check" CHECK ("case_issue_links"."role" in ('origin', 'work', 'reference')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "kind" text NOT NULL, + "actor_type" text NOT NULL, + "actor_user_id" text, + "actor_agent_id" uuid, + "run_id" uuid, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "case_events_kind_check" CHECK ("case_events"."kind" in ( + 'created', + 'updated', + 'fields_changed', + 'status_changed', + 'issue_linked', + 'issue_unlinked', + 'document_revised', + 'child_linked', + 'attachment_added', + 'label_added', + 'label_removed' + )), + CONSTRAINT "case_events_actor_type_check" CHECK ("case_events"."actor_type" in ('user', 'agent', 'system')) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_labels" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "label_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "case_attachments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "case_id" uuid NOT NULL, + "asset_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_parent_case_id_cases_id_fk" FOREIGN KEY ("parent_case_id") REFERENCES "public"."cases"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "cases" ADD CONSTRAINT "cases_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_issue_links" ADD CONSTRAINT "case_issue_links_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_issue_links" ADD CONSTRAINT "case_issue_links_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_issue_links" ADD CONSTRAINT "case_issue_links_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_events" ADD CONSTRAINT "case_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_events" ADD CONSTRAINT "case_events_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_events" ADD CONSTRAINT "case_events_actor_agent_id_agents_id_fk" FOREIGN KEY ("actor_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_documents" ADD CONSTRAINT "case_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_documents" ADD CONSTRAINT "case_documents_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_documents" ADD CONSTRAINT "case_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_labels" ADD CONSTRAINT "case_labels_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_labels" ADD CONSTRAINT "case_labels_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_labels" ADD CONSTRAINT "case_labels_label_id_labels_id_fk" FOREIGN KEY ("label_id") REFERENCES "public"."labels"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_attachments" ADD CONSTRAINT "case_attachments_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_attachments" ADD CONSTRAINT "case_attachments_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "case_attachments" ADD CONSTRAINT "case_attachments_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "cases_company_case_number_uq" ON "cases" USING btree ("company_id","case_number"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "cases_identifier_uq" ON "cases" USING btree ("identifier"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "cases_company_type_key_uq" ON "cases" USING btree ("company_id","case_type","key"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_company_status_idx" ON "cases" USING btree ("company_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_company_type_idx" ON "cases" USING btree ("company_id","case_type"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_company_project_idx" ON "cases" USING btree ("company_id","project_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_parent_idx" ON "cases" USING btree ("parent_case_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_title_search_idx" ON "cases" USING gin ("title" gin_trgm_ops); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_identifier_search_idx" ON "cases" USING gin ("identifier" gin_trgm_ops); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "cases_summary_search_idx" ON "cases" USING gin ("summary" gin_trgm_ops); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_issue_links_case_issue_uq" ON "case_issue_links" USING btree ("case_id","issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_issue_links_company_case_idx" ON "case_issue_links" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_issue_links_issue_idx" ON "case_issue_links" USING btree ("issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_events_case_created_idx" ON "case_events" USING btree ("case_id","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_events_company_case_idx" ON "case_events" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_documents_company_case_key_uq" ON "case_documents" USING btree ("company_id","case_id","key"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_documents_document_uq" ON "case_documents" USING btree ("document_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_documents_company_case_updated_idx" ON "case_documents" USING btree ("company_id","case_id","updated_at"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_labels_case_label_uq" ON "case_labels" USING btree ("case_id","label_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_labels_company_case_idx" ON "case_labels" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_labels_label_idx" ON "case_labels" USING btree ("label_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "case_attachments_company_case_idx" ON "case_attachments" USING btree ("company_id","case_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "case_attachments_asset_uq" ON "case_attachments" USING btree ("asset_id"); diff --git a/packages/db/src/migrations/0144_case_document_annotations.sql b/packages/db/src/migrations/0144_case_document_annotations.sql new file mode 100644 index 0000000000..144d708a4c --- /dev/null +++ b/packages/db/src/migrations/0144_case_document_annotations.sql @@ -0,0 +1,31 @@ +ALTER TABLE "document_annotation_threads" ADD COLUMN IF NOT EXISTS "case_id" uuid; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" ADD COLUMN IF NOT EXISTS "case_id" uuid; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" DROP CONSTRAINT IF EXISTS "document_annotation_threads_owner_check"; +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" DROP CONSTRAINT IF EXISTS "document_annotation_threads_exactly_one_owner_chk"; +--> statement-breakpoint +ALTER TABLE "document_annotation_threads" ADD CONSTRAINT "document_annotation_threads_exactly_one_owner_chk" CHECK (num_nonnulls("issue_id", "routine_id", "case_id") = 1); +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" DROP CONSTRAINT IF EXISTS "document_annotation_comments_owner_check"; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" DROP CONSTRAINT IF EXISTS "document_annotation_comments_exactly_one_owner_chk"; +--> statement-breakpoint +ALTER TABLE "document_annotation_comments" ADD CONSTRAINT "document_annotation_comments_exactly_one_owner_chk" CHECK (num_nonnulls("issue_id", "routine_id", "case_id") = 1); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_annotation_threads_company_case_status_idx" ON "document_annotation_threads" USING btree ("company_id","case_id","status"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "document_annotation_comments_company_case_created_at_idx" ON "document_annotation_comments" USING btree ("company_id","case_id","created_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 6c793f8906..fe570bd9dc 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -988,6 +988,20 @@ "when": 1783555301100, "tag": "0142_company_search_sort_indexes", "breakpoints": true + }, + { + "idx": 143, + "version": "7", + "when": 1783457051766, + "tag": "0143_cases_foundation", + "breakpoints": true + }, + { + "idx": 144, + "version": "7", + "when": 1783520000000, + "tag": "0144_case_document_annotations", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/cases.ts b/packages/db/src/schema/cases.ts new file mode 100644 index 0000000000..0a45029ff6 --- /dev/null +++ b/packages/db/src/schema/cases.ts @@ -0,0 +1,168 @@ +import { sql } from "drizzle-orm"; +import { + type AnyPgColumn, + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { assets } from "./assets.js"; +import { companies } from "./companies.js"; +import { documents } from "./documents.js"; +import { issues } from "./issues.js"; +import { labels } from "./labels.js"; +import { projects } from "./projects.js"; + +export const cases = pgTable( + "cases", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + caseNumber: integer("case_number").notNull(), + identifier: text("identifier").notNull(), + caseType: text("case_type").notNull(), + key: text("key"), + title: text("title").notNull(), + summary: text("summary"), + status: text("status").notNull().default("draft"), + fields: jsonb("fields").$type>().notNull().default({}), + parentCaseId: uuid("parent_case_id").references((): AnyPgColumn => cases.id, { onDelete: "set null" }), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCaseNumberUq: uniqueIndex("cases_company_case_number_uq").on(table.companyId, table.caseNumber), + identifierUq: uniqueIndex("cases_identifier_uq").on(table.identifier), + companyTypeKeyUq: uniqueIndex("cases_company_type_key_uq").on(table.companyId, table.caseType, table.key), + companyStatusIdx: index("cases_company_status_idx").on(table.companyId, table.status), + companyTypeIdx: index("cases_company_type_idx").on(table.companyId, table.caseType), + companyProjectIdx: index("cases_company_project_idx").on(table.companyId, table.projectId), + parentIdx: index("cases_parent_idx").on(table.parentCaseId), + titleSearchIdx: index("cases_title_search_idx").using("gin", table.title.op("gin_trgm_ops")), + identifierSearchIdx: index("cases_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")), + summarySearchIdx: index("cases_summary_search_idx").using("gin", table.summary.op("gin_trgm_ops")), + statusCheck: check( + "cases_status_check", + sql`${table.status} in ('draft', 'in_progress', 'in_review', 'approved', 'done', 'cancelled')`, + ), + }), +); + +export const caseIssueLinks = pgTable( + "case_issue_links", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + role: text("role").notNull(), + createdByRunId: uuid("created_by_run_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + caseIssueUq: uniqueIndex("case_issue_links_case_issue_uq").on(table.caseId, table.issueId), + companyCaseIdx: index("case_issue_links_company_case_idx").on(table.companyId, table.caseId), + issueIdx: index("case_issue_links_issue_idx").on(table.issueId), + roleCheck: check("case_issue_links_role_check", sql`${table.role} in ('origin', 'work', 'reference')`), + }), +); + +export const caseEvents = pgTable( + "case_events", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + kind: text("kind").notNull(), + actorType: text("actor_type").notNull(), + actorUserId: text("actor_user_id"), + actorAgentId: uuid("actor_agent_id").references(() => agents.id, { onDelete: "set null" }), + runId: uuid("run_id"), + payload: jsonb("payload").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + caseCreatedIdx: index("case_events_case_created_idx").on(table.caseId, table.createdAt), + companyCaseIdx: index("case_events_company_case_idx").on(table.companyId, table.caseId), + kindCheck: check( + "case_events_kind_check", + sql`${table.kind} in ( + 'created', + 'updated', + 'fields_changed', + 'status_changed', + 'issue_linked', + 'issue_unlinked', + 'document_revised', + 'child_linked', + 'attachment_added', + 'label_added', + 'label_removed' + )`, + ), + actorTypeCheck: check("case_events_actor_type_check", sql`${table.actorType} in ('user', 'agent', 'system')`), + }), +); + +export const caseDocuments = pgTable( + "case_documents", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + key: text("key").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCaseKeyUq: uniqueIndex("case_documents_company_case_key_uq").on(table.companyId, table.caseId, table.key), + documentUq: uniqueIndex("case_documents_document_uq").on(table.documentId), + companyCaseUpdatedIdx: index("case_documents_company_case_updated_idx").on(table.companyId, table.caseId, table.updatedAt), + }), +); + +export const caseLabels = pgTable( + "case_labels", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + labelId: uuid("label_id").notNull().references(() => labels.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + caseLabelUq: uniqueIndex("case_labels_case_label_uq").on(table.caseId, table.labelId), + companyCaseIdx: index("case_labels_company_case_idx").on(table.companyId, table.caseId), + labelIdx: index("case_labels_label_idx").on(table.labelId), + }), +); + +export const caseAttachments = pgTable( + "case_attachments", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + caseId: uuid("case_id").notNull().references(() => cases.id, { onDelete: "cascade" }), + assetId: uuid("asset_id").notNull().references(() => assets.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCaseIdx: index("case_attachments_company_case_idx").on(table.companyId, table.caseId), + assetUq: uniqueIndex("case_attachments_asset_uq").on(table.assetId), + }), +); diff --git a/packages/db/src/schema/document_annotation_comments.ts b/packages/db/src/schema/document_annotation_comments.ts index 082a1acb6e..e6fa7da205 100644 --- a/packages/db/src/schema/document_annotation_comments.ts +++ b/packages/db/src/schema/document_annotation_comments.ts @@ -9,6 +9,7 @@ import { heartbeatRuns } from "./heartbeat_runs.js"; import { issueComments } from "./issue_comments.js"; import { issues } from "./issues.js"; import { routines } from "./routines.js"; +import { cases } from "./cases.js"; export const documentAnnotationComments = pgTable( "document_annotation_comments", @@ -18,6 +19,7 @@ export const documentAnnotationComments = pgTable( threadId: uuid("thread_id").notNull().references(() => documentAnnotationThreads.id, { onDelete: "cascade" }), issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), + caseId: uuid("case_id").references(() => cases.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), body: text("body").notNull(), authorType: text("author_type").$type().notNull(), @@ -45,6 +47,11 @@ export const documentAnnotationComments = pgTable( table.routineId, table.createdAt, ), + companyCaseCreatedAtIdx: index("document_annotation_comments_company_case_created_at_idx").on( + table.companyId, + table.caseId, + table.createdAt, + ), companyDocumentCreatedAtIdx: index("document_annotation_comments_company_document_created_at_idx").on( table.companyId, table.documentId, @@ -54,7 +61,7 @@ export const documentAnnotationComments = pgTable( bodySearchIdx: index("document_annotation_comments_body_search_idx").using("gin", table.body.op("gin_trgm_ops")), exactlyOneOwnerChk: check( "document_annotation_comments_exactly_one_owner_chk", - sql`num_nonnulls(${table.issueId}, ${table.routineId}) = 1`, + sql`num_nonnulls(${table.issueId}, ${table.routineId}, ${table.caseId}) = 1`, ), }), ); diff --git a/packages/db/src/schema/document_annotation_threads.ts b/packages/db/src/schema/document_annotation_threads.ts index d09c40d28d..b3aae3ee92 100644 --- a/packages/db/src/schema/document_annotation_threads.ts +++ b/packages/db/src/schema/document_annotation_threads.ts @@ -12,6 +12,7 @@ import { documentRevisions } from "./document_revisions.js"; import { documents } from "./documents.js"; import { issues } from "./issues.js"; import { routines } from "./routines.js"; +import { cases } from "./cases.js"; export const documentAnnotationThreads = pgTable( "document_annotation_threads", @@ -20,6 +21,7 @@ export const documentAnnotationThreads = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), issueId: uuid("issue_id").references(() => issues.id, { onDelete: "cascade" }), routineId: uuid("routine_id").references(() => routines.id, { onDelete: "cascade" }), + caseId: uuid("case_id").references(() => cases.id, { onDelete: "cascade" }), documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), documentKey: text("document_key").notNull(), status: text("status").$type().notNull().default("open"), @@ -64,6 +66,11 @@ export const documentAnnotationThreads = pgTable( table.routineId, table.status, ), + companyCaseStatusIdx: index("document_annotation_threads_company_case_status_idx").on( + table.companyId, + table.caseId, + table.status, + ), companyCurrentRevisionOpenIdx: index("document_annotation_threads_company_current_revision_open_idx").on( table.companyId, table.documentId, @@ -76,7 +83,7 @@ export const documentAnnotationThreads = pgTable( ), exactlyOneOwnerChk: check( "document_annotation_threads_exactly_one_owner_chk", - sql`num_nonnulls(${table.issueId}, ${table.routineId}) = 1`, + sql`num_nonnulls(${table.issueId}, ${table.routineId}, ${table.caseId}) = 1`, ), }), ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index aadaaf63db..735fd4e621 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -44,6 +44,14 @@ export { externalObjectMentions } from "./external_object_mentions.js"; export { issueRelations } from "./issue_relations.js"; export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js"; export { pipelines, pipelineStages, pipelineTransitions } from "./pipelines.js"; +export { + cases, + caseAttachments, + caseDocuments, + caseEvents, + caseIssueLinks, + caseLabels, +} from "./cases.js"; export { pipelineCases, pipelineCaseIssueLinks, diff --git a/packages/shared/src/types/document-annotation.ts b/packages/shared/src/types/document-annotation.ts index 7c57fdf3e1..43a193cd75 100644 --- a/packages/shared/src/types/document-annotation.ts +++ b/packages/shared/src/types/document-annotation.ts @@ -60,6 +60,7 @@ export interface DocumentAnnotationThread { companyId: string; issueId: string | null; routineId?: string | null; + caseId?: string | null; documentId: string; documentKey: string; status: DocumentAnnotationThreadStatus; @@ -92,6 +93,7 @@ export interface DocumentAnnotationComment { threadId: string; issueId: string | null; routineId?: string | null; + caseId?: string | null; documentId: string; body: string; authorType: IssueCommentAuthorType; diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 10a72e0c6d..b34581ba74 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -49,6 +49,7 @@ export interface InstanceExperimentalSettings { enableIsolatedWorkspaces: boolean; enableStreamlinedLeftNavigation: boolean; enablePipelines: boolean; + enableCases: boolean; enableConferenceRoomChat: boolean; enableTaskWatchdogs: boolean; enableIssuePlanDecompositions: boolean; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 8798e19f69..612e5d1a19 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -43,6 +43,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), enablePipelines: z.boolean().default(false), + enableCases: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), enableTaskWatchdogs: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), diff --git a/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md b/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md index 8870aa6580..88c8a965ee 100644 --- a/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md +++ b/packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md @@ -28,6 +28,96 @@ Write the channel-appropriate announcement for a release without churn. Differen - An internal-only change with no user impact. Update internal docs; do not announce. - The release is incomplete (still in active development). Wait until it ships, even if marketing wants the post. +## Paperclip Cases output + +When this skill runs inside Paperclip and `experimental.enableCases` is enabled, +emit durable release-content cases before handing off the copy. Cases preserve +the inspectable output; the issue coordinates the work. + +Use `skills/paperclip/references/cases.md` for the API contract. Include +`X-Paperclip-Run-Id` on writes when `PAPERCLIP_RUN_ID` is set. If the API returns +`403 Cases are disabled`, report that limitation and continue with the requested +copy artifact. + +Upsert the parent release case first when it does not already exist: + +```json +{ + "caseType": "release", + "key": "paperclip-release:vYYYY.MDD.P", + "title": "Paperclip vYYYY.MDD.P release", + "status": "in_progress", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "release_date": "YYYY-MM-DD", + "release_patch": 0, + "stable": true, + "channels": ["blog_post", "tweet_storm"], + "artifacts": { + "changelog_path": "releases/vYYYY.MDD.P.md", + "publish_url": null + } + } +} +``` + +For a dev blog, upsert a child case with `parentCaseId` set to the release case: + +```json +{ + "caseType": "blog_post", + "key": "paperclip-release:vYYYY.MDD.P:blog-post", + "title": "Paperclip vYYYY.MDD.P launch post", + "status": "in_review", + "parentCaseId": "", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "slug": "paperclip-vYYYY-MDD-P", + "word_count_target": 650, + "target_audience": ["operators", "developers"], + "requires_screenshot": false, + "links": { + "release_notes": "releases/vYYYY.MDD.P.md", + "publish_url": null + }, + "sections": ["hook", "whats_new", "upgrade", "whats_next"] + } +} +``` + +For social output, upsert a sibling child case: + +```json +{ + "caseType": "tweet_storm", + "key": "paperclip-release:vYYYY.MDD.P:tweet-storm", + "title": "Paperclip vYYYY.MDD.P tweet storm", + "status": "in_review", + "parentCaseId": "", + "fields": { + "schema_version": 1, + "version": "vYYYY.MDD.P", + "post_count": 1, + "channel": "x", + "target_audience": ["operators", "contributors"], + "links": { + "release_notes": "releases/vYYYY.MDD.P.md", + "publish_url": null + }, + "review": { + "needs_human_copy_paste": true, + "approved_by": null + } + } +} +``` + +Write the produced copy to `PUT /api/cases/:caseId/documents/body` with +`format: "markdown"` and a `changeSummary`. Fetch the latest document revision +and pass `baseRevisionId` when updating an existing body document. + ## Determine the audience and channel first | Audience | Best channel | Tone | diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json index 791750aa2a..aa64ff9229 100644 --- a/packages/skills-catalog/generated/catalog.json +++ b/packages/skills-catalog/generated/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "packageName": "@paperclipai/skills-catalog", "packageVersion": "0.3.1", - "generatedAt": "2026-07-09T15:02:07.574Z", + "generatedAt": "2026-07-10T00:27:11.902Z", "skills": [ { "id": "paperclipai:bundled:docs:doc-maintenance", @@ -409,11 +409,11 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 4416, - "sha256": "062810ac34e9edc89efa701fec2eee60f16949d1944cc2cae49803cb91e8cbf4" + "sizeBytes": 6945, + "sha256": "d745cca96350518fabae14f5c407d779e1df2c5d091b5b4a5c55bb888b569e5f" } ], - "contentHash": "sha256:f22a9ed696e6614c6db2757a149f48b3295e81f78c27d065d9cb164cf4f8a9bd" + "contentHash": "sha256:efe8ea89b552df95222609867c9c75f1b40e16f457d34e7e4a124f54daa33efa" }, { "id": "paperclipai:optional:finance:ramp", diff --git a/packages/skills-catalog/src/release-content-cases-contract.test.ts b/packages/skills-catalog/src/release-content-cases-contract.test.ts new file mode 100644 index 0000000000..64bd29a337 --- /dev/null +++ b/packages/skills-catalog/src/release-content-cases-contract.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function readRepoFile(path: string) { + return readFileSync(new URL(`../../../${path}`, import.meta.url), "utf8"); +} + +describe("release-content Cases contract", () => { + it("keeps release-content skills wired to emit the required case tree", () => { + const release = readRepoFile(".agents/skills/release/SKILL.md"); + const changelog = readRepoFile(".agents/skills/release-changelog/SKILL.md"); + const discord = readRepoFile(".agents/skills/release-changelog-discord-message/SKILL.md"); + const announcement = readRepoFile("packages/skills-catalog/catalog/optional/content/release-announcement/SKILL.md"); + const combined = [release, changelog, discord, announcement].join("\n"); + + for (const required of [ + "\"caseType\": \"release\"", + "\"caseType\": \"blog_post\"", + "\"caseType\": \"tweet_storm\"", + "\"parentCaseId\"", + "PUT /api/cases/:caseId/documents/body", + "paperclip-release:vYYYY.MDD.P", + "X-Paperclip-Run-Id", + ]) { + expect(combined).toContain(required); + } + + expect(release).toContain("same three cases instead of duplicating them"); + expect(changelog).toContain("\"release_patch\": 0"); + expect(changelog).toContain("\"stable\": true"); + expect(changelog).toContain("\"channels\": [\"changelog\", \"blog_post\", \"tweet_storm\"]"); + expect(changelog).toContain("\"artifacts\""); + expect(changelog).toContain("\"verification\""); + expect(changelog).toContain("\"notes\": null"); + expect(announcement).toContain("\"word_count_target\": 650"); + expect(announcement).toContain("\"requires_screenshot\": false"); + expect(discord).toContain("\"needs_human_copy_paste\": true"); + expect(discord).toContain("\"approved_by\": null"); + }); +}); diff --git a/server/src/__tests__/cases-routes.test.ts b/server/src/__tests__/cases-routes.test.ts new file mode 100644 index 0000000000..d758670c7f --- /dev/null +++ b/server/src/__tests__/cases-routes.test.ts @@ -0,0 +1,904 @@ +import { createHash, randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + assets, + caseAttachments, + caseDocuments, + caseEvents, + caseIssueLinks, + caseLabels, + cases, + companies, + createDb, + documentAnnotationComments, + documentAnnotationThreads, + documents, + documentRevisions, + heartbeatRuns, + instanceSettings, + issues, + labels, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { actorMiddleware } from "../middleware/auth.js"; +import { createLocalAgentJwt } from "../agent-auth-jwt.js"; +import { buildCasePatchUpdateValues, caseRoutes } from "../routes/cases.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import type { StorageService } from "../storage/types.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe.sequential : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres cases route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("cases routes", () => { + it("omits completedAt from non-status case patches", () => { + const now = new Date("2026-07-10T00:00:00.000Z"); + const completedAt = new Date("2026-07-09T00:00:00.000Z"); + + expect(buildCasePatchUpdateValues({ title: "Rename" }, { status: "todo", completedAt: null }, now)).not.toHaveProperty("completedAt"); + expect(buildCasePatchUpdateValues({ title: "Rename" }, { status: "done", completedAt }, now)).not.toHaveProperty("completedAt"); + + const statusPatch = buildCasePatchUpdateValues({ status: "done" }, { status: "todo", completedAt: null }, now); + expect(statusPatch).toHaveProperty("completedAt"); + expect(statusPatch.completedAt).toBeInstanceOf(Date); + }); + + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const previousAgentJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + + const storage: StorageService = { + provider: "local_disk", + async putFile(input) { + return { + provider: "local_disk", + objectKey: `${input.namespace}/${randomUUID()}`, + contentType: input.contentType, + byteSize: input.body.length, + sha256: createHash("sha256").update(input.body).digest("hex"), + originalFilename: input.originalFilename, + }; + }, + async getObject() { + throw new Error("not used"); + }, + async headObject() { + return { exists: false }; + }, + async deleteObject() {}, + }; + + beforeAll(async () => { + process.env.PAPERCLIP_AGENT_JWT_SECRET = "cases-routes-test-secret"; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-cases-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(documentAnnotationComments); + await db.delete(documentAnnotationThreads); + await db.delete(caseAttachments); + await db.delete(caseLabels); + await db.delete(caseDocuments); + await db.delete(caseIssueLinks); + await db.delete(caseEvents); + await db.delete(cases); + await db.delete(documentRevisions); + await db.delete(documents); + await db.delete(assets); + await db.delete(labels); + await db.delete(issues); + await db.delete(heartbeatRuns); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + await db.delete(instanceSettings); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + if (previousAgentJwtSecret === undefined) { + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + } else { + process.env.PAPERCLIP_AGENT_JWT_SECRET = previousAgentJwtSecret; + } + }); + + function app(actor: Express.Request["actor"]) { + const instance = express(); + instance.use(express.json()); + instance.use((req, _res, next) => { + req.actor = actor; + next(); + }); + instance.use("/api", caseRoutes(db, storage)); + instance.use(errorHandler); + return instance; + } + + function authenticatedApp() { + const instance = express(); + instance.use(express.json()); + instance.use(actorMiddleware(db, { deploymentMode: "authenticated" })); + instance.use("/api", caseRoutes(db, storage)); + instance.use(errorHandler); + return instance; + } + + async function enableCases() { + await instanceSettingsService(db).updateExperimental({ enableCases: true }); + } + + async function seedCompany(prefix = "CASE") { + const [company] = await db.insert(companies).values({ + name: `${prefix} Co`, + issuePrefix: `${prefix}${randomUUID().replace(/-/g, "").slice(0, 4)}`, + }).returning(); + return company!; + } + + async function seedAgent(companyId: string) { + const [agent] = await db.insert(agents).values({ + companyId, + name: "Cases Agent", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }).returning(); + return agent!; + } + + const boardActor: Express.Request["actor"] = { + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + }; + + it("gates every case route when enableCases is off", async () => { + const company = await seedCompany("OFF"); + const [caseRow] = await db.insert(cases).values({ + companyId: company.id, + caseNumber: 1, + identifier: `${company.issuePrefix}-C1`, + caseType: "bug", + title: "Hidden case", + }).returning(); + const http = request(app(boardActor)); + + await http.get(`/api/companies/${company.id}/cases`).expect(403); + await http.post(`/api/companies/${company.id}/cases`).send({ caseType: "bug", title: "Bug" }).expect(403); + await http.get(`/api/cases/${caseRow!.id}`).expect(403); + await http.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(403); + await http.put(`/api/cases/${caseRow!.id}/documents/body`).send({ body: "Body" }).expect(403); + await http.get(`/api/cases/${caseRow!.id}/documents/body/annotations`).expect(403); + await http.post(`/api/cases/${caseRow!.id}/links`).send({ issueId: randomUUID(), role: "work" }).expect(403); + await http.post(`/api/cases/${caseRow!.id}/attachments`).attach("file", Buffer.from("x"), "x.txt").expect(403); + await http.get(`/api/cases/${caseRow!.id}/events`).expect(403); + }); + + it("falls through shared /cases paths to later routers when the id is not a Cases row", async () => { + // Pipelines mounts its own /cases/:caseId routes after caseRoutes in app.ts; + // pipeline case ids must reach that router regardless of the enableCases flag. + const instance = express(); + instance.use(express.json()); + instance.use((req, _res, next) => { + req.actor = boardActor; + next(); + }); + instance.use("/api", caseRoutes(db, storage)); + const pipelinesStandIn = express.Router(); + pipelinesStandIn.get("/cases/:caseId", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.patch("/cases/:caseId", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.put("/cases/:caseId/documents/:key", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.get("/cases/:caseId/documents/:key/revisions", (_req, res) => res.json({ handledBy: "pipelines" })); + pipelinesStandIn.get("/cases/:caseId/events", (_req, res) => res.json({ handledBy: "pipelines" })); + instance.use("/api", pipelinesStandIn); + instance.use(errorHandler); + const http = request(instance); + + const foreignId = randomUUID(); + // Flag off: non-Cases ids are not blocked by the Cases gate. + await http.get(`/api/cases/${foreignId}`).expect(200, { handledBy: "pipelines" }); + // Body is not validated against Cases schemas before falling through. + await http.patch(`/api/cases/${foreignId}`).send({ stageKey: "review" }).expect(200, { handledBy: "pipelines" }); + await http.put(`/api/cases/${foreignId}/documents/body`).send({ markdown: "x" }).expect(200, { handledBy: "pipelines" }); + await http.get(`/api/cases/${foreignId}/documents/body/revisions`).expect(200, { handledBy: "pipelines" }); + await http.get(`/api/cases/${foreignId}/events`).expect(200, { handledBy: "pipelines" }); + + // Flag on: real Cases rows are still handled by the cases router, unknown ids still fall through. + await enableCases(); + const company = await seedCompany("FALL"); + const [caseRow] = await db.insert(cases).values({ + companyId: company.id, + caseNumber: 1, + identifier: `${company.issuePrefix}-C1`, + caseType: "bug", + title: "Ours", + }).returning(); + const detail = await http.get(`/api/cases/${caseRow!.id}`).expect(200); + expect(detail.body.identifier).toBe(caseRow!.identifier); + await http.get(`/api/cases/${foreignId}`).expect(200, { handledBy: "pipelines" }); + }); + + it("creates cases and upserts idempotently by type and key", async () => { + await enableCases(); + const company = await seedCompany("UPS"); + const http = request(app(boardActor)); + + const first = await http + .post(`/api/companies/${company.id}/cases`) + .send({ + caseType: "security", + key: "CVE-1", + title: "Investigate report", + fields: { severity: "high" }, + }) + .expect(201); + const second = await http + .post(`/api/companies/${company.id}/cases`) + .send({ + caseType: "security", + key: "CVE-1", + title: "Investigate report again", + fields: { severity: "critical" }, + }) + .expect(200); + + expect(second.body.id).toBe(first.body.id); + expect(first.body.identifier).toBe(`${company.issuePrefix.toUpperCase()}-C1`); + const all = await db.select().from(cases); + expect(all).toHaveLength(1); + expect(all[0]!.title).toBe("Investigate report again"); + expect(all[0]!.fields).toEqual({ severity: "critical" }); + }); + + it("converges concurrent keyed upserts to one case", async () => { + await enableCases(); + const company = await seedCompany("RCE"); + const http = request(app(boardActor)); + + const requests = [ + http.post(`/api/companies/${company.id}/cases`).send({ + caseType: "release_note", + key: "2026-07-07", + title: "Release note A", + fields: { channel: "stable" }, + }), + http.post(`/api/companies/${company.id}/cases`).send({ + caseType: "release_note", + key: "2026-07-07", + title: "Release note B", + fields: { channel: "canary" }, + }), + ]; + + const responses = await Promise.all(requests); + expect(responses.map((res) => res.status).sort()).toEqual([200, 201]); + expect(responses[0]!.body.id).toBe(responses[1]!.body.id); + + const all = await db.select().from(cases); + expect(all).toHaveLength(1); + expect(all[0]!.caseType).toBe("release_note"); + expect(all[0]!.key).toBe("2026-07-07"); + expect(["Release note A", "Release note B"]).toContain(all[0]!.title); + expect([{ channel: "stable" }, { channel: "canary" }]).toContainEqual(all[0]!.fields); + }); + + it("upserts keyless cases by company and type", async () => { + await enableCases(); + const company = await seedCompany("NUL"); + const http = request(app(boardActor)); + + const first = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "release_note", title: "Draft release note" }) + .expect(201); + const second = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "release_note", title: "Updated release note" }) + .expect(200); + + expect(second.body.id).toBe(first.body.id); + expect(second.body.key).toBeNull(); + expect(second.body.title).toBe("Updated release note"); + const all = await db.select().from(cases); + expect(all).toHaveLength(1); + }); + + it("resolves cases by identifier", async () => { + await enableCases(); + const company = await seedCompany("REF"); + const http = request(app(boardActor)); + + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "blog_post", key: "launch", title: "Launch post" }) + .expect(201); + + const byIdentifier = await http.get(`/api/cases/${created.body.identifier}`).expect(200); + expect(byIdentifier.body.id).toBe(created.body.id); + expect(byIdentifier.body.identifier).toMatch(/^REF[A-Z0-9]{4}-C1$/); + }); + + it("auto-links run writes to their issue with a work link and event", async () => { + await enableCases(); + const company = await seedCompany("RUN"); + const agent = await seedAgent(company.id); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId: company.id, + agentId: agent.id, + status: "running", + }); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Source task", + status: "in_progress", + executionRunId: runId, + }).returning(); + const created = await request(app(boardActor)) + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "bug", title: "Bug" }) + .expect(201); + + const agentActor: Express.Request["actor"] = { + type: "agent", + companyId: company.id, + agentId: agent.id, + runId, + source: "agent_jwt", + onBehalfOfUserId: null, + onBehalfOfMemberships: [], + }; + await request(app(agentActor)) + .patch(`/api/cases/${created.body.id}`) + .send({ fields: { rootCause: "missing coverage" } }) + .expect(200); + + const links = await db.select().from(caseIssueLinks); + expect(links).toHaveLength(1); + expect(links[0]!.caseId).toBe(created.body.id); + expect(links[0]!.issueId).toBe(issue!.id); + expect(links[0]!.role).toBe("work"); + expect(links[0]!.createdByRunId).toBe(runId); + + const linkedEvents = await db.select().from(caseEvents).where(eq(caseEvents.kind, "issue_linked")); + expect(linkedEvents).toHaveLength(1); + expect(linkedEvents[0]!.actorAgentId).toBe(agent.id); + expect(linkedEvents[0]!.runId).toBe(runId); + expect(linkedEvents[0]!.payload).toMatchObject({ issueId: issue!.id, role: "work", autoLinked: true }); + }); + + it("lets a run-scoped agent JWT complete the case happy path without manual linking", async () => { + await enableCases(); + const company = await seedCompany("JWT"); + const agent = await seedAgent(company.id); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId: company.id, + agentId: agent.id, + status: "running", + }); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Agent case source", + status: "in_progress", + executionRunId: runId, + }).returning(); + const token = createLocalAgentJwt(agent.id, company.id, agent.adapterType, runId); + expect(token).toBeTruthy(); + + const http = request(authenticatedApp()); + const createResponse = await http + .post(`/api/companies/${company.id}/cases`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .send({ + caseType: "blog_post", + key: "launch-post", + title: "Launch post", + fields: { slug: "launch-post", target_audience: "operators" }, + }) + .expect(201); + const caseId = createResponse.body.id as string; + + await http + .put(`/api/cases/${createResponse.body.identifier}/documents/body`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .send({ body: "# Launch\n\nDraft body." }) + .expect(200); + + await http + .patch(`/api/cases/${caseId}`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .send({ + status: "in_review", + fields: { slug: "launch-post", target_audience: "operators", publish_url: "https://example.com/launch" }, + }) + .expect(200); + + await http + .post(`/api/cases/${caseId}/attachments`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .attach("file", Buffer.from("asset"), "asset.txt") + .expect(201); + + const links = await db.select().from(caseIssueLinks); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + companyId: company.id, + caseId, + issueId: issue!.id, + role: "origin", + createdByRunId: runId, + }); + + const detail = await http + .get(`/api/cases/${createResponse.body.identifier}`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .expect(200); + expect(detail.body.status).toBe("in_review"); + expect(detail.body.documents).toHaveLength(1); + expect(detail.body.attachments).toHaveLength(1); + expect(detail.body.issueLinks).toHaveLength(1); + + const eventRows = await db.select().from(caseEvents); + expect(eventRows.map((event) => event.kind)).toEqual(expect.arrayContaining([ + "created", + "issue_linked", + "document_revised", + "status_changed", + "attachment_added", + ])); + expect(eventRows.filter((event) => event.runId === runId)).toHaveLength(eventRows.length); + }); + + it("rejects cross-company agent access across the cases route surface", async () => { + await enableCases(); + const ownCompany = await seedCompany("OWN"); + const otherCompany = await seedCompany("OTH"); + const agent = await seedAgent(ownCompany.id); + const [otherIssue] = await db.insert(issues).values({ + companyId: otherCompany.id, + identifier: `${otherCompany.issuePrefix.toUpperCase()}-1`, + title: "Other company task", + status: "todo", + }).returning(); + const [caseRow] = await db.insert(cases).values({ + companyId: otherCompany.id, + caseNumber: 1, + identifier: `${otherCompany.issuePrefix.toUpperCase()}-C1`, + caseType: "bug", + title: "Other company case", + }).returning(); + const [ownCase] = await db.insert(cases).values({ + companyId: ownCompany.id, + caseNumber: 1, + identifier: `${ownCompany.issuePrefix.toUpperCase()}-C1`, + caseType: "bug", + title: "Own company case", + }).returning(); + await db.insert(caseEvents).values({ + companyId: otherCompany.id, + caseId: caseRow!.id, + kind: "created", + actorType: "system", + payload: {}, + }); + + const agentActor: Express.Request["actor"] = { + type: "agent", + companyId: ownCompany.id, + agentId: agent.id, + source: "agent_key", + keyId: "key-1", + onBehalfOfUserId: "user-1", + onBehalfOfMemberships: [], + }; + const http = request(app(agentActor)); + + await http.get(`/api/companies/${otherCompany.id}/cases`).expect(403); + await http + .post(`/api/companies/${otherCompany.id}/cases`) + .send({ caseType: "bug", title: "Wrong company create" }) + .expect(403); + await http.get(`/api/cases/${caseRow!.id}`).expect(404); + await http.get(`/api/cases/${caseRow!.identifier}`).expect(404); + await http.patch(`/api/cases/${caseRow!.id}`).send({ status: "in_progress" }).expect(404); + await http.put(`/api/cases/${caseRow!.id}/documents/body`).send({ body: "Body" }).expect(404); + await http + .post(`/api/cases/${caseRow!.id}/links`) + .send({ issueId: otherIssue!.id, role: "reference" }) + .expect(404); + await http + .post(`/api/cases/${caseRow!.id}/attachments`) + .attach("file", Buffer.from("artifact"), "artifact.txt") + .expect(404); + await http.get(`/api/cases/${caseRow!.id}/events`).expect(404); + await http.get(`/api/issues/${otherIssue!.id}/cases`).expect(404); + await http.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404); + + const limitedBoardActor: Express.Request["actor"] = { + type: "board", + userId: "limited-board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [ownCompany.id], + memberships: [{ companyId: ownCompany.id, membershipRole: "operator", status: "active" }], + }; + const limitedBoardHttp = request(app(limitedBoardActor)); + const ownCaseResponse = await limitedBoardHttp.get(`/api/cases/${ownCase!.id}`).expect(200); + expect(ownCaseResponse.body.id).toBe(ownCase!.id); + await limitedBoardHttp.get(`/api/cases/${caseRow!.id}`).expect(404); + await limitedBoardHttp.get(`/api/cases/${caseRow!.identifier}`).expect(404); + await limitedBoardHttp.get(`/api/issues/${otherIssue!.id}/cases`).expect(404); + await limitedBoardHttp.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404); + + const scopedAdminHttp = request(app({ ...limitedBoardActor, isInstanceAdmin: true })); + await scopedAdminHttp.get(`/api/cases/${caseRow!.id}`).expect(404); + await scopedAdminHttp.get(`/api/cases/${caseRow!.identifier}`).expect(404); + await scopedAdminHttp.get(`/api/issues/${otherIssue!.id}/cases`).expect(404); + await scopedAdminHttp.get(`/api/issues/${otherIssue!.identifier}/cases`).expect(404); + + expect(await db.select().from(cases)).toHaveLength(2); + expect(await db.select().from(caseDocuments)).toHaveLength(0); + expect(await db.select().from(documents)).toHaveLength(0); + expect(await db.select().from(caseIssueLinks)).toHaveLength(0); + expect(await db.select().from(caseAttachments)).toHaveLength(0); + expect(await db.select().from(assets)).toHaveLength(0); + expect(await db.select().from(caseEvents)).toHaveLength(1); + }); + + it("supports documents, manual issue links, attachment links, events, and list filters", async () => { + await enableCases(); + const company = await seedCompany("SUR"); + const [label] = await db.insert(labels).values({ + companyId: company.id, + name: "Needs Review", + color: "#f59e0b", + }).returning(); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + identifier: `${company.issuePrefix.toUpperCase()}-12`, + title: "Related task", + status: "todo", + }).returning(); + const http = request(app(boardActor)); + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "incident", title: "Production incident", status: "in_progress" }) + .expect(201); + + await http.patch(`/api/cases/${created.body.id}`).send({ labels: [label!.id] }).expect(200); + await http.put(`/api/cases/${created.body.identifier}/documents/runbook`).send({ body: "Steps" }).expect(200); + await http.post(`/api/cases/${created.body.id}/links`).send({ issueId: issue!.id, role: "reference" }).expect(201); + await http.post(`/api/cases/${created.body.id}/attachments`).attach("file", Buffer.from("artifact"), "artifact.txt").expect(201); + + const activeList = await http + .get(`/api/companies/${company.id}/cases`) + .query({ status: "active", label: label!.id, q: "Production" }) + .expect(200); + expect(activeList.body).toHaveLength(1); + expect(activeList.body[0].id).toBe(created.body.id); + + const [project] = await db.insert(projects).values({ companyId: company.id, name: "Launch" }).returning(); + const [projectCase] = await db.insert(cases).values({ + companyId: company.id, + projectId: project!.id, + caseNumber: 50, + identifier: `${company.issuePrefix.toUpperCase()}-C50`, + caseType: "brief", + title: "Project brief", + status: "draft", + }).returning(); + const multiFiltered = await http + .get(`/api/companies/${company.id}/cases`) + .query({ types: ["incident", "brief"], statuses: ["in_progress", "draft"], projectIds: [project!.id], includeNoProject: "true" }) + .expect(200); + expect(multiFiltered.body.map((row: { id: string }) => row.id).sort()).toEqual([created.body.id, projectCase!.id].sort()); + + await db.insert(cases).values(Array.from({ length: 205 }, (_, index) => ({ + companyId: company.id, + caseNumber: 100 + index, + identifier: `${company.issuePrefix.toUpperCase()}-C${100 + index}`, + caseType: "incident", + title: `Filler incident ${index}`, + status: "in_progress", + updatedAt: new Date(`2030-01-01T00:${String(index % 60).padStart(2, "0")}:00.000Z`), + }))); + const deepFiltered = await http + .get(`/api/companies/${company.id}/cases`) + .query({ q: "Production", limit: 1 }) + .expect(200); + expect(deepFiltered.body).toHaveLength(1); + expect(deepFiltered.body[0].id).toBe(created.body.id); + + const detail = await http.get(`/api/cases/${created.body.identifier}`).expect(200); + expect(detail.body.labels).toHaveLength(1); + expect(detail.body.documents).toHaveLength(1); + expect(detail.body.issueLinks).toHaveLength(1); + expect(detail.body.attachments).toHaveLength(1); + + const events = await http.get(`/api/cases/${created.body.id}/events`).expect(200); + expect(events.body.map((event: { kind: string }) => event.kind)).toEqual( + expect.arrayContaining(["created", "label_added", "document_revised", "issue_linked", "attachment_added"]), + ); + const linkedEvent = events.body.find((event: { kind: string }) => event.kind === "issue_linked"); + expect(linkedEvent.issue).toMatchObject({ + id: issue!.id, + identifier: issue!.identifier, + title: "Related task", + status: "todo", + }); + }); + + it("enriches events and revisions with actor name and run→issue attribution", async () => { + await enableCases(); + const company = await seedCompany("ATT"); + const agent = await seedAgent(company.id); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId: company.id, + agentId: agent.id, + status: "running", + }); + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Attribution source task", + status: "in_progress", + executionRunId: runId, + }).returning(); + + const agentActor: Express.Request["actor"] = { + type: "agent", + companyId: company.id, + agentId: agent.id, + runId, + source: "agent_jwt", + onBehalfOfUserId: null, + onBehalfOfMemberships: [], + }; + const http = request(app(agentActor)); + + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "blog_post", title: "Attribution post" }) + .expect(201); + // Two revisions on the body document. + const rev1 = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v1" }) + .expect(200); + await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v2", baseRevisionId: rev1.body.revision.id, changeSummary: "polish" }) + .expect(200); + + const events = await http.get(`/api/cases/${created.body.id}/events`).expect(200); + const revisedEvent = events.body.find((e: { kind: string }) => e.kind === "document_revised"); + expect(revisedEvent.actorAgentName).toBe("Cases Agent"); + expect(revisedEvent.issue).toMatchObject({ id: issue!.id, title: "Attribution source task" }); + + const revisions = await http + .get(`/api/cases/${created.body.id}/documents/body/revisions`) + .expect(200); + expect(revisions.body.revisions).toHaveLength(2); + expect(revisions.body.revisions[0].revisionNumber).toBe(2); + expect(revisions.body.revisions[0].body).toBe("# v2"); + expect(revisions.body.revisions[0].changeSummary).toBe("polish"); + expect(revisions.body.revisions[0].actorAgentName).toBe("Cases Agent"); + expect(revisions.body.revisions[0].issue).toMatchObject({ id: issue!.id }); + }); + + it("locks, unlocks, deletes, and restores case documents through shared document controls", async () => { + await enableCases(); + const company = await seedCompany("DOC"); + const http = request(app(boardActor)); + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "blog_post", title: "Document controls" }) + .expect(201); + + const firstRevision = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v1" }) + .expect(200); + const secondRevision = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# v2", baseRevisionId: firstRevision.body.revision.id }) + .expect(200); + + const loaded = await http.get(`/api/cases/${created.body.id}/documents/body`).expect(200); + expect(loaded.body.body).toBe("# v2"); + expect(loaded.body.latestRevisionNumber).toBe(2); + + const locked = await http.post(`/api/cases/${created.body.id}/documents/body/lock`).expect(200); + expect(locked.body.lockedAt).toBeTruthy(); + await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "# blocked", baseRevisionId: secondRevision.body.revision.id }) + .expect(409); + await http.delete(`/api/cases/${created.body.id}/documents/body`).expect(409); + + const unlocked = await http.post(`/api/cases/${created.body.id}/documents/body/unlock`).expect(200); + expect(unlocked.body.lockedAt).toBeNull(); + + const restored = await http + .post(`/api/cases/${created.body.id}/documents/body/revisions/${firstRevision.body.revision.id}/restore`) + .expect(200); + expect(restored.body.document.body).toBe("# v1"); + expect(restored.body.document.latestRevisionNumber).toBe(3); + expect(restored.body.restoredFromRevisionNumber).toBe(1); + + const revisions = await http.get(`/api/cases/${created.body.id}/documents/body/revisions`).expect(200); + expect(revisions.body.revisions).toHaveLength(3); + expect(revisions.body.revisions[0].changeSummary).toBe("Restored from revision 1"); + + await http.delete(`/api/cases/${created.body.id}/documents/body`).expect(200); + await http.get(`/api/cases/${created.body.id}`).expect(200).expect((res) => { + expect(res.body.documents).toHaveLength(0); + }); + }); + + it("creates, replies to, resolves, reopens, and remaps case document annotations", async () => { + await enableCases(); + const company = await seedCompany("ANN"); + const http = request(app(boardActor)); + const created = await http + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "brief", title: "Annotated case" }) + .expect(201); + const document = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ body: "Alpha beta gamma" }) + .expect(200); + + const annotation = await http + .post(`/api/cases/${created.body.id}/documents/body/annotations`) + .send({ + baseRevisionId: document.body.revision.id, + baseRevisionNumber: document.body.revision.revisionNumber, + selector: { + quote: { exact: "beta", prefix: "Alpha ", suffix: " gamma" }, + position: { normalizedStart: 6, normalizedEnd: 10, markdownStart: 6, markdownEnd: 10 }, + }, + body: "Clarify this word.", + }) + .expect(201); + + expect(annotation.body.caseId).toBe(created.body.id); + expect(annotation.body.issueId).toBeNull(); + expect(annotation.body.routineId).toBeNull(); + expect(annotation.body.comments[0].caseId).toBe(created.body.id); + + const listed = await http + .get(`/api/cases/${created.body.identifier}/documents/body/annotations?status=all&includeComments=true`) + .expect(200); + expect(listed.body).toHaveLength(1); + expect(listed.body[0].comments).toHaveLength(1); + + const reply = await http + .post(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}/comments`) + .send({ body: "Added context." }) + .expect(201); + expect(reply.body.caseId).toBe(created.body.id); + + const resolved = await http + .patch(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`) + .send({ status: "resolved" }) + .expect(200); + expect(resolved.body.status).toBe("resolved"); + + const reopened = await http + .patch(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`) + .send({ status: "open" }) + .expect(200); + expect(reopened.body.status).toBe("open"); + + const updatedDocument = await http + .put(`/api/cases/${created.body.id}/documents/body`) + .send({ + body: "Alpha beta gamma delta", + baseRevisionId: document.body.revision.id, + }) + .expect(200); + const remapped = await http + .get(`/api/cases/${created.body.id}/documents/body/annotations/${annotation.body.id}`) + .expect(200); + expect(remapped.body.currentRevisionNumber).toBe(updatedDocument.body.revision.revisionNumber); + expect(remapped.body.comments).toHaveLength(2); + + const activities = await db + .select({ action: activityLog.action, entityType: activityLog.entityType, entityId: activityLog.entityId }) + .from(activityLog) + .where(eq(activityLog.entityId, created.body.id)); + expect(activities).toEqual(expect.arrayContaining([ + expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_created" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_comment_added" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_resolved" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_thread_reopened" }), + expect.objectContaining({ entityType: "case", action: "case.document_annotation_remapped" }), + ])); + }); + + it("lists children by parent, exposes parent in detail, and lists cases for an issue", async () => { + await enableCases(); + const company = await seedCompany("TREE"); + const boardHttp = request(app(boardActor)); + const parent = await boardHttp + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "epic", title: "Parent epic" }) + .expect(201); + const child = await boardHttp + .post(`/api/companies/${company.id}/cases`) + .send({ caseType: "task", title: "Child task", parentCaseId: parent.body.id }) + .expect(201); + + const children = await boardHttp + .get(`/api/companies/${company.id}/cases`) + .query({ parent: parent.body.id }) + .expect(200); + expect(children.body).toHaveLength(1); + expect(children.body[0].id).toBe(child.body.id); + + const searchOnly = await boardHttp + .get(`/api/companies/${company.id}/cases`) + .query({ q: "Child task" }) + .expect(200); + expect(searchOnly.body.map((row: { id: string }) => row.id)).toEqual([child.body.id]); + + const searchWithAncestors = await boardHttp + .get(`/api/companies/${company.id}/cases`) + .query({ q: "Child task", includeAncestors: "true" }) + .expect(200); + expect(searchWithAncestors.body).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: child.body.id, matchesListFilters: true }), + expect.objectContaining({ id: parent.body.id, matchesListFilters: false }), + ])); + + const childDetail = await boardHttp.get(`/api/cases/${child.body.id}`).expect(200); + expect(childDetail.body.parent).toMatchObject({ id: parent.body.id, identifier: parent.body.identifier }); + + // Link the child case to an issue, then resolve cases-for-issue. + const [issue] = await db.insert(issues).values({ + companyId: company.id, + title: "Issue with cases", + status: "todo", + }).returning(); + await boardHttp + .post(`/api/cases/${child.body.id}/links`) + .send({ issueId: issue!.id, role: "work" }) + .expect(201); + + const forIssue = await boardHttp.get(`/api/issues/${issue!.id}/cases`).expect(200); + expect(forIssue.body).toHaveLength(1); + expect(forIssue.body[0]).toMatchObject({ role: "work" }); + expect(forIssue.body[0].case).toMatchObject({ id: child.body.id, identifier: child.body.identifier, status: child.body.status }); + }); +}); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 85cb08c0e4..089a2877e8 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -26,6 +26,7 @@ describe("instance settings service", () => { enableConferenceRoomChat: false, enableExternalObjects: false, enablePipelines: false, + enableCases: false, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableTaskWatchdogs: true, diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 9736c25694..5eb8c8e87e 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -55,6 +55,8 @@ const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", const explicitOpenApiCoverageExclusions = new Set([ // Pipeline routes are experimental and not yet represented in the public OpenAPI document. "pipelines.ts", + // Case routes are experimental (enableCases flag) and not yet in the public OpenAPI document. + "cases.ts", ]); function createApp() { diff --git a/server/src/app.ts b/server/src/app.ts index e5c266f469..75e0f1fe24 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -20,6 +20,7 @@ import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; +import { caseRoutes } from "./routes/cases.js"; import { fileResourceRoutes } from "./routes/file-resources.js"; import { routineRoutes } from "./routes/routines.js"; import { pipelineRoutes } from "./routes/pipelines.js"; @@ -238,6 +239,7 @@ export async function createApp( feedbackExportService: opts.feedbackExportService, pluginWorkerManager: workerManager, })); + api.use(caseRoutes(db, opts.storageService)); api.use(issueTreeControlRoutes(db)); api.use(fileResourceRoutes(db)); api.use(routineRoutes(db, { pluginWorkerManager: workerManager })); diff --git a/server/src/routes/cases.ts b/server/src/routes/cases.ts new file mode 100644 index 0000000000..15a3c43e4e --- /dev/null +++ b/server/src/routes/cases.ts @@ -0,0 +1,1539 @@ +import { Router, type Request, type Response } from "express"; +import multer from "multer"; +import { z } from "zod"; +import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + assets, + caseAttachments, + caseDocuments, + caseEvents, + caseIssueLinks, + caseLabels, + cases, + companies, + documents, + documentRevisions, + issues, + labels, + projects, +} from "@paperclipai/db"; +import { + createDocumentAnnotationCommentSchema, + createDocumentAnnotationThreadSchema, + updateDocumentAnnotationThreadSchema, + isUuidLike, +} from "@paperclipai/shared"; +import { normalizeContentType } from "../attachment-types.js"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; +import { documentAnnotationService, logActivity } from "../services/index.js"; +import type { StorageService } from "../storage/types.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +type CaseRouteDb = Db | Parameters[0]>[0]; +type CaseActor = ReturnType; + +const CASE_STATUSES = ["draft", "in_progress", "in_review", "approved", "done", "cancelled"] as const; +const CASE_LINK_ROLES = ["origin", "work", "reference"] as const; +const DEFAULT_EVENTS_LIMIT = 100; +const MAX_EVENTS_LIMIT = 500; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); +const caseStatusSchema = z.enum(CASE_STATUSES); +const caseTypeSchema = z.string().trim().min(1).max(120); +const caseKeySchema = z.string().trim().min(1).max(512); +const documentKeySchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_.:-]+$/); + +const createCaseSchema = z.object({ + projectId: z.string().uuid().nullable().optional(), + caseType: caseTypeSchema, + key: caseKeySchema.nullable().optional(), + title: z.string().trim().min(1).max(500), + summary: z.string().max(8_000).nullable().optional(), + status: caseStatusSchema.optional(), + fields: jsonObjectSchema.optional(), + parentCaseId: z.string().uuid().nullable().optional(), +}).strict(); + +const patchCaseSchema = z.object({ + projectId: z.string().uuid().nullable().optional(), + title: z.string().trim().min(1).max(500).optional(), + summary: z.string().max(8_000).nullable().optional(), + status: caseStatusSchema.optional(), + fields: jsonObjectSchema.optional(), + parentCaseId: z.string().uuid().nullable().optional(), + labels: z.array(z.string().uuid()).max(100).optional(), + labelIds: z.array(z.string().uuid()).max(100).optional(), +}).strict(); + +const createIssueLinkSchema = z.object({ + issueId: z.string().uuid(), + role: z.enum(CASE_LINK_ROLES), +}).strict(); + +const upsertCaseDocumentSchema = z.object({ + title: z.string().trim().min(1).max(200).optional(), + format: z.string().trim().min(1).max(80).optional().default("markdown"), + body: z.string().max(200_000), + changeSummary: z.string().trim().max(1_000).nullable().optional(), + baseRevisionId: z.string().uuid().nullable().optional(), +}).strict(); + +const queryListParamSchema = z.union([z.string(), z.array(z.string())]).optional(); + +const listCasesQuerySchema = z.object({ + type: z.string().trim().min(1).max(120).optional(), + types: queryListParamSchema, + status: z.string().trim().min(1).max(120).optional(), + statuses: queryListParamSchema, + project: z.string().uuid().optional(), + projectId: z.string().uuid().optional(), + projectIds: queryListParamSchema, + includeNoProject: z.enum(["true", "false", "1", "0"]).optional(), + label: z.string().uuid().optional(), + labelId: z.string().uuid().optional(), + parent: z.string().uuid().optional(), + q: z.string().trim().min(1).max(200).optional(), + includeAncestors: z.enum(["true", "false", "1", "0"]).optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(100), +}).strict(); + +const listEventsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(MAX_EVENTS_LIMIT).optional().default(DEFAULT_EVENTS_LIMIT), +}).strict(); + +function eventActorValues(actor: CaseActor) { + return { + actorType: actor.actorType, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + actorAgentId: actor.agentId, + runId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + }; +} + +async function assertCasesEnabled(db: Db) { + const experimental = await instanceSettingsService(db).getExperimental(); + if (!experimental.enableCases) { + throw forbidden("Cases are disabled"); + } +} + +async function lockCaseUpsertKey(db: CaseRouteDb, input: { companyId: string; caseType: string; key: string | null | undefined }) { + const lockKey = `paperclip:case-upsert:${input.companyId}:${input.caseType}:${input.key ?? ""}`; + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`); +} + +async function lockCaseDocumentKey(db: CaseRouteDb, input: { companyId: string; caseId: string; key: string }) { + const lockKey = `paperclip:case-document:${input.companyId}:${input.caseId}:${input.key}`; + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`); +} + +async function lockCaseLabels(db: CaseRouteDb, input: { companyId: string; caseId: string }) { + const lockKey = `paperclip:case-labels:${input.companyId}:${input.caseId}`; + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${lockKey}))`); +} + +function parseDocumentKey(raw: string | undefined) { + const parsed = documentKeySchema.safeParse(raw); + if (!parsed.success) throw badRequest("Invalid document key", parsed.error.issues); + return parsed.data; +} + +function parseBooleanQuery(value: unknown) { + return value === true || value === "true" || value === "1"; +} + +function parseQueryList(value: string | string[] | undefined): string[] { + const values = Array.isArray(value) ? value : value ? [value] : []; + return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean); +} + +function annotationActorInput(req: Request) { + const actor = getActorInfo(req); + return { + actor, + annotationActor: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }, + }; +} + +async function loadCaseByIdOrIdentifier(db: CaseRouteDb, idOrIdentifier: string, companyIds?: string[]) { + if (companyIds && companyIds.length === 0) return null; + const normalizedIdentifier = idOrIdentifier.trim().toUpperCase(); + const identityWhere = isUuidLike(idOrIdentifier) + ? or(eq(cases.id, idOrIdentifier), eq(cases.identifier, normalizedIdentifier)) + : eq(cases.identifier, normalizedIdentifier); + const where = companyIds + ? and(identityWhere, inArray(cases.companyId, companyIds)) + : identityWhere; + return db.select().from(cases).where(where).limit(1).then((rows) => rows[0] ?? null); +} + +async function loadIssueByIdOrIdentifier(db: CaseRouteDb, idOrIdentifier: string, companyIds?: string[]) { + if (companyIds && companyIds.length === 0) return null; + const normalizedIdentifier = idOrIdentifier.trim().toUpperCase(); + const identityWhere = isUuidLike(idOrIdentifier) + ? or(eq(issues.id, idOrIdentifier), eq(issues.identifier, normalizedIdentifier)) + : eq(issues.identifier, normalizedIdentifier); + const where = companyIds + ? and(identityWhere, inArray(issues.companyId, companyIds)) + : identityWhere; + return db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(where) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +function caseLookupCompanyIds(req: Request) { + if (req.actor.type === "agent") return req.actor.companyId ? [req.actor.companyId] : []; + if (req.actor.type === "board" && req.actor.source === "local_implicit") return undefined; + if (req.actor.type === "board" && Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0) { + return req.actor.companyIds; + } + if (req.actor.type === "board" && req.actor.isInstanceAdmin) return undefined; + return []; +} + +async function assertCaseAccess(db: Db, req: Request, idOrIdentifier: string) { + const row = await loadCaseByIdOrIdentifier(db, idOrIdentifier, caseLookupCompanyIds(req)); + if (!row) throw notFound("Case not found"); + assertCompanyAccess(req, row.companyId); + return row; +} + +// The pipelines feature registers its own /cases/:caseId routes after this +// router. On paths both features share, return null (caller falls through via +// next()) when the id is not a new-Cases row so pipeline case requests still +// reach their handler regardless of the enableCases flag. +async function resolveSharedPathCase(db: Db, req: Request, idOrIdentifier: string) { + const companyIds = caseLookupCompanyIds(req); + const row = await loadCaseByIdOrIdentifier(db, idOrIdentifier, companyIds); + if (!row) return null; + await assertCasesEnabled(db); + assertCompanyAccess(req, row.companyId); + return row; +} + +async function assertProjectBelongsToCompany(db: CaseRouteDb, input: { companyId: string; projectId: string | null }) { + if (!input.projectId) return; + const row = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.id, input.projectId), eq(projects.companyId, input.companyId))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!row) throw unprocessable("Project does not belong to company"); +} + +async function assertParentCaseBelongsToCompany(db: CaseRouteDb, input: { + companyId: string; + caseId?: string; + parentCaseId: string | null; +}) { + if (!input.parentCaseId) return; + if (input.caseId && input.parentCaseId === input.caseId) { + throw unprocessable("A case cannot be its own parent"); + } + const row = await db + .select({ id: cases.id }) + .from(cases) + .where(and(eq(cases.id, input.parentCaseId), eq(cases.companyId, input.companyId))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!row) throw unprocessable("Parent case does not belong to company"); +} + +async function assertLabelsBelongToCompany(db: CaseRouteDb, companyId: string, labelIds: string[]) { + if (labelIds.length === 0) return; + const uniqueIds = [...new Set(labelIds)]; + const rows = await db + .select({ id: labels.id }) + .from(labels) + .where(and(eq(labels.companyId, companyId), inArray(labels.id, uniqueIds))); + if (rows.length !== uniqueIds.length) { + throw unprocessable("One or more labels do not belong to company"); + } +} + +async function insertCaseEvent(db: CaseRouteDb, input: { + companyId: string; + caseId: string; + kind: typeof caseEvents.$inferInsert["kind"]; + actor: CaseActor; + payload?: Record; +}) { + const now = new Date(); + const [event] = await db.insert(caseEvents).values({ + companyId: input.companyId, + caseId: input.caseId, + kind: input.kind, + ...eventActorValues(input.actor), + payload: input.payload ?? {}, + createdAt: now, + updatedAt: now, + }).returning(); + return event!; +} + +async function resolveIssueForRun(db: CaseRouteDb, companyId: string, runId: string | null | undefined) { + if (!runId || !isUuidLike(runId)) return null; + return db + .select({ id: issues.id }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + or( + eq(issues.executionRunId, runId), + eq(issues.checkoutRunId, runId), + eq(issues.originRunId, runId), + ), + )) + .orderBy(desc(issues.updatedAt), desc(issues.createdAt)) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +/** Batch resolve agent display names for a set of agent ids. */ +async function resolveAgentNames(db: CaseRouteDb, agentIds: (string | null)[]) { + const valid = [...new Set(agentIds.filter((id): id is string => !!id))]; + if (valid.length === 0) return new Map(); + const rows = await db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where(inArray(agents.id, valid)); + return new Map(rows.map((row) => [row.id, row.name])); +} + +/** + * Batch resolve run → issue attribution. Mirrors resolveIssueForRun's precedence + * (latest-updated issue whose execution/checkout/origin run matches), but for a + * whole set of runs at once so the activity feed / revisions rail avoid N+1s. + */ +async function resolveIssuesForRuns(db: CaseRouteDb, companyId: string, runIds: (string | null)[]) { + const valid = [...new Set(runIds.filter((id): id is string => !!id && isUuidLike(id)))]; + const map = new Map(); + if (valid.length === 0) return map; + const rows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + executionRunId: issues.executionRunId, + checkoutRunId: issues.checkoutRunId, + originRunId: issues.originRunId, + updatedAt: issues.updatedAt, + createdAt: issues.createdAt, + }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + or( + inArray(issues.executionRunId, valid), + inArray(issues.checkoutRunId, valid), + inArray(issues.originRunId, valid), + ), + )) + .orderBy(desc(issues.updatedAt), desc(issues.createdAt)); + for (const runId of valid) { + const match = rows.find( + (row) => row.executionRunId === runId || row.checkoutRunId === runId || row.originRunId === runId, + ); + if (match) { + map.set(runId, { id: match.id, identifier: match.identifier ?? match.id, title: match.title, status: match.status }); + } + } + return map; +} + +function payloadIssueIdForEvent(kind: string, payload: Record | null | undefined) { + if (kind !== "issue_linked" && kind !== "issue_unlinked") return null; + const issueId = payload?.issueId; + return typeof issueId === "string" && isUuidLike(issueId) ? issueId : null; +} + +async function resolveIssuesByIds(db: CaseRouteDb, companyId: string, issueIds: (string | null)[]) { + const valid = [...new Set(issueIds.filter((id): id is string => !!id && isUuidLike(id)))]; + const map = new Map(); + if (valid.length === 0) return map; + const rows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), inArray(issues.id, valid))); + for (const row of rows) { + map.set(row.id, { id: row.id, identifier: row.identifier ?? row.id, title: row.title, status: row.status }); + } + return map; +} + +async function autoLinkRunIssue(db: CaseRouteDb, input: { + companyId: string; + caseId: string; + actor: CaseActor; + role: "origin" | "work"; +}) { + const issue = await resolveIssueForRun(db, input.companyId, input.actor.runId); + if (!issue) return null; + const now = new Date(); + const [link] = await db.insert(caseIssueLinks).values({ + companyId: input.companyId, + caseId: input.caseId, + issueId: issue.id, + role: input.role, + createdByRunId: input.actor.runId && isUuidLike(input.actor.runId) ? input.actor.runId : null, + createdAt: now, + updatedAt: now, + }).onConflictDoNothing({ + target: [caseIssueLinks.caseId, caseIssueLinks.issueId], + }).returning(); + if (!link) return null; + await insertCaseEvent(db, { + companyId: input.companyId, + caseId: input.caseId, + kind: "issue_linked", + actor: input.actor, + payload: { issueId: issue.id, role: input.role, autoLinked: true }, + }); + return link; +} + +async function nextCaseIdentity(db: CaseRouteDb, companyId: string) { + await db.execute(sql`select pg_advisory_xact_lock(hashtext(${`paperclip:cases:${companyId}`}))`); + const [company] = await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .limit(1); + if (!company) throw notFound("Company not found"); + const [maxRow] = await db + .select({ maxNum: sql`coalesce(max(${cases.caseNumber}), 0)` }) + .from(cases) + .where(eq(cases.companyId, companyId)); + const caseNumber = (maxRow?.maxNum ?? 0) + 1; + return { + caseNumber, + identifier: `${company.issuePrefix.toUpperCase()}-C${caseNumber}`, + }; +} + +function completedAtForStatus(status: string, previous?: Date | null) { + if (status === "done" || status === "cancelled") return previous ?? new Date(); + return null; +} + +type PatchCaseBody = z.infer; + +export function buildCasePatchUpdateValues( + body: PatchCaseBody, + caseRow: Pick, + now: Date, +) { + const status = body.status ?? caseRow.status; + return { + ...(Object.hasOwn(body, "projectId") ? { projectId: body.projectId ?? null } : {}), + ...(body.title !== undefined ? { title: body.title } : {}), + ...(Object.hasOwn(body, "summary") ? { summary: body.summary ?? null } : {}), + ...(body.status !== undefined ? { status, completedAt: completedAtForStatus(status, caseRow.completedAt) } : {}), + ...(body.fields !== undefined ? { fields: body.fields } : {}), + ...(Object.hasOwn(body, "parentCaseId") ? { parentCaseId: body.parentCaseId ?? null } : {}), + updatedAt: now, + }; +} + +async function loadCaseDetail(db: CaseRouteDb, row: typeof cases.$inferSelect) { + const [labelRows, linkRows, documentRows, attachmentRows] = await Promise.all([ + db + .select({ label: labels }) + .from(caseLabels) + .innerJoin(labels, eq(caseLabels.labelId, labels.id)) + .where(and(eq(caseLabels.companyId, row.companyId), eq(caseLabels.caseId, row.id))) + .orderBy(asc(labels.name)), + db + .select({ link: caseIssueLinks, issue: issues }) + .from(caseIssueLinks) + .innerJoin(issues, eq(caseIssueLinks.issueId, issues.id)) + .where(and(eq(caseIssueLinks.companyId, row.companyId), eq(caseIssueLinks.caseId, row.id))) + .orderBy(asc(caseIssueLinks.createdAt)), + db + .select({ link: caseDocuments, document: documents }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and(eq(caseDocuments.companyId, row.companyId), eq(caseDocuments.caseId, row.id))) + .orderBy(asc(caseDocuments.key)), + db + .select({ link: caseAttachments, asset: assets }) + .from(caseAttachments) + .innerJoin(assets, eq(caseAttachments.assetId, assets.id)) + .where(and(eq(caseAttachments.companyId, row.companyId), eq(caseAttachments.caseId, row.id))) + .orderBy(asc(caseAttachments.createdAt)), + ]); + const parent = row.parentCaseId + ? await db + .select({ + id: cases.id, + identifier: cases.identifier, + title: cases.title, + caseType: cases.caseType, + status: cases.status, + }) + .from(cases) + .where(eq(cases.id, row.parentCaseId)) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + return { + ...row, + parent, + labels: labelRows.map((item) => item.label), + issueLinks: linkRows.map((item) => ({ + ...item.link, + issue: { + id: item.issue.id, + identifier: item.issue.identifier, + title: item.issue.title, + status: item.issue.status, + }, + })), + documents: documentRows.map((item) => ({ + key: item.link.key, + document: item.document, + })), + attachments: attachmentRows.map((item) => ({ + id: item.link.id, + asset: item.asset, + createdAt: item.link.createdAt, + updatedAt: item.link.updatedAt, + })), + }; +} + +async function loadCaseDocumentLink(db: CaseRouteDb, input: { companyId: string; caseId: string; key: string }) { + return db + .select({ link: caseDocuments, document: documents }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and( + eq(caseDocuments.companyId, input.companyId), + eq(caseDocuments.caseId, input.caseId), + eq(caseDocuments.key, input.key), + )) + .limit(1) + .then((rows) => rows[0] ?? null); +} + +async function includeCaseAncestors( + db: CaseRouteDb, + companyId: string, + baseRows: Array, +) { + const baseIds = new Set(baseRows.map((row) => row.id)); + const rowsById = new Map(baseRows.map((row) => [row.id, row])); + const ancestorRows: Array = []; + let pending = [...new Set( + baseRows + .map((row) => row.parentCaseId) + .filter((id): id is string => { + if (!id) return false; + return !rowsById.has(id); + }), + )]; + + while (pending.length > 0) { + const ancestors = await db + .select() + .from(cases) + .where(and(eq(cases.companyId, companyId), inArray(cases.id, pending))); + const nextPending = new Set(); + for (const row of ancestors) { + if (rowsById.has(row.id)) continue; + rowsById.set(row.id, row); + ancestorRows.push(row); + if (row.parentCaseId && !rowsById.has(row.parentCaseId)) { + nextPending.add(row.parentCaseId); + } + } + pending = [...nextPending]; + } + + return [...baseRows, ...ancestorRows].map((row) => ({ + ...row, + matchesListFilters: baseIds.has(row.id), + })); +} + +function caseDocumentResponse(input: { key: string; document: typeof documents.$inferSelect }) { + return { + ...input.document, + key: input.key, + body: input.document.latestBody, + }; +} + +function singleFileUpload(req: Request, res: Response, maxBytes: number) { + const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: maxBytes, files: 1 }, + }).single("file"); + return new Promise((resolve, reject) => { + upload(req, res, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +export function caseRoutes(db: Db, storage: StorageService) { + const router = Router(); + const documentAnnotationsSvc = documentAnnotationService(db); + + async function logCaseAnnotationRemaps(input: { + caseRow: typeof cases.$inferSelect; + key: string; + document: Pick; + body: string; + actor: CaseActor; + }) { + const remapped = await documentAnnotationsSvc.remapOpenThreadsForCaseDocument({ + caseId: input.caseRow.id, + key: input.key, + documentId: input.document.id, + nextRevisionId: input.document.latestRevisionId, + nextRevisionNumber: input.document.latestRevisionNumber, + nextBody: input.body, + }); + for (const remap of remapped) { + await logActivity(db, { + companyId: input.caseRow.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: "case.document_annotation_remapped", + entityType: "case", + entityId: input.caseRow.id, + details: { + key: input.key, + documentKey: input.key, + documentId: input.document.id, + threadId: remap.thread.id, + revisionNumber: input.document.latestRevisionNumber, + anchorState: remap.thread.anchorState, + anchorConfidence: remap.thread.anchorConfidence, + snapshotId: remap.snapshot.id, + }, + }); + } + } + + router.post("/companies/:companyId/cases", validate(createCaseSchema), async (req, res) => { + await assertCasesEnabled(db); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const actor = getActorInfo(req); + const body = req.body as z.infer; + + const result = await db.transaction(async (tx) => { + await assertProjectBelongsToCompany(tx, { companyId, projectId: body.projectId ?? null }); + await assertParentCaseBelongsToCompany(tx, { companyId, parentCaseId: body.parentCaseId ?? null }); + await lockCaseUpsertKey(tx, { companyId, caseType: body.caseType, key: body.key }); + const keyFilter = body.key ? eq(cases.key, body.key) : isNull(cases.key); + + const now = new Date(); + const existing = await tx + .select() + .from(cases) + .where(and(eq(cases.companyId, companyId), eq(cases.caseType, body.caseType), keyFilter)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existing) { + const status = body.status ?? existing.status; + const [updated] = await tx.update(cases).set({ + projectId: body.projectId ?? existing.projectId, + title: body.title, + summary: body.summary ?? existing.summary, + status, + fields: body.fields ?? existing.fields, + parentCaseId: body.parentCaseId ?? existing.parentCaseId, + completedAt: completedAtForStatus(status, existing.completedAt), + updatedAt: now, + }).where(eq(cases.id, existing.id)).returning(); + await insertCaseEvent(tx, { + companyId, + caseId: existing.id, + kind: "updated", + actor, + payload: { upsert: true }, + }); + await autoLinkRunIssue(tx, { companyId, caseId: existing.id, actor, role: "origin" }); + return { created: false, row: updated! }; + } + + const identity = await nextCaseIdentity(tx, companyId); + const status = body.status ?? "draft"; + const [created] = await tx.insert(cases).values({ + companyId, + projectId: body.projectId ?? null, + ...identity, + caseType: body.caseType, + key: body.key ?? null, + title: body.title, + summary: body.summary ?? null, + status, + fields: body.fields ?? {}, + parentCaseId: body.parentCaseId ?? null, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + completedAt: completedAtForStatus(status), + createdAt: now, + updatedAt: now, + }).returning(); + await insertCaseEvent(tx, { + companyId, + caseId: created!.id, + kind: "created", + actor, + payload: { caseType: body.caseType, key: body.key ?? null }, + }); + await autoLinkRunIssue(tx, { companyId, caseId: created!.id, actor, role: "origin" }); + return { created: true, row: created! }; + }); + + res.status(result.created ? 201 : 200).json(await loadCaseDetail(db, result.row)); + }); + + router.get("/companies/:companyId/cases", async (req, res) => { + await assertCasesEnabled(db); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const parsed = listCasesQuerySchema.safeParse(req.query); + if (!parsed.success) throw badRequest("Invalid case list query", parsed.error.issues); + const query = parsed.data; + const filters = [eq(cases.companyId, companyId)]; + const typeFilters = parseQueryList(query.types ?? query.type); + if (typeFilters.length === 1) filters.push(eq(cases.caseType, typeFilters[0]!)); + else if (typeFilters.length > 1) filters.push(inArray(cases.caseType, typeFilters)); + + const statusFilters = parseQueryList(query.statuses ?? (query.status === "active" ? undefined : query.status)); + if (query.status === "active" && statusFilters.length === 0) { + filters.push(sql`${cases.status} not in ('done', 'cancelled')`); + } else if (statusFilters.length > 0) { + for (const status of statusFilters) { + if (!CASE_STATUSES.includes(status as (typeof CASE_STATUSES)[number])) { + throw badRequest("Invalid case status"); + } + } + filters.push(statusFilters.length === 1 ? eq(cases.status, statusFilters[0]!) : inArray(cases.status, statusFilters)); + } + + const projectFilters = parseQueryList(query.projectIds ?? query.projectId ?? query.project); + for (const projectId of projectFilters) { + if (!isUuidLike(projectId)) throw badRequest("Invalid project id"); + } + const includeNoProject = parseBooleanQuery(query.includeNoProject); + if (projectFilters.length > 0 && includeNoProject) { + filters.push(or(inArray(cases.projectId, projectFilters), isNull(cases.projectId))!); + } else if (projectFilters.length === 1) { + filters.push(eq(cases.projectId, projectFilters[0]!)); + } else if (projectFilters.length > 1) { + filters.push(inArray(cases.projectId, projectFilters)); + } else if (includeNoProject) { + filters.push(isNull(cases.projectId)); + } + if (query.parent) filters.push(eq(cases.parentCaseId, query.parent)); + const labelId = query.labelId ?? query.label; + if (labelId) { + filters.push(sql`${cases.id} in ( + select ${caseLabels.caseId} from ${caseLabels} + where ${caseLabels.companyId} = ${companyId} and ${caseLabels.labelId} = ${labelId} + )`); + } + if (query.q) { + const pattern = `%${query.q.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`; + filters.push(or( + ilike(cases.identifier, pattern), + ilike(cases.title, pattern), + ilike(cases.summary, pattern), + ilike(cases.key, pattern), + )!); + } + + const rows = await db + .select() + .from(cases) + .where(and(...filters)) + .orderBy(desc(cases.updatedAt), desc(cases.createdAt)) + .limit(query.limit); + res.json(parseBooleanQuery(query.includeAncestors) ? await includeCaseAncestors(db, companyId, rows) : rows); + }); + + router.get("/cases/:id/documents/:key", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const link = await loadCaseDocumentLink(db, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) throw notFound("Case document not found"); + res.json(caseDocumentResponse({ key, document: link.document })); + }); + + router.get("/cases/:id/documents/:key/annotations", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const status = req.query.status === "resolved" || req.query.status === "all" ? req.query.status : "open"; + const threads = await documentAnnotationsSvc.listThreadsForCaseDocument(caseRow.id, key, { + status, + includeComments: parseBooleanQuery(req.query.includeComments), + }); + res.json(threads); + }); + + router.get("/cases/:id/documents/:key/annotations/:threadId", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const thread = await documentAnnotationsSvc.getThreadForCaseDocument( + caseRow.id, + key, + req.params.threadId as string, + ); + if (!thread) throw notFound("Annotation thread not found"); + res.json(thread); + }); + + router.post( + "/cases/:id/documents/:key/annotations", + validate(createDocumentAnnotationThreadSchema), + async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentAnnotationsSvc.createCaseThread( + caseRow.id, + key, + req.body, + annotationActor, + ); + const firstComment = thread.comments[0]; + await logActivity(db, { + companyId: caseRow.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "case.document_annotation_thread_created", + entityType: "case", + entityId: caseRow.id, + details: { + key: thread.documentKey, + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + commentId: firstComment?.id ?? null, + revisionNumber: thread.currentRevisionNumber, + quote: thread.selectedText.slice(0, 240), + }, + }); + res.status(201).json(thread); + }, + ); + + router.post( + "/cases/:id/documents/:key/annotations/:threadId/comments", + validate(createDocumentAnnotationCommentSchema), + async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const { actor, annotationActor } = annotationActorInput(req); + const comment = await documentAnnotationsSvc.addCaseComment( + caseRow.id, + key, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: caseRow.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "case.document_annotation_comment_added", + entityType: "case", + entityId: caseRow.id, + details: { + key, + documentKey: key, + threadId: comment.threadId, + commentId: comment.id, + bodySnippet: comment.body.slice(0, 120), + }, + }); + res.status(201).json(comment); + }, + ); + + router.patch( + "/cases/:id/documents/:key/annotations/:threadId", + validate(updateDocumentAnnotationThreadSchema), + async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const { actor, annotationActor } = annotationActorInput(req); + const thread = await documentAnnotationsSvc.updateCaseThread( + caseRow.id, + key, + req.params.threadId as string, + req.body, + annotationActor, + ); + await logActivity(db, { + companyId: caseRow.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: thread.status === "resolved" + ? "case.document_annotation_thread_resolved" + : "case.document_annotation_thread_reopened", + entityType: "case", + entityId: caseRow.id, + details: { + key: thread.documentKey, + documentKey: thread.documentKey, + documentId: thread.documentId, + threadId: thread.id, + status: thread.status, + }, + }); + res.json(thread); + }, + ); + + router.put("/cases/:id/documents/:key", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const actor = getActorInfo(req); + const body = upsertCaseDocumentSchema.parse(req.body); + + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const existing = await tx + .select({ link: caseDocuments, document: documents, revision: documentRevisions }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .leftJoin(documentRevisions, eq(documents.latestRevisionId, documentRevisions.id)) + .where(and( + eq(caseDocuments.companyId, caseRow.companyId), + eq(caseDocuments.caseId, caseRow.id), + eq(caseDocuments.key, key), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existing?.document.lockedAt) { + throw conflict("Document is locked", { + key, + documentId: existing.document.id, + lockedAt: existing.document.lockedAt, + }); + } + if (existing && !body.baseRevisionId) { + throw conflict("Case document update requires baseRevisionId", { + code: "stale_base_revision", + latestRevisionId: existing.document.latestRevisionId, + latestRevisionNumber: existing.document.latestRevisionNumber, + }); + } + if (existing && body.baseRevisionId !== existing.document.latestRevisionId) { + throw conflict("Case document was updated by someone else", { + code: "stale_base_revision", + latestRevisionId: existing.document.latestRevisionId, + latestRevisionNumber: existing.document.latestRevisionNumber, + latestRevision: existing.revision, + }); + } + if (!existing && body.baseRevisionId) { + throw conflict("Case document does not exist yet", { + code: "stale_base_revision", + latestRevisionId: null, + latestRevisionNumber: null, + }); + } + + const now = new Date(); + const [document] = existing + ? await tx.update(documents).set({ + title: body.title ?? existing.document.title, + format: body.format, + updatedAt: now, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + }).where(eq(documents.id, existing.document.id)).returning() + : await tx.insert(documents).values({ + companyId: caseRow.companyId, + title: body.title ?? key, + format: body.format, + latestBody: body.body, + latestRevisionNumber: 1, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + createdAt: now, + updatedAt: now, + }).returning(); + const nextRevisionNumber = existing ? existing.document.latestRevisionNumber + 1 : 1; + const [revision] = await tx.insert(documentRevisions).values({ + companyId: caseRow.companyId, + documentId: document!.id, + revisionNumber: nextRevisionNumber, + title: body.title ?? document!.title, + format: body.format, + body: body.body, + changeSummary: body.changeSummary ?? null, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + createdAt: now, + }).returning(); + await tx.update(documents).set({ + title: body.title ?? document!.title, + format: body.format, + latestBody: body.body, + latestRevisionId: revision!.id, + latestRevisionNumber: revision!.revisionNumber, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedAt: now, + }).where(eq(documents.id, document!.id)); + if (!existing) { + await tx.insert(caseDocuments).values({ + companyId: caseRow.companyId, + caseId: caseRow.id, + documentId: document!.id, + key, + createdAt: now, + updatedAt: now, + }); + } else { + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, document!.id)); + } + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "document_revised", + actor, + payload: { key, documentId: document!.id, revisionId: revision!.id, revisionNumber: revision!.revisionNumber }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return { + document: { + ...document!, + title: body.title ?? document!.title, + format: body.format, + latestBody: body.body, + latestRevisionId: revision!.id, + latestRevisionNumber: revision!.revisionNumber, + updatedAt: now, + }, + revision, + }; + }); + await logCaseAnnotationRemaps({ + caseRow, + key, + document: result.document, + body: result.document.latestBody, + actor, + }); + res.json(result); + }); + + router.post("/cases/:id/documents/:key/lock", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const actor = getActorInfo(req); + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) throw notFound("Case document not found"); + if (link.document.lockedAt) return caseDocumentResponse({ key, document: link.document }); + const now = new Date(); + const [document] = await tx.update(documents).set({ + lockedAt: now, + lockedByAgentId: actor.agentId, + lockedByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedAt: now, + }).where(eq(documents.id, link.document.id)).returning(); + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, link.document.id)); + return caseDocumentResponse({ key, document: document! }); + }); + res.json(result); + }); + + router.post("/cases/:id/documents/:key/unlock", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) throw notFound("Case document not found"); + if (!link.document.lockedAt) return caseDocumentResponse({ key, document: link.document }); + const now = new Date(); + const [document] = await tx.update(documents).set({ + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + updatedAt: now, + }).where(eq(documents.id, link.document.id)).returning(); + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, link.document.id)); + return caseDocumentResponse({ key, document: document! }); + }); + res.json(result); + }); + + router.post("/cases/:id/documents/:key/revisions/:revisionId/restore", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const revisionId = req.params.revisionId as string; + const actor = getActorInfo(req); + + const result = await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const existing = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!existing) throw notFound("Case document not found"); + if (existing.document.lockedAt) { + throw conflict("Document is locked", { + key, + documentId: existing.document.id, + lockedAt: existing.document.lockedAt, + }); + } + const sourceRevision = await tx + .select() + .from(documentRevisions) + .where(and(eq(documentRevisions.id, revisionId), eq(documentRevisions.documentId, existing.document.id))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!sourceRevision) throw notFound("Case document revision not found"); + if (existing.document.latestRevisionId === sourceRevision.id) { + throw conflict("Selected revision is already the latest revision", { + currentRevisionId: existing.document.latestRevisionId, + }); + } + + const now = new Date(); + const nextRevisionNumber = existing.document.latestRevisionNumber + 1; + const [restoredRevision] = await tx.insert(documentRevisions).values({ + companyId: caseRow.companyId, + documentId: existing.document.id, + revisionNumber: nextRevisionNumber, + title: sourceRevision.title ?? null, + format: sourceRevision.format, + body: sourceRevision.body, + changeSummary: `Restored from revision ${sourceRevision.revisionNumber}`, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + createdAt: now, + }).returning(); + const [document] = await tx.update(documents).set({ + title: sourceRevision.title ?? null, + format: sourceRevision.format, + latestBody: sourceRevision.body, + latestRevisionId: restoredRevision!.id, + latestRevisionNumber: nextRevisionNumber, + updatedByAgentId: actor.agentId, + updatedByUserId: actor.actorType === "user" ? actor.actorId : null, + updatedAt: now, + }).where(eq(documents.id, existing.document.id)).returning(); + await tx.update(caseDocuments).set({ updatedAt: now }).where(eq(caseDocuments.documentId, existing.document.id)); + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "document_revised", + actor, + payload: { + key, + documentId: existing.document.id, + revisionId: restoredRevision!.id, + revisionNumber: restoredRevision!.revisionNumber, + restoredFromRevisionId: sourceRevision.id, + restoredFromRevisionNumber: sourceRevision.revisionNumber, + }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return { + document: caseDocumentResponse({ key, document: document! }), + revision: restoredRevision!, + restoredFromRevisionId: sourceRevision.id, + restoredFromRevisionNumber: sourceRevision.revisionNumber, + }; + }); + await logCaseAnnotationRemaps({ + caseRow, + key, + document: result.document, + body: result.document.body, + actor, + }); + res.json(result); + }); + + router.delete("/cases/:id/documents/:key", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + await db.transaction(async (tx) => { + await lockCaseDocumentKey(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + const link = await loadCaseDocumentLink(tx, { companyId: caseRow.companyId, caseId: caseRow.id, key }); + if (!link) return; + if (link.document.lockedAt) { + throw conflict("Document is locked", { + key, + documentId: link.document.id, + lockedAt: link.document.lockedAt, + }); + } + await tx.delete(caseDocuments).where(eq(caseDocuments.documentId, link.document.id)); + await tx.delete(documents).where(eq(documents.id, link.document.id)); + }); + res.json({ ok: true }); + }); + + router.post("/cases/:id/links", validate(createIssueLinkSchema), async (req, res) => { + await assertCasesEnabled(db); + const caseRow = await assertCaseAccess(db, req, req.params.id as string); + const actor = getActorInfo(req); + const body = req.body as z.infer; + + const result = await db.transaction(async (tx) => { + const issue = await tx + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, body.issueId), eq(issues.companyId, caseRow.companyId))) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!issue) throw unprocessable("Issue does not belong to case company"); + const now = new Date(); + const [link] = await tx.insert(caseIssueLinks).values({ + companyId: caseRow.companyId, + caseId: caseRow.id, + issueId: body.issueId, + role: body.role, + createdByRunId: actor.runId && isUuidLike(actor.runId) ? actor.runId : null, + createdAt: now, + updatedAt: now, + }).onConflictDoNothing({ + target: [caseIssueLinks.caseId, caseIssueLinks.issueId], + }).returning(); + if (link) { + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "issue_linked", + actor, + payload: { issueId: body.issueId, role: body.role, autoLinked: false }, + }); + } + return link ?? await tx + .select() + .from(caseIssueLinks) + .where(and(eq(caseIssueLinks.caseId, caseRow.id), eq(caseIssueLinks.issueId, body.issueId))) + .limit(1) + .then((rows) => rows[0]); + }); + res.status(201).json(result); + }); + + router.post("/cases/:id/attachments", async (req, res) => { + await assertCasesEnabled(db); + const caseRow = await assertCaseAccess(db, req, req.params.id as string); + const actor = getActorInfo(req); + const [company] = await db + .select({ attachmentMaxBytes: companies.attachmentMaxBytes }) + .from(companies) + .where(eq(companies.id, caseRow.companyId)) + .limit(1); + const maxBytes = company?.attachmentMaxBytes ?? 10 * 1024 * 1024; + + try { + await singleFileUpload(req, res, maxBytes); + } catch (err) { + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + throw unprocessable(`Attachment exceeds ${maxBytes} bytes`); + } + throw badRequest(err.message); + } + throw err; + } + const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file; + if (!file) throw badRequest("Missing file field 'file'"); + if (file.buffer.length <= 0) throw unprocessable("Attachment is empty"); + + const stored = await storage.putFile({ + companyId: caseRow.companyId, + namespace: `cases/${caseRow.id}`, + originalFilename: file.originalname || null, + contentType: normalizeContentType(file.mimetype), + body: file.buffer, + }); + const result = await db.transaction(async (tx) => { + const now = new Date(); + const [asset] = await tx.insert(assets).values({ + companyId: caseRow.companyId, + provider: stored.provider, + objectKey: stored.objectKey, + contentType: stored.contentType, + byteSize: stored.byteSize, + sha256: stored.sha256, + originalFilename: stored.originalFilename, + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + createdAt: now, + updatedAt: now, + }).returning(); + const [attachment] = await tx.insert(caseAttachments).values({ + companyId: caseRow.companyId, + caseId: caseRow.id, + assetId: asset!.id, + createdAt: now, + updatedAt: now, + }).returning(); + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "attachment_added", + actor, + payload: { attachmentId: attachment!.id, assetId: asset!.id, originalFilename: asset!.originalFilename }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return { ...attachment!, asset }; + }); + res.status(201).json(result); + }); + + router.get("/cases/:id/events", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const parsed = listEventsQuerySchema.safeParse(req.query); + if (!parsed.success) throw badRequest("Invalid case events query", parsed.error.issues); + const rows = await db + .select() + .from(caseEvents) + .where(and(eq(caseEvents.companyId, caseRow.companyId), eq(caseEvents.caseId, caseRow.id))) + .orderBy(desc(caseEvents.createdAt), desc(caseEvents.id)) + .limit(parsed.data.limit); + // Enrich each row with its actor's display name, run→issue attribution, + // and the linked issue captured in link/unlink payloads. + const payloadIssueIds = rows.map((row) => payloadIssueIdForEvent(row.kind, row.payload)); + const [agentNames, issueMap, payloadIssueMap] = await Promise.all([ + resolveAgentNames(db, rows.map((row) => row.actorAgentId)), + resolveIssuesForRuns(db, caseRow.companyId, rows.map((row) => row.runId)), + resolveIssuesByIds(db, caseRow.companyId, payloadIssueIds), + ]); + res.json(rows.map((row) => ({ + ...row, + actorAgentName: row.actorAgentId ? agentNames.get(row.actorAgentId) ?? null : null, + issue: payloadIssueMap.get(payloadIssueIdForEvent(row.kind, row.payload) ?? "") + ?? (row.runId ? issueMap.get(row.runId) ?? null : null), + }))); + }); + + router.get("/cases/:id/documents/:key/revisions", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const key = parseDocumentKey(req.params.key as string); + const link = await db + .select({ documentId: caseDocuments.documentId, document: documents }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and( + eq(caseDocuments.companyId, caseRow.companyId), + eq(caseDocuments.caseId, caseRow.id), + eq(caseDocuments.key, key), + )) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!link) throw notFound("Case document not found"); + const revisions = await db + .select() + .from(documentRevisions) + .where(and( + eq(documentRevisions.companyId, caseRow.companyId), + eq(documentRevisions.documentId, link.documentId), + )) + .orderBy(desc(documentRevisions.revisionNumber)); + const [agentNames, issueMap] = await Promise.all([ + resolveAgentNames(db, revisions.map((rev) => rev.createdByAgentId)), + resolveIssuesForRuns(db, caseRow.companyId, revisions.map((rev) => rev.createdByRunId)), + ]); + res.json({ + key, + document: { + id: link.document.id, + title: link.document.title, + format: link.document.format, + latestRevisionId: link.document.latestRevisionId, + latestRevisionNumber: link.document.latestRevisionNumber, + }, + revisions: revisions.map((rev) => ({ + id: rev.id, + revisionNumber: rev.revisionNumber, + title: rev.title, + format: rev.format, + body: rev.body, + changeSummary: rev.changeSummary, + createdAt: rev.createdAt, + createdByAgentId: rev.createdByAgentId, + createdByUserId: rev.createdByUserId, + createdByRunId: rev.createdByRunId, + actorAgentName: rev.createdByAgentId ? agentNames.get(rev.createdByAgentId) ?? null : null, + issue: rev.createdByRunId ? issueMap.get(rev.createdByRunId) ?? null : null, + })), + }); + }); + + router.get("/issues/:issueId/cases", async (req, res) => { + await assertCasesEnabled(db); + const issueIdOrIdentifier = (req.params.issueId as string).trim(); + const issue = await loadIssueByIdOrIdentifier(db, issueIdOrIdentifier, caseLookupCompanyIds(req)); + if (!issue) throw notFound("Issue not found"); + assertCompanyAccess(req, issue.companyId); + const rows = await db + .select({ link: caseIssueLinks, caseRow: cases }) + .from(caseIssueLinks) + .innerJoin(cases, eq(caseIssueLinks.caseId, cases.id)) + .where(and(eq(caseIssueLinks.companyId, issue.companyId), eq(caseIssueLinks.issueId, issue.id))) + .orderBy(asc(caseIssueLinks.createdAt)); + res.json(rows.map((row) => ({ + id: row.link.id, + role: row.link.role, + createdAt: row.link.createdAt, + case: { + id: row.caseRow.id, + identifier: row.caseRow.identifier, + title: row.caseRow.title, + caseType: row.caseRow.caseType, + status: row.caseRow.status, + }, + }))); + }); + + router.get("/cases/:id", async (req, res, next) => { + const row = await resolveSharedPathCase(db, req, req.params.id as string); + if (!row) return next(); + res.json(await loadCaseDetail(db, row)); + }); + + router.patch("/cases/:id", async (req, res, next) => { + const caseRow = await resolveSharedPathCase(db, req, req.params.id as string); + if (!caseRow) return next(); + const actor = getActorInfo(req); + const body = patchCaseSchema.parse(req.body); + const nextLabelIds = body.labelIds ?? body.labels; + + const updated = await db.transaction(async (tx) => { + await assertProjectBelongsToCompany(tx, { companyId: caseRow.companyId, projectId: body.projectId ?? null }); + await assertParentCaseBelongsToCompany(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + parentCaseId: body.parentCaseId ?? null, + }); + if (nextLabelIds) await assertLabelsBelongToCompany(tx, caseRow.companyId, nextLabelIds); + + const now = new Date(); + const [row] = await tx.update(cases).set(buildCasePatchUpdateValues(body, caseRow, now)).where(eq(cases.id, caseRow.id)).returning(); + + if (nextLabelIds) { + await lockCaseLabels(tx, { companyId: caseRow.companyId, caseId: caseRow.id }); + const current = await tx + .select({ labelId: caseLabels.labelId }) + .from(caseLabels) + .where(and(eq(caseLabels.companyId, caseRow.companyId), eq(caseLabels.caseId, caseRow.id))); + const currentIds = new Set(current.map((item) => item.labelId)); + const desiredIds = new Set(nextLabelIds); + const added = [...desiredIds].filter((id) => !currentIds.has(id)); + const removed = [...currentIds].filter((id) => !desiredIds.has(id)); + if (removed.length > 0) { + await tx.delete(caseLabels).where(and(eq(caseLabels.caseId, caseRow.id), inArray(caseLabels.labelId, removed))); + for (const labelId of removed) { + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "label_removed", + actor, + payload: { labelId }, + }); + } + } + if (added.length > 0) { + await tx.insert(caseLabels).values(added.map((labelId) => ({ + companyId: caseRow.companyId, + caseId: caseRow.id, + labelId, + createdAt: now, + updatedAt: now, + }))).onConflictDoNothing(); + for (const labelId of added) { + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind: "label_added", + actor, + payload: { labelId }, + }); + } + } + } + + const kind = body.status !== undefined + ? "status_changed" + : body.fields !== undefined + ? "fields_changed" + : Object.hasOwn(body, "parentCaseId") && body.parentCaseId + ? "child_linked" + : "updated"; + await insertCaseEvent(tx, { + companyId: caseRow.companyId, + caseId: caseRow.id, + kind, + actor, + payload: { + previousStatus: body.status !== undefined ? caseRow.status : undefined, + status: body.status, + parentCaseId: body.parentCaseId, + }, + }); + await autoLinkRunIssue(tx, { companyId: caseRow.companyId, caseId: caseRow.id, actor, role: "work" }); + return row!; + }); + res.json(await loadCaseDetail(db, updated)); + }); + + return router; +} diff --git a/server/src/services/document-annotations.ts b/server/src/services/document-annotations.ts index 207b7f2f52..8199a6ef41 100644 --- a/server/src/services/document-annotations.ts +++ b/server/src/services/document-annotations.ts @@ -5,6 +5,7 @@ import { documentAnnotationComments, documentAnnotationThreads, documents, + caseDocuments, issueComments, issueDocuments, routineDocuments, @@ -51,11 +52,22 @@ type RoutineDocumentRow = { latestRevisionNumber: number; }; +type CaseDocumentRow = { + caseId: string; + companyId: string; + documentId: string; + documentKey: string; + latestBody: string; + latestRevisionId: string | null; + latestRevisionNumber: number; +}; + const threadSelect = { id: documentAnnotationThreads.id, companyId: documentAnnotationThreads.companyId, issueId: documentAnnotationThreads.issueId, routineId: documentAnnotationThreads.routineId, + caseId: documentAnnotationThreads.caseId, documentId: documentAnnotationThreads.documentId, documentKey: documentAnnotationThreads.documentKey, status: documentAnnotationThreads.status, @@ -88,6 +100,7 @@ const commentSelect = { threadId: documentAnnotationComments.threadId, issueId: documentAnnotationComments.issueId, routineId: documentAnnotationComments.routineId, + caseId: documentAnnotationComments.caseId, documentId: documentAnnotationComments.documentId, body: documentAnnotationComments.body, authorType: documentAnnotationComments.authorType, @@ -154,6 +167,31 @@ export function documentAnnotationService(db: Db) { .then((rows: RoutineDocumentRow[]) => rows[0] ?? null); } + async function getCaseDocument( + caseId: string, + key: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select({ + caseId: caseDocuments.caseId, + companyId: documents.companyId, + documentId: documents.id, + documentKey: caseDocuments.key, + latestBody: documents.latestBody, + latestRevisionId: documents.latestRevisionId, + latestRevisionNumber: documents.latestRevisionNumber, + }) + .from(caseDocuments) + .innerJoin(documents, eq(caseDocuments.documentId, documents.id)) + .where(and( + eq(caseDocuments.caseId, caseId), + eq(caseDocuments.key, key), + eq(caseDocuments.companyId, documents.companyId), + )) + .then((rows: CaseDocumentRow[]) => rows[0] ?? null); + } + async function getThreadForIssue( issueId: string, documentKey: string, @@ -192,6 +230,27 @@ export function documentAnnotationService(db: Db) { .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); } + async function getThreadForCase( + caseId: string, + documentKey: string, + threadId: string, + companyId: string, + documentId: string, + dbOrTx: any = db, + ): Promise { + return dbOrTx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.id, threadId), + eq(documentAnnotationThreads.companyId, companyId), + eq(documentAnnotationThreads.caseId, caseId), + eq(documentAnnotationThreads.documentId, documentId), + eq(documentAnnotationThreads.documentKey, documentKey), + )) + .then((rows: DocumentAnnotationThread[]) => rows[0] ?? null); + } + async function commentsForThreads(threadIds: string[], dbOrTx: any = db): Promise { if (threadIds.length === 0) return []; return dbOrTx @@ -290,6 +349,40 @@ export function documentAnnotationService(db: Db) { })); }, + listThreadsForCaseDocument: async ( + caseId: string, + key: string, + options: { status?: "open" | "resolved" | "all"; includeComments?: boolean } = {}, + ) => { + const doc = await getCaseDocument(caseId, key); + if (!doc) throw notFound("Document not found"); + const conditions = [ + eq(documentAnnotationThreads.companyId, doc.companyId), + eq(documentAnnotationThreads.caseId, caseId), + eq(documentAnnotationThreads.documentId, doc.documentId), + ]; + if (options.status && options.status !== "all") { + conditions.push(eq(documentAnnotationThreads.status, options.status)); + } + const threads: DocumentAnnotationThread[] = await db + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and(...conditions)) + .orderBy(desc(documentAnnotationThreads.updatedAt), desc(documentAnnotationThreads.id)); + if (!options.includeComments) return threads; + const comments = await commentsForThreads(threads.map((thread) => thread.id)); + const commentsByThread = new Map(); + for (const comment of comments) { + const existing = commentsByThread.get(comment.threadId) ?? []; + existing.push(comment); + commentsByThread.set(comment.threadId, existing); + } + return threads.map((thread) => ({ + ...thread, + comments: commentsByThread.get(thread.id) ?? [], + })); + }, + getThreadForIssueDocument: async (issueId: string, key: string, threadId: string) => { const thread = await getThreadForIssue(issueId, key, threadId); if (!thread) return null; @@ -306,6 +399,15 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments }; }, + getThreadForCaseDocument: async (caseId: string, key: string, threadId: string) => { + const doc = await getCaseDocument(caseId, key); + if (!doc) return null; + const thread = await getThreadForCase(caseId, key, threadId, doc.companyId, doc.documentId); + if (!thread) return null; + const comments = await commentsForThreads([thread.id]); + return { ...thread, comments }; + }, + createThread: async ( issueId: string, key: string, @@ -481,6 +583,96 @@ export function documentAnnotationService(db: Db) { return { ...thread, comments: [comment] }; }), + createCaseThread: async ( + caseId: string, + key: string, + input: CreateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + await tx.execute(sql` + select ${documents.id} + from ${caseDocuments} + inner join ${documents} on ${caseDocuments.documentId} = ${documents.id} + where ${and(eq(caseDocuments.caseId, caseId), eq(caseDocuments.key, key))} + for update of ${documents} + `); + const doc = await getCaseDocument(caseId, key, tx); + if (!doc) throw notFound("Document not found"); + if ( + input.baseRevisionId !== doc.latestRevisionId + || input.baseRevisionNumber !== doc.latestRevisionNumber + ) { + throw conflict("Annotation anchor requires the current document revision", { + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + }); + } + + const verification = verifyDocumentAnchorSelector({ + markdown: doc.latestBody, + selector: input.selector, + }); + if (!verification.ok || !verification.anchor) { + throw unprocessable("Annotation anchor does not match the current document revision", { + reason: verification.reason, + }); + } + + const now = new Date(); + const [thread] = await tx + .insert(documentAnnotationThreads) + .values({ + companyId: doc.companyId, + issueId: null, + routineId: null, + caseId, + documentId: doc.documentId, + documentKey: doc.documentKey, + status: "open", + anchorState: "active", + anchorConfidence: "exact", + originalRevisionId: doc.latestRevisionId, + originalRevisionNumber: doc.latestRevisionNumber, + currentRevisionId: doc.latestRevisionId, + currentRevisionNumber: doc.latestRevisionNumber, + selectedText: verification.anchor.selectedText, + prefixText: verification.anchor.prefixText, + suffixText: verification.anchor.suffixText, + normalizedStart: verification.anchor.normalizedStart, + normalizedEnd: verification.anchor.normalizedEnd, + markdownStart: verification.anchor.markdownStart, + markdownEnd: verification.anchor.markdownEnd, + anchorSelector: input.selector, + createdByAgentId: actor.agentId ?? null, + createdByUserId: actor.userId ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(threadSelect); + + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: doc.companyId, + threadId: thread.id, + issueId: null, + routineId: null, + caseId, + documentId: doc.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + + return { ...thread, comments: [comment] }; + }), + addComment: async ( issueId: string, key: string, @@ -553,6 +745,44 @@ export function documentAnnotationService(db: Db) { return comment; }), + addCaseComment: async ( + caseId: string, + key: string, + threadId: string, + input: CreateDocumentAnnotationComment, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const doc = await getCaseDocument(caseId, key, tx); + if (!doc) throw notFound("Document not found"); + const thread = await getThreadForCase(caseId, key, threadId, doc.companyId, doc.documentId, tx); + if (!thread) throw notFound("Annotation thread not found"); + const now = new Date(); + const [comment] = await tx + .insert(documentAnnotationComments) + .values({ + companyId: thread.companyId, + threadId: thread.id, + issueId: null, + routineId: null, + caseId: thread.caseId, + documentId: thread.documentId, + body: input.body, + authorType: actor.actorType, + authorAgentId: actor.agentId ?? null, + authorUserId: actor.userId ?? null, + createdByRunId: actor.runId ?? null, + issueCommentId: null, + createdAt: now, + updatedAt: now, + }) + .returning(commentSelect); + await tx + .update(documentAnnotationThreads) + .set({ updatedAt: now }) + .where(eq(documentAnnotationThreads.id, thread.id)); + return comment; + }), + cleanupForIssueCommentDeletion: async ( issueId: string, issueCommentId: string, @@ -688,6 +918,42 @@ export function documentAnnotationService(db: Db) { return updated; }), + updateCaseThread: async ( + caseId: string, + key: string, + threadId: string, + input: UpdateDocumentAnnotationThread, + actor: ActorInput, + ) => db.transaction(async (tx) => { + const doc = await getCaseDocument(caseId, key, tx); + if (!doc) throw notFound("Document not found"); + const thread = await getThreadForCase(caseId, key, threadId, doc.companyId, doc.documentId, tx); + if (!thread) throw notFound("Annotation thread not found"); + if (!input.status || input.status === thread.status) return thread; + + const now = new Date(); + const [updated] = await tx + .update(documentAnnotationThreads) + .set(input.status === "resolved" + ? { + status: "resolved", + resolvedByAgentId: actor.agentId ?? null, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + } + : { + status: "open", + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: null, + updatedAt: now, + }) + .where(eq(documentAnnotationThreads.id, thread.id)) + .returning(threadSelect); + return updated; + }), + remapOpenThreadsForDocument: async (input: { issueId: string; key: string; @@ -838,6 +1104,81 @@ export function documentAnnotationService(db: Db) { return changed; }), + remapOpenThreadsForCaseDocument: async (input: { + caseId: string; + key: string; + documentId: string; + nextRevisionId: string | null; + nextRevisionNumber: number; + nextBody: string; + }) => db.transaction(async (tx) => { + const threads: DocumentAnnotationThread[] = await tx + .select(threadSelect) + .from(documentAnnotationThreads) + .where(and( + eq(documentAnnotationThreads.caseId, input.caseId), + eq(documentAnnotationThreads.documentId, input.documentId), + eq(documentAnnotationThreads.status, "open"), + )); + const changed = []; + const now = new Date(); + + for (const thread of threads) { + if (thread.currentRevisionId === input.nextRevisionId) continue; + const previousAnchor = snapshotFromThread(thread); + const remap = remapDocumentAnchor({ + previousAnchor, + nextMarkdown: input.nextBody, + }); + const nextAnchor = remap.anchor; + const nextSelector = nextAnchor ? anchorSnapshotToSelector(nextAnchor) : thread.anchorSelector; + const [updated] = await tx + .update(documentAnnotationThreads) + .set({ + currentRevisionId: input.nextRevisionId, + currentRevisionNumber: input.nextRevisionNumber, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + ...(nextAnchor + ? { + selectedText: nextAnchor.selectedText, + prefixText: nextAnchor.prefixText, + suffixText: nextAnchor.suffixText, + normalizedStart: nextAnchor.normalizedStart, + normalizedEnd: nextAnchor.normalizedEnd, + markdownStart: nextAnchor.markdownStart, + markdownEnd: nextAnchor.markdownEnd, + } + : {}), + anchorSelector: nextSelector, + updatedAt: now, + }) + .where(eq(documentAnnotationThreads.id, thread.id)) + .returning(threadSelect); + const [snapshot] = await tx + .insert(documentAnnotationAnchorSnapshots) + .values({ + companyId: thread.companyId, + threadId: thread.id, + documentId: thread.documentId, + fromRevisionId: thread.currentRevisionId, + fromRevisionNumber: thread.currentRevisionNumber, + toRevisionId: input.nextRevisionId, + toRevisionNumber: input.nextRevisionNumber, + previousAnchor, + nextAnchor, + anchorState: remap.anchorState, + anchorConfidence: remap.confidence, + failureReason: remap.anchor ? null : remap.reason, + createdAt: now, + }) + .returning(); + changed.push({ thread: updated, snapshot }); + } + + return changed; + }), + selectorToAnchorSnapshot, }; } diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 4301adf3ba..01e33d1163 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -48,6 +48,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, enablePipelines: parsed.data.enablePipelines ?? false, + enableCases: parsed.data.enableCases ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, @@ -72,6 +73,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, enablePipelines: false, + enableCases: false, enableConferenceRoomChat: false, enableTaskWatchdogs: false, enableIssuePlanDecompositions: false, diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 3c9b66555c..bf4d86634b 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -269,6 +269,11 @@ Load `references/workflows.md` when the task matches one of these: - CEO-safe company imports/exports (preview/apply). - App-level self-test playbook. +## Cases + +Load `references/cases.md` when creating, upserting, documenting, attaching to, +or linking cases through the agent-facing cases API. + ## Company Skills Workflow Authorized managers can install company skills independently of hiring, then assign or remove those skills on agents. diff --git a/skills/paperclip/references/cases.md b/skills/paperclip/references/cases.md new file mode 100644 index 0000000000..99ba843736 --- /dev/null +++ b/skills/paperclip/references/cases.md @@ -0,0 +1,295 @@ +# Cases + +Cases are agent-owned work records for durable outputs such as blog posts, +research packets, release notes, incidents, QA runs, or generated asset sets. +They are company-scoped and live beside issues: issues coordinate work, while +cases preserve the structured object an agent is producing. + +Cases are experimental and must be enabled with `experimental.enableCases`. +If a route returns `403 Cases are disabled`, stop and report that the operator +must enable cases before the skill can use this surface. + +## Core Model + +A case has: + +- `identifier`: server-assigned display id such as `PAP-C42` +- `caseType`: skill-owned type such as `blog_post`, `image_assets`, or `incident` +- `key`: optional deterministic upsert key inside `(companyId, caseType)` +- `title` and optional `summary` +- `status`: `draft`, `in_progress`, `in_review`, `approved`, `done`, or `cancelled` +- `fields`: JSON object owned by the skill using the case +- `parentCaseId`: optional parent case for child work +- documents, attachments, issue links, labels, and events + +Use deterministic `caseType` + `key` when a skill may be retried. Repeating +`POST /api/companies/:companyId/cases` with the same `caseType` and `key` +upserts the same case instead of creating a duplicate. + +## Upsert Semantics + +`POST /api/companies/:companyId/cases` creates or upserts a case. + +Request: + +```json +{ + "caseType": "blog_post", + "key": "launch-announcement", + "title": "Launch announcement", + "summary": "Draft launch post for operators.", + "status": "draft", + "fields": { + "slug": "launch-announcement", + "target_audience": "operators" + } +} +``` + +Response: + +- `201` when a new case was created +- `200` when an existing `(caseType, key)` case was updated + +Field behavior on upsert: + +- `title` is required and replaces the previous title. +- `projectId`, `summary`, `status`, `fields`, and `parentCaseId` replace the + previous value when present. +- Omitted optional values preserve the previous value during upsert. +- `fields` is replaced as a whole object when provided. It is not deep-merged. + Send the complete desired JSON object each time. +- Concurrent retries with the same `(caseType, key)` converge to one case. + +Do not use a random `key` for retryable skills. Use a stable content slug, +external id, source URL hash, or parent-derived request key. + +## Read And Search + +Get a case by UUID or identifier: + +```http +GET /api/cases/PAP-C42 +``` + +List cases for a company: + +```http +GET /api/companies/:companyId/cases?type=blog_post&status=active&q=launch +``` + +Useful filters: + +- `type`: exact `caseType` +- `status`: exact lifecycle status, or `active` for non-terminal cases +- `projectId` / `project`: project UUID +- `labelId` / `label`: label UUID +- `q`: identifier, title, summary, or key search +- `limit`: 1-200, default 100 + +## Documents + +Use case documents for rich bodies such as drafts, briefs, reports, or plans. + +```http +PUT /api/cases/:caseIdOrIdentifier/documents/body +Content-Type: application/json + +{ + "title": "Launch announcement body", + "format": "markdown", + "body": "# Launch announcement\n\nDraft copy...", + "changeSummary": "Initial draft" +} +``` + +Updating an existing case document requires `baseRevisionId`: + +```json +{ + "baseRevisionId": "latest-revision-uuid", + "body": "Updated body" +} +``` + +If you get `409 stale_base_revision`, refetch the case detail, read the latest +document revision id, merge intentionally, and retry with that `baseRevisionId`. + +## Fields + +Each skill owns the schema of `fields` for the `caseType` it creates. Keep fields +small, typed, and stable enough for other agents to inspect. + +Examples: + +```json +{ + "slug": "launch-announcement", + "target_audience": "operators", + "publish_url": "https://example.com/blog/launch-announcement" +} +``` + +Patch fields or status with: + +```http +PATCH /api/cases/:caseIdOrIdentifier +Content-Type: application/json + +{ + "status": "in_review", + "fields": { + "slug": "launch-announcement", + "target_audience": "operators", + "publish_url": "https://example.com/blog/launch-announcement" + } +} +``` + +Remember: `fields` replaces the whole object when present. + +## Issue Links + +Link cases to issues explicitly when needed: + +```http +POST /api/cases/:caseIdOrIdentifier/links +Content-Type: application/json + +{ + "issueId": "issue-uuid", + "role": "reference" +} +``` + +Roles: + +- `origin`: the issue/run that created the case +- `work`: an issue/run that changed the case +- `reference`: related issue context + +Agent run writes auto-link the run's issue when Paperclip can resolve it from +the run JWT or `X-Paperclip-Run-Id`. Creation/upsert writes use `origin`; later +document, patch, and attachment writes use `work` when no link already exists. +You do not need to manually link the current issue before writing the case. + +## Child Cases + +Create child cases by setting `parentCaseId` to the parent case UUID. + +```json +{ + "caseType": "image_assets", + "key": "launch-announcement:hero-images", + "title": "Hero images for launch announcement", + "parentCaseId": "parent-case-uuid", + "fields": { + "required_assets": ["hero", "social-card"] + } +} +``` + +Use child cases when the output has independently inspectable pieces or when +another agent can work on a bounded part without editing the parent case body. + +## Attachments + +Attach generated files with multipart form data: + +```http +POST /api/cases/:caseIdOrIdentifier/attachments +Content-Type: multipart/form-data + +file=@hero.png +``` + +The server records an asset and adds an `attachment_added` case event. + +## Lifecycle + +Use the lifecycle consistently: + +- `draft`: case exists but useful work has not started +- `in_progress`: an agent is actively producing or revising it +- `in_review`: ready for reviewer, board, or downstream approval +- `approved`: accepted but not finally shipped or archived +- `done`: complete and no further action remains +- `cancelled`: intentionally abandoned + +Terminal statuses are `done` and `cancelled`; setting either records +`completedAt`. Moving back to a non-terminal status clears `completedAt`. + +## Worked Blog Post Example + +Create or upsert the parent blog post: + +```http +POST /api/companies/:companyId/cases +Content-Type: application/json + +{ + "caseType": "blog_post", + "key": "paperclip-cases-launch", + "title": "Introducing Paperclip Cases", + "summary": "Blog post explaining the cases surface for agent outputs.", + "status": "in_progress", + "fields": { + "slug": "paperclip-cases-launch", + "target_audience": "AI company operators", + "publish_url": null + } +} +``` + +Write the body: + +```http +PUT /api/cases/PAP-C42/documents/body +Content-Type: application/json + +{ + "title": "Introducing Paperclip Cases", + "format": "markdown", + "body": "# Introducing Paperclip Cases\n\n..." +} +``` + +Create the child image-assets case: + +```http +POST /api/companies/:companyId/cases +Content-Type: application/json + +{ + "caseType": "image_assets", + "key": "paperclip-cases-launch:image-assets", + "title": "Image assets for Introducing Paperclip Cases", + "parentCaseId": "parent-case-uuid", + "status": "in_progress", + "fields": { + "slug": "paperclip-cases-launch", + "required_assets": ["hero", "social-card"], + "publish_url": null + } +} +``` + +Attach generated assets to the child, then patch both cases as they move through +review: + +```http +PATCH /api/cases/PAP-C42 +Content-Type: application/json + +{ + "status": "in_review", + "fields": { + "slug": "paperclip-cases-launch", + "target_audience": "AI company operators", + "publish_url": "https://example.com/blog/paperclip-cases-launch" + } +} +``` + +If the same skill retries the example with the same keys, it updates the parent +and child cases rather than creating duplicates. diff --git a/ui/src/App.cases-routing.test.tsx b/ui/src/App.cases-routing.test.tsx new file mode 100644 index 0000000000..d555a52357 --- /dev/null +++ b/ui/src/App.cases-routing.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom + +// Regression guard for PAP-13002: the experimental Cases UI emits *unprefixed* +// links (`/cases`, `/cases/:id`) — the same global-unprefixed pattern Pipelines +// uses. Those only resolve if `cases` and `cases/:caseIdentifier` are registered +// as reserved unprefixed redirect routes in ; otherwise the first path +// segment is parsed as a company prefix ("CASES") and the page 404s with +// "No company matches prefix". This drives the real route table so a +// future removal of those redirect routes fails loudly. + +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// jsdom's CSS parser rejects the custom-property marker rule stitches inserts +// (`--sxs{--sxs:N}`), pulled into 's eager import graph transitively via +// @codesandbox/sandpack-react. Substitute a benign, valid rule on parse failure +// so stitches' index bookkeeping stays intact and the module graph evaluates. +// (sandpack itself is never exercised by the routing under test.) +beforeAll(() => { + const sheetProto = window.CSSStyleSheet.prototype as unknown as { + insertRule: (rule: string, index?: number) => number; + __pap13002Patched?: boolean; + }; + if (!sheetProto.__pap13002Patched) { + const original = sheetProto.insertRule; + sheetProto.insertRule = function patched(this: CSSStyleSheet, rule: string, index?: number) { + try { + return original.call(this, rule, index); + } catch { + try { + return original.call(this, ".pap13002-noop{}", index); + } catch { + return this.cssRules?.length ?? 0; + } + } + }; + sheetProto.__pap13002Patched = true; + } +}); + +// Real Layout renders the full authenticated shell (sidebar, data queries) and +// owns the "No company matches prefix" NotFound. For routing we only need it to +// resolve the :companyPrefix segment and render its nested routes. +vi.mock("./components/Layout", async () => { + const { Outlet } = await import("react-router-dom"); + return { Layout: () => }; +}); + +// The experimental gate would otherwise hide the page behind a feature flag. +vi.mock("./components/CasesExperimentalGate", () => ({ + CasesExperimentalGate: ({ children }: { children: ReactNode }) => <>{children}, +})); + +// Rendered by outside and needs DialogProvider; irrelevant here. +vi.mock("./components/OnboardingWizardVariant", () => ({ + OnboardingWizardVariant: () => null, +})); + +// Sentinel pages so we can assert *which* route resolved. +vi.mock("./pages/Cases", () => ({ Cases: () =>
CASES_LIST_PAGE
})); +vi.mock("./pages/CaseDetail", () => ({ CaseDetail: () =>
CASE_DETAIL_PAGE
})); + +// CloudAccessGate must fall through to (authorized w/ company access). +const mockHealthApi = vi.hoisted(() => ({ get: vi.fn() })); +const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); +const mockAccessApi = vi.hoisted(() => ({ + getCurrentBoardAccess: vi.fn(), + claimBootstrapAdmin: vi.fn(), +})); +vi.mock("./api/health", () => ({ healthApi: mockHealthApi })); +vi.mock("./api/auth", () => ({ authApi: mockAuthApi })); +vi.mock("./api/access", () => ({ accessApi: mockAccessApi })); + +// The prefix resolver + redirect logic both read the active company. +const PAP_COMPANY = { + id: "company-1", + name: "Paperclip", + issuePrefix: "PAP", + status: "active", +}; +vi.mock("./context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [PAP_COMPANY], + selectedCompanyId: PAP_COMPANY.id, + selectedCompany: PAP_COMPANY, + loading: false, + }), + CompanyProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +async function flushReact() { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +async function waitForText(container: HTMLElement, text: string) { + for (let attempt = 0; attempt < 25; attempt += 1) { + if (container.textContent?.includes(text)) return; + await flushReact(); + } + expect(container.textContent).toContain(text); +} + +async function renderAppAt(container: HTMLElement, path: string) { + const { App } = await import("./App"); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root.render( + + + + + , + ); + }); + return root; +} + +describe("App Cases routing (PAP-13002)", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "private", + bootstrapStatus: "ready", + }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: [PAP_COMPANY.id], + source: "session", + keyId: null, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("redirects unprefixed /cases to the company-prefixed list page", async () => { + const root = await renderAppAt(container, "/cases"); + await waitForText(container, "CASES_LIST_PAGE"); + expect(container.textContent).not.toContain("No company matches prefix"); + flushSync(() => root.unmount()); + }, 20000); + + it("redirects unprefixed /cases/:id to the company-prefixed detail page", async () => { + const root = await renderAppAt(container, "/cases/PAP-C5"); + await waitForText(container, "CASE_DETAIL_PAGE"); + expect(container.textContent).not.toContain("No company matches prefix"); + flushSync(() => root.unmount()); + }, 20000); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b09498d7e9..076484b302 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,9 @@ import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; +import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; +import { Cases } from "./pages/Cases"; +import { CaseDetail } from "./pages/CaseDetail"; import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; @@ -145,6 +148,14 @@ function boardRoutes() { } /> ) : null} } /> + } + /> + } + /> } @@ -444,6 +455,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/cases.ts b/ui/src/api/cases.ts new file mode 100644 index 0000000000..a43738e5d6 --- /dev/null +++ b/ui/src/api/cases.ts @@ -0,0 +1,332 @@ +import type { DocumentRevision, IssueDocument, IssueLabel } from "@paperclipai/shared"; +import { api } from "./client"; + +// ----------------------------------------------------------------------------- +// Cases API (experimental — PAP-12947). Mirrors server/src/routes/cases.ts. +// Human-writable in v1 = status + labels only; everything else is agent-authored. +// ----------------------------------------------------------------------------- + +export const CASE_STATUSES = [ + "draft", + "in_progress", + "in_review", + "approved", + "done", + "cancelled", +] as const; +export type CaseStatus = (typeof CASE_STATUSES)[number]; + +/** Statuses hidden by the list's default `Active` filter. */ +export const TERMINAL_CASE_STATUSES: readonly CaseStatus[] = ["done", "cancelled"]; + +export type CaseLinkRole = "origin" | "work" | "reference"; + +/** A case row as returned by the list endpoint. */ +export interface CaseSummary { + id: string; + companyId: string; + projectId: string | null; + caseNumber: number; + identifier: string; + caseType: string; + key: string | null; + title: string; + summary: string | null; + status: CaseStatus; + fields: Record; + parentCaseId: string | null; + createdByAgentId: string | null; + createdByUserId: string | null; + completedAt: string | null; + createdAt: string; + updatedAt: string; + /** + * Present only when `includeAncestors` is requested. Ancestor rows included for + * tree context are `false`; rows that matched the list query are `true`. + */ + matchesListFilters?: boolean; +} + +export interface CaseDocumentRef { + key: string; + document: CaseDocument; +} + +export interface CaseDocument { + id: string; + companyId: string; + title: string | null; + format: string; + latestBody: string | null; + latestRevisionId: string | null; + latestRevisionNumber: number | null; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + lockedAt: string | null; + lockedByAgentId: string | null; + lockedByUserId: string | null; + sourceTrust?: IssueDocument["sourceTrust"]; + createdAt: string; + updatedAt: string; +} + +export interface CaseIssueLink { + id: string; + caseId: string; + issueId: string; + role: CaseLinkRole; + createdAt: string; + issue: { + id: string; + identifier: string; + title: string; + status: string; + }; +} + +export interface CaseAttachmentRef { + id: string; + asset: { + id: string; + contentType: string; + byteSize: number; + originalFilename: string | null; + }; + createdAt: string; + updatedAt: string; +} + +/** A lightweight parent reference embedded in the detail payload. */ +export interface CaseParentRef { + id: string; + identifier: string; + title: string; + caseType: string; + status: CaseStatus; +} + +/** Content URL for an attachment's asset (served by the assets route). */ +export function caseAttachmentUrl(attachment: CaseAttachmentRef): string { + return `/api/assets/${attachment.asset.id}/content`; +} + +export function isImageAttachment(attachment: CaseAttachmentRef): boolean { + return attachment.asset.contentType.startsWith("image/"); +} + +/** The full detail payload (loadCaseDetail on the server). */ +export interface CaseDetail extends CaseSummary { + parent: CaseParentRef | null; + labels: IssueLabel[]; + issueLinks: CaseIssueLink[]; + documents: CaseDocumentRef[]; + attachments: CaseAttachmentRef[]; +} + +export type CaseEventKind = + | "created" + | "updated" + | "fields_changed" + | "status_changed" + | "issue_linked" + | "issue_unlinked" + | "document_revised" + | "child_linked" + | "attachment_added" + | "label_added" + | "label_removed"; + +/** Run→issue attribution shared by feed rows and revisions. */ +export interface CaseAttributionIssue { + id: string; + identifier: string; + title: string; + status: string; +} + +export interface CaseEvent { + id: string; + caseId: string; + kind: CaseEventKind; + actorType: "user" | "agent" | "system"; + actorUserId: string | null; + actorAgentId: string | null; + runId: string | null; + payload: Record; + createdAt: string; + /** Display name of the acting agent (P4 enrichment), null for user/system. */ + actorAgentName: string | null; + /** Issue linked by this event, or the issue whose run produced it. */ + issue: CaseAttributionIssue | null; +} + +/** One revision of a case document, with author + via-issue attribution. */ +export interface CaseDocumentRevision { + id: string; + companyId?: string; + documentId?: string; + revisionNumber: number; + title: string; + format: string; + body: string | null; + changeSummary: string | null; + createdAt: string; + createdByAgentId: string | null; + createdByUserId: string | null; + createdByRunId: string | null; + actorAgentName: string | null; + issue: CaseAttributionIssue | null; +} + +export interface CaseDocumentRevisions { + key: string; + document: { + id: string; + title: string; + format: string; + latestRevisionId: string | null; + latestRevisionNumber: number | null; + }; + revisions: CaseDocumentRevision[]; +} + +/** A case linked to an issue, as returned by the issue-page rail endpoint. */ +export interface IssueCaseLink { + id: string; + role: CaseLinkRole; + createdAt: string; + case: { + id: string; + identifier: string; + title: string; + caseType: string; + status: CaseStatus; + }; +} + +export interface ListCasesParams { + type?: string; + types?: string[]; + status?: string; + statuses?: string[]; + projectId?: string; + projectIds?: string[]; + includeNoProject?: boolean; + labelId?: string; + /** Filter to direct children of a parent case id (P4 children tree). */ + parent?: string; + q?: string; + includeAncestors?: boolean; + limit?: number; +} + +function appendAll(search: URLSearchParams, key: string, values: readonly string[] | undefined) { + for (const value of values ?? []) search.append(key, value); +} + +function toQuery(params: ListCasesParams): string { + const search = new URLSearchParams(); + if (params.type) search.set("type", params.type); + appendAll(search, "types", params.types); + if (params.status) search.set("status", params.status); + appendAll(search, "statuses", params.statuses); + if (params.projectId) search.set("projectId", params.projectId); + appendAll(search, "projectIds", params.projectIds); + if (params.includeNoProject) search.set("includeNoProject", "true"); + if (params.labelId) search.set("labelId", params.labelId); + if (params.parent) search.set("parent", params.parent); + if (params.q) search.set("q", params.q); + if (params.includeAncestors) search.set("includeAncestors", "true"); + if (params.limit != null) search.set("limit", String(params.limit)); + const qs = search.toString(); + return qs ? `?${qs}` : ""; +} + +export interface PatchCaseInput { + status?: CaseStatus; + labelIds?: string[]; +} + +export function caseDocumentToIssueDocument(caseId: string, key: string, document: CaseDocument): IssueDocument { + return { + id: document.id, + companyId: document.companyId, + issueId: caseId, + key, + title: document.title, + format: "markdown", + body: document.latestBody ?? "", + latestRevisionId: document.latestRevisionId, + latestRevisionNumber: document.latestRevisionNumber ?? 1, + createdByAgentId: document.createdByAgentId, + createdByUserId: document.createdByUserId, + updatedByAgentId: document.updatedByAgentId, + updatedByUserId: document.updatedByUserId, + lockedAt: document.lockedAt ? new Date(document.lockedAt) : null, + lockedByAgentId: document.lockedByAgentId, + lockedByUserId: document.lockedByUserId, + sourceTrust: document.sourceTrust, + createdAt: new Date(document.createdAt), + updatedAt: new Date(document.updatedAt), + }; +} + +export function caseRevisionToDocumentRevision(caseId: string, key: string, revision: CaseDocumentRevision): DocumentRevision { + return { + id: revision.id, + companyId: revision.companyId ?? "", + documentId: revision.documentId ?? "", + issueId: caseId, + key, + revisionNumber: revision.revisionNumber, + title: revision.title, + format: "markdown", + body: revision.body ?? "", + changeSummary: revision.changeSummary, + createdByAgentId: revision.createdByAgentId, + createdByUserId: revision.createdByUserId, + createdAt: new Date(revision.createdAt), + }; +} + +export const casesApi = { + list: (companyId: string, params: ListCasesParams = {}) => + api.get(`/companies/${companyId}/cases${toQuery(params)}`), + get: (idOrIdentifier: string) => api.get(`/cases/${idOrIdentifier}`), + patch: (idOrIdentifier: string, input: PatchCaseInput) => + api.patch(`/cases/${idOrIdentifier}`, input), + listEvents: (idOrIdentifier: string, limit = 100) => + api.get(`/cases/${idOrIdentifier}/events?limit=${limit}`), + listChildren: (companyId: string, parentId: string) => + api.get(`/companies/${companyId}/cases${toQuery({ parent: parentId, limit: 200 })}`), + getDocument: (idOrIdentifier: string, key: string) => + api.get(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}`), + upsertDocument: ( + idOrIdentifier: string, + key: string, + data: { title?: string | null; format?: string; body: string; baseRevisionId?: string | null }, + ) => + api.put<{ document: CaseDocument & { key: string; body: string }; revision: CaseDocumentRevision }>( + `/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}`, + data, + ), + lockDocument: (idOrIdentifier: string, key: string) => + api.post(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/lock`, {}), + unlockDocument: (idOrIdentifier: string, key: string) => + api.post(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/unlock`, {}), + restoreDocumentRevision: (idOrIdentifier: string, key: string, revisionId: string) => + api.post<{ + document: CaseDocument & { key: string; body: string }; + revision: CaseDocumentRevision; + restoredFromRevisionId: string; + restoredFromRevisionNumber: number; + }>(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`, {}), + deleteDocument: (idOrIdentifier: string, key: string) => + api.delete<{ ok: true }>(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}`), + listRevisions: (idOrIdentifier: string, key: string) => + api.get(`/cases/${idOrIdentifier}/documents/${encodeURIComponent(key)}/revisions`), + listForIssue: (issueIdOrIdentifier: string) => + api.get(`/issues/${issueIdOrIdentifier}/cases`), +}; diff --git a/ui/src/api/document-annotations.ts b/ui/src/api/document-annotations.ts index 5a939c3a4c..7292d73056 100644 --- a/ui/src/api/document-annotations.ts +++ b/ui/src/api/document-annotations.ts @@ -13,6 +13,7 @@ export type DocumentAnnotationListFilter = "open" | "resolved" | "all"; export type DocumentAnnotationTarget = | { kind: "issue"; issueId: string; documentKey: string } + | { kind: "case"; caseId: string; documentKey: string } | { kind: "routine"; routineId: string; documentKey: "description" }; function issueTarget(issueId: string, documentKey: string): DocumentAnnotationTarget { @@ -23,6 +24,9 @@ function targetBasePath(target: DocumentAnnotationTarget) { if (target.kind === "routine") { return `/routines/${target.routineId}/description/annotations`; } + if (target.kind === "case") { + return `/cases/${target.caseId}/documents/${encodeURIComponent(target.documentKey)}/annotations`; + } return `/issues/${target.issueId}/documents/${encodeURIComponent(target.documentKey)}/annotations`; } diff --git a/ui/src/components/CaseActivityFeed.test.tsx b/ui/src/components/CaseActivityFeed.test.tsx new file mode 100644 index 0000000000..4279dd3cb5 --- /dev/null +++ b/ui/src/components/CaseActivityFeed.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CaseActivityFeed } from "./CaseActivityFeed"; +import type { CaseEvent } from "@/api/cases"; + +function act(callback: () => void) { + flushSync(callback); +} + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function event(overrides: Partial): CaseEvent { + return { + id: Math.random().toString(36).slice(2), + caseId: "case-1", + kind: "created", + actorType: "system", + actorUserId: null, + actorAgentId: null, + runId: null, + payload: {}, + createdAt: "2026-07-07T00:00:00.000Z", + actorAgentName: null, + issue: null, + ...overrides, + }; +} + +describe("CaseActivityFeed", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => { + container.remove(); + }); + + function render(events: CaseEvent[]) { + const root = createRoot(container); + act(() => root.render()); + return root; + } + + it("shows the empty state when there are no events", () => { + const root = render([]); + expect(container.textContent).toContain("No activity yet"); + act(() => root.unmount()); + }); + + it("renders actor name and run→issue attribution", () => { + const root = render([ + event({ + kind: "document_revised", + actorType: "agent", + actorAgentId: "agent-1", + actorAgentName: "Cases Agent", + runId: "run-1", + issue: { id: "i1", identifier: "PAP-42", title: "Source task", status: "in_progress" }, + }), + ]); + const text = container.textContent ?? ""; + expect(text).toContain("document revised"); + expect(text).toContain("Cases Agent"); + expect(text).toContain("via"); + // The issue chip links to the issue detail. + const issueLink = container.querySelector('a[href="/issues/PAP-42"]'); + expect(issueLink?.textContent).toContain("PAP-42"); + expect(issueLink?.textContent).toContain("Source task"); + act(() => root.unmount()); + }); + + it("renders an auto-link event as a system actor with a linked issue", () => { + const root = render([ + event({ + kind: "issue_linked", + actorType: "system", + issue: { id: "i2", identifier: "PAP-9", title: "Auto", status: "todo" }, + }), + ]); + const text = container.textContent ?? ""; + expect(text).toContain("issue linked"); + expect(text).toContain("System"); + expect(text).toContain("issue"); + expect(container.querySelector('a[href="/issues/PAP-9"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("filters rows by kind when a filter chip is toggled", () => { + const root = render([ + event({ kind: "created" }), + event({ kind: "status_changed", payload: { previousStatus: "draft", status: "in_review" } }), + ]); + // The status-transition detail only appears in the status_changed row. + expect(container.textContent).toContain("draft → in_review"); + + // Open the activity filter dropdown and choose "created"; only created + // rows remain, so the status-transition detail disappears. + const filterButton = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.includes("All activity"), + ); + expect(filterButton).toBeTruthy(); + act(() => filterButton!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + const createdItem = Array.from(document.body.querySelectorAll('[role="menuitemcheckbox"]')).find( + (item) => item.textContent === "created", + ); + expect(createdItem).toBeTruthy(); + act(() => createdItem!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(container.textContent).not.toContain("draft → in_review"); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseActivityFeed.tsx b/ui/src/components/CaseActivityFeed.tsx new file mode 100644 index 0000000000..b404c71501 --- /dev/null +++ b/ui/src/components/CaseActivityFeed.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState } from "react"; +import { Link } from "@/lib/router"; +import { Bot, User, Cog, ChevronDown, ListFilter } from "lucide-react"; +import type { CaseEvent, CaseEventKind } from "@/api/cases"; +import { Button } from "@/components/ui/button"; +import { StatusIcon } from "@/components/StatusIcon"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn, relativeTime } from "@/lib/utils"; + +const EVENT_LABEL: Record = { + created: "created", + updated: "updated", + fields_changed: "fields changed", + status_changed: "status changed", + issue_linked: "issue linked", + issue_unlinked: "issue unlinked", + document_revised: "document revised", + child_linked: "child linked", + attachment_added: "attachment added", + label_added: "label added", + label_removed: "label removed", +}; + +/** Human label for the actor, preferring the resolved agent name. */ +function actorLabel(event: CaseEvent): string { + if (event.actorType === "agent") return event.actorAgentName ?? "Agent"; + if (event.actorType === "user") return "User"; + return "System"; +} + +function ActorIcon({ event }: { event: CaseEvent }) { + const Icon = event.actorType === "agent" ? Bot : event.actorType === "user" ? User : Cog; + return ; +} + +function issueRelationLabel(event: CaseEvent): string { + return event.kind === "issue_linked" || event.kind === "issue_unlinked" ? "issue" : "via"; +} + +/** One event with actor + run→issue attribution (P4 §1). */ +export function CaseEventRow({ event, compact = false }: { event: CaseEvent; compact?: boolean }) { + const detail = + event.kind === "status_changed" && event.payload + ? `${(event.payload.previousStatus as string) ?? "?"} → ${(event.payload.status as string) ?? "?"}` + : ""; + return ( +
+ +
+
+ {EVENT_LABEL[event.kind] ?? event.kind} + {detail && · {detail}} +
+
+ {actorLabel(event)} + {event.issue && ( + <> + · + {issueRelationLabel(event)} + + + {event.issue.identifier} + {event.issue.title} + + + )} + · + {relativeTime(event.createdAt)} +
+
+
+ ); +} + +/** The full activity feed with kind filters (detail-page Activity tab). */ +export function CaseActivityFeed({ events }: { events: CaseEvent[] }) { + const [active, setActive] = useState>(new Set()); + + // Only offer filters for kinds actually present, in first-seen order. + const presentKinds = useMemo(() => { + const seen: CaseEventKind[] = []; + for (const e of events) if (!seen.includes(e.kind)) seen.push(e.kind); + return seen; + }, [events]); + + const filtered = useMemo( + () => (active.size === 0 ? events : events.filter((e) => active.has(e.kind))), + [events, active], + ); + + function toggle(kind: CaseEventKind) { + setActive((prev) => { + const next = new Set(prev); + if (next.has(kind)) next.delete(kind); + else next.add(kind); + return next; + }); + } + + const filterLabel = active.size === 0 + ? "All activity" + : active.size === 1 + ? EVENT_LABEL[[...active][0]!] ?? [...active][0]! + : `${active.size} filters`; + + if (events.length === 0) { + return

No activity yet.

; + } + + return ( +
+
+

+ {filtered.length} of {events.length} events +

+ + + + + + Activity filter + setActive(new Set())}> + All activity + + + {presentKinds.map((kind) => ( + toggle(kind)} + > + {EVENT_LABEL[kind] ?? kind} + + ))} + + +
+ {filtered.length === 0 ? ( +

No events match this filter.

+ ) : ( +
+ {filtered.map((event) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/components/CaseAttachmentsGallery.tsx b/ui/src/components/CaseAttachmentsGallery.tsx new file mode 100644 index 0000000000..96c6464590 --- /dev/null +++ b/ui/src/components/CaseAttachmentsGallery.tsx @@ -0,0 +1,91 @@ +import { useMemo, useState } from "react"; +import { FileText } from "lucide-react"; +import { + caseAttachmentUrl, + isImageAttachment, + type CaseAttachmentRef, +} from "@/api/cases"; +import { ImageGalleryModal, type GalleryMediaItem } from "@/components/ImageGalleryModal"; +import { cn } from "@/lib/utils"; + +function humanBytes(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Attachments gallery (P4 §4): image-friendly grid. Image cases hold variations + * as attachments, so images render as thumbnails that open the shared + * lightbox; non-image assets fall back to a labelled file tile. No + * variation-picker (out of scope). + */ +export function CaseAttachmentsGallery({ attachments }: { attachments: CaseAttachmentRef[] }) { + const [galleryIndex, setGalleryIndex] = useState(null); + + // The lightbox only navigates across image attachments. + const imageItems = useMemo( + () => + attachments.filter(isImageAttachment).map((a) => ({ + id: a.id, + contentPath: caseAttachmentUrl(a), + contentType: a.asset.contentType, + originalFilename: a.asset.originalFilename, + })), + [attachments], + ); + + if (attachments.length === 0) { + return

No attachments.

; + } + + return ( + <> +
+ {attachments.map((attachment) => { + const isImage = isImageAttachment(attachment); + const filename = attachment.asset.originalFilename ?? "attachment"; + const imageIdx = isImage ? imageItems.findIndex((i) => i.id === attachment.id) : -1; + return ( + + ); + })} +
+ {galleryIndex !== null && ( + !open && setGalleryIndex(null)} + /> + )} + + ); +} diff --git a/ui/src/components/CaseChildrenTree.test.tsx b/ui/src/components/CaseChildrenTree.test.tsx new file mode 100644 index 0000000000..317d49ba69 --- /dev/null +++ b/ui/src/components/CaseChildrenTree.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CaseChildrenTree } from "./CaseChildrenTree"; +import type { CaseSummary } from "@/api/cases"; + +function act(callback: () => void) { + flushSync(callback); +} + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function child(overrides: Partial): CaseSummary { + return { + id: Math.random().toString(36).slice(2), + companyId: "c1", + projectId: null, + caseNumber: 1, + identifier: "PAP-C1", + caseType: "task", + key: null, + title: "A child", + summary: null, + status: "in_progress", + fields: {}, + parentCaseId: "parent", + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + ...overrides, + }; +} + +describe("CaseChildrenTree", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => container.remove()); + + function render(children: CaseSummary[]) { + const root = createRoot(container); + act(() => root.render()); + return root; + } + + it("shows the empty state with no children", () => { + const root = render([]); + expect(container.textContent).toContain("No child cases"); + act(() => root.unmount()); + }); + + it("renders each child with identifier, type and status chips linking to detail without keys", () => { + const root = render([ + child({ identifier: "PAP-C8", key: "launch/post", caseType: "blog_post", status: "in_review", title: "Post" }), + child({ identifier: "PAP-C9", caseType: "image", status: "done", title: "Hero image" }), + ]); + const text = container.textContent ?? ""; + expect(text).toContain("PAP-C8"); + expect(text).not.toContain("launch/post"); + expect(text).toContain("blog_post"); + // StatusBadge renders the status with underscores as spaces. + expect(text).toContain("in review"); + expect(text).toContain("Hero image"); + expect(container.querySelector('a[href="/PAP/cases/PAP-C8"]')).not.toBeNull(); + expect(container.querySelector('a[href="/PAP/cases/PAP-C9"]')).not.toBeNull(); + expect(container.querySelector('a[href="/PAP/cases/PAP-C8"]')?.className).not.toContain("border"); + act(() => root.unmount()); + }); + + it("caps long child lists until show more is clicked", () => { + const root = createRoot(container); + const children = Array.from({ length: 7 }, (_, index) => + child({ id: `child-${index + 1}`, identifier: `PAP-C${index + 1}`, title: `Child ${index + 1}` }) + ); + act(() => root.render()); + + expect(container.textContent).toContain("Child 5"); + expect(container.textContent).not.toContain("Child 6"); + expect(container.textContent).toContain("Show 2 more"); + + const showMore = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Show 2 more") + ); + expect(showMore).toBeTruthy(); + act(() => { + showMore!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Child 6"); + expect(container.textContent).toContain("Child 7"); + expect(container.textContent).not.toContain("Show 2 more"); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseChildrenTree.tsx b/ui/src/components/CaseChildrenTree.tsx new file mode 100644 index 0000000000..65504bed5f --- /dev/null +++ b/ui/src/components/CaseChildrenTree.tsx @@ -0,0 +1,73 @@ +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; +import { Link, useCaseHref } from "@/lib/router"; +import type { CaseSummary } from "@/api/cases"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { StatusBadge } from "@/components/StatusBadge"; +import { CaseCopyableToken } from "@/components/CaseIdentifierKey"; + +type CaseRelationRow = Pick & { + key?: string | null; +}; + +/** + * Children tree (P4 §3): the parent's direct child cases with type + status + * chips. Display only — no rollup semantics. Renders nothing structural beyond + * a flat list; nesting depth is intentionally one level in v1. + */ +export function CaseChildrenTree({ + children, + maxVisible, +}: { + children: CaseRelationRow[]; + maxVisible?: number; +}) { + const caseHref = useCaseHref(); + const [expanded, setExpanded] = useState(false); + if (children.length === 0) { + return

No child cases.

; + } + + const shouldCap = maxVisible != null && children.length > maxVisible; + const visibleChildren = shouldCap && !expanded ? children.slice(0, maxVisible) : children; + const hiddenCount = children.length - visibleChildren.length; + + return ( +
+
    + {visibleChildren.map((child) => ( +
  • + + + {child.title} + {child.caseType} + + +
  • + ))} +
+ {hiddenCount > 0 ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/CaseFieldsPanel.test.tsx b/ui/src/components/CaseFieldsPanel.test.tsx new file mode 100644 index 0000000000..1c85a9f814 --- /dev/null +++ b/ui/src/components/CaseFieldsPanel.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CaseFieldsPanel } from "./CaseFieldsPanel"; + +function act(callback: () => void) { + flushSync(callback); +} + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CaseFieldsPanel", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => { + container.remove(); + }); + + function render(fields: Record) { + const root = createRoot(container); + act(() => { + root.render(); + }); + return root; + } + + it("shows the empty state when there are no fields", () => { + const root = render({}); + expect(container.textContent).toContain("No fields set"); + act(() => root.unmount()); + }); + + it("renders all four generic value types per spec", () => { + const root = render({ + slug: "hermes-agent-post", + word_count: 1850, + published: true, + draft_only: false, + tags: ["ai", "launch"], + publish_url: "https://example.com/post", + related_case: "PAP-C12", + missing: null, + config: { nested: "x" }, + }); + + // string + expect(container.textContent).toContain("hermes-agent-post"); + // number — locale grouped, tabular + expect(container.textContent).toContain("1,850"); + // string[] — chips + expect(container.textContent).toContain("ai"); + expect(container.textContent).toContain("launch"); + // url → external link + const urlLink = [...container.querySelectorAll("a")].find( + (a) => a.getAttribute("href") === "https://example.com/post", + ); + expect(urlLink).toBeTruthy(); + expect(urlLink?.getAttribute("target")).toBe("_blank"); + // case identifier → case link chip + const caseLink = [...container.querySelectorAll("a")].find( + (a) => a.getAttribute("href") === "/PAP/cases/PAP-C12", + ); + expect(caseLink).toBeTruthy(); + // boolean never renders raw "true"/"false" + expect(container.textContent).not.toContain("true"); + expect(container.textContent).not.toContain("false"); + // null → em-dash present + expect(container.textContent).toContain("—"); + // object fallback → pretty-printed mono JSON block + expect(container.textContent).toContain('"nested": "x"'); + // key insertion order preserved (slug before word_count) + const text = container.textContent ?? ""; + expect(text.indexOf("slug")).toBeLessThan(text.indexOf("word_count")); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseFieldsPanel.tsx b/ui/src/components/CaseFieldsPanel.tsx new file mode 100644 index 0000000000..cd14c73a86 --- /dev/null +++ b/ui/src/components/CaseFieldsPanel.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { Check } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { IssueReferencePill } from "@/components/IssueReferencePill"; +import { Link, useCaseHref } from "@/lib/router"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { cn } from "@/lib/utils"; + +// ----------------------------------------------------------------------------- +// CaseFieldsPanel (PAP-12968 §3) — the generic key-value renderer for a case's +// `fields` JSON blob. The server stores arbitrary agent-authored JSON, so the UI +// renders by *value type* (Postel's law: never crash on unexpected shapes) and +// preserves the skill's key insertion order (does NOT alphabetize). +// ----------------------------------------------------------------------------- + +const URL_RE = /^https?:\/\/\S+$/i; +const CASE_ID_RE = /^[A-Z][A-Z0-9]*-C\d+$/; +const ISSUE_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/; +const ISSUE_ID_IN_TEXT_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** A muted em-dash for null / empty / missing values. */ +function EmptyValue() { + return ; +} + +function isIssueIdentifierField(fieldKey: string | undefined): boolean { + if (!fieldKey) return false; + const normalized = fieldKey.toLowerCase().replace(/[^a-z0-9]/g, ""); + return normalized.includes("issueidentifier") || normalized.includes("taskidentifier"); +} + +function stringifyCopyValue(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value, null, 2); +} + +function extractIssueIdentifiers(value: unknown, fieldKey?: string): string[] { + if (!isIssueIdentifierField(fieldKey)) { + return typeof value === "string" && ISSUE_ID_RE.test(value.trim()) ? [value.trim()] : []; + } + + const identifiers: string[] = []; + const add = (candidate: unknown) => { + if (typeof candidate !== "string") return; + for (const match of candidate.matchAll(ISSUE_ID_IN_TEXT_RE)) identifiers.push(match[0]); + }; + + if (Array.isArray(value)) value.forEach(add); + else add(value); + + return [...new Set(identifiers)]; +} + +function IssueIdentifierValue({ identifiers }: { identifiers: string[] }) { + return ( + + {identifiers.map((identifier) => ( + + ))} + + ); +} + +function CopyableCompactValue({ + value, + children, + className, +}: { + value: unknown; + children: ReactNode; + className?: string; +}) { + const text = stringifyCopyValue(value); + const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = useCallback(() => { + void copyTextToClipboard(text).then(() => { + setCopied(true); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + }); + }, [text]); + + return ( + + + {copied ? ( + + + Copied + + ) : null} + + ); +} + +function StringValue({ value, variant }: { value: string; variant: "compact" | "full" }) { + const caseHref = useCaseHref(); + const trimmed = value.trim(); + if (trimmed === "") return ; + if (URL_RE.test(trimmed)) { + return ( + + {trimmed} + + + ); + } + if (CASE_ID_RE.test(trimmed)) { + return ( + + {trimmed} + + ); + } + if (variant === "compact") { + return ( + + {value} + + ); + } + return {value}; +} + +export function CaseFieldValue({ + value, + fieldKey, + variant = "full", +}: { + value: unknown; + fieldKey?: string; + variant?: "compact" | "full"; +}) { + if (value === null || value === undefined) return ; + + const issueIdentifiers = extractIssueIdentifiers(value, fieldKey); + if (issueIdentifiers.length > 0) return ; + + if (typeof value === "string") return ; + + if (typeof value === "number") { + if (!Number.isFinite(value)) return {String(value)}; + if (variant === "compact") { + return ( + + {value.toLocaleString()} + + ); + } + return {value.toLocaleString()}; + } + + if (typeof value === "boolean") { + return value ? ( + + ) : ( + + ); + } + + if (Array.isArray(value)) { + if (value.length === 0) return ; + if (variant === "compact") { + return ( + + {stringifyCopyValue(value)} + + ); + } + return ( +
+ {value.map((item, index) => ( + + {typeof item === "string" || typeof item === "number" || typeof item === "boolean" + ? String(item) + : JSON.stringify(item)} + + ))} +
+ ); + } + + if (isPlainObject(value)) { + const snippet = JSON.stringify(value); + if (variant === "compact") { + return ( + + {snippet} + + ); + } + return ( +
+        {JSON.stringify(value, null, 2)}
+      
+ ); + } + + return {String(value)}; +} + +export function CaseFieldsPanel({ fields }: { fields: Record }) { + const entries = Object.entries(fields ?? {}); + + return ( +
+
+

Fields

+ from the skill's schema — rendered generically +
+ + {entries.length === 0 ? ( +
No fields set
+ ) : ( +
+ {entries.map(([key, value]) => ( +
+
{key}
+
+ +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/ui/src/components/CaseIdentifierKey.tsx b/ui/src/components/CaseIdentifierKey.tsx new file mode 100644 index 0000000000..90e3e1613c --- /dev/null +++ b/ui/src/components/CaseIdentifierKey.tsx @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react"; +import { Check } from "lucide-react"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { cn } from "@/lib/utils"; + +export function CaseCopyableToken({ + value, + label, + className, + containerClassName, + truncate = true, + stopPropagation, +}: { + value: string; + label: string; + className?: string; + containerClassName?: string; + truncate?: boolean; + stopPropagation?: boolean; +}) { + const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = useCallback((event: MouseEvent) => { + if (stopPropagation) { + event.preventDefault(); + event.stopPropagation(); + } + void copyTextToClipboard(value).then(() => { + setCopied(true); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + }); + }, [stopPropagation, value]); + + return ( + + + {copied ? ( + + + Copied + + ) : null} + + ); +} + +export function CaseIdentifierKey({ + identifier, + caseKey, + className, + stopPropagation, +}: { + identifier: string; + caseKey?: string | null; + className?: string; + stopPropagation?: boolean; +}) { + return ( + + + {caseKey ? ( + + ) : null} + + ); +} diff --git a/ui/src/components/CaseRevisionRail.test.tsx b/ui/src/components/CaseRevisionRail.test.tsx new file mode 100644 index 0000000000..b8cd879771 --- /dev/null +++ b/ui/src/components/CaseRevisionRail.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaseDocumentRevisions } from "@/api/cases"; +import { CaseRevisionRail } from "./CaseRevisionRail"; + +function act(callback: () => void) { + flushSync(callback); +} + +const mockCasesApi = vi.hoisted(() => ({ listRevisions: vi.fn() })); + +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} + +const revisions: CaseDocumentRevisions = { + key: "body", + document: { id: "doc-1", title: "body", format: "markdown", latestRevisionId: "r2", latestRevisionNumber: 2 }, + revisions: [ + { + id: "r2", + revisionNumber: 2, + title: "body", + format: "markdown", + body: "# Second version", + changeSummary: "polish wording", + createdAt: "2026-07-07T02:00:00.000Z", + createdByAgentId: "agent-1", + createdByUserId: null, + createdByRunId: "run-2", + actorAgentName: "Cases Agent", + issue: { id: "i1", identifier: "PAP-42", title: "Task", status: "in_progress" }, + }, + { + id: "r1", + revisionNumber: 1, + title: "body", + format: "markdown", + body: "# First version", + changeSummary: null, + createdAt: "2026-07-07T01:00:00.000Z", + createdByAgentId: "agent-1", + createdByUserId: null, + createdByRunId: "run-1", + actorAgentName: "Cases Agent", + issue: null, + }, + ], +}; + +describe("CaseRevisionRail", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockCasesApi.listRevisions.mockReset(); + }); + afterEach(() => container.remove()); + + async function render() { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + await flush(); + return root; + } + + it("renders both revisions and shows the latest body by default", async () => { + mockCasesApi.listRevisions.mockResolvedValue(revisions); + const root = await render(); + const text = container.textContent ?? ""; + expect(text).toContain("rev 2"); + expect(text).toContain("rev 1"); + expect(text).toContain("latest"); + expect(text).toContain("polish wording"); + expect(text).toContain("Cases Agent"); + // Latest (rev 2) selected → its body renders; via-issue attribution shown. + expect(container.querySelector('[data-testid="md"]')?.textContent).toBe("# Second version"); + expect(container.querySelector('a[href="/issues/PAP-42"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("switches the rendered body when an older revision is picked", async () => { + mockCasesApi.listRevisions.mockResolvedValue(revisions); + const root = await render(); + const rev1Button = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("rev 1"), + ); + expect(rev1Button).toBeTruthy(); + act(() => rev1Button!.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flush(); + expect(container.querySelector('[data-testid="md"]')?.textContent).toBe("# First version"); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/CaseRevisionRail.tsx b/ui/src/components/CaseRevisionRail.tsx new file mode 100644 index 0000000000..c31d5eb431 --- /dev/null +++ b/ui/src/components/CaseRevisionRail.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@/lib/router"; +import { casesApi, type CaseDocumentRevision } from "@/api/cases"; +import { queryKeys } from "@/lib/queryKeys"; +import { buildLineDiff, type DiffRow } from "@/lib/line-diff"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn, relativeTime } from "@/lib/utils"; +import { Diff } from "lucide-react"; + +/** Author + via-issue attribution line for a revision. */ +function RevisionByline({ revision }: { revision: CaseDocumentRevision }) { + const author = revision.actorAgentName ?? (revision.createdByUserId ? "User" : "System"); + return ( + + {author} + {revision.issue && ( + <> + · + via + e.stopPropagation()} + title={revision.issue.title} + > + {revision.issue.identifier} + + + )} + + ); +} + +function getRevisionLabel(revision: CaseDocumentRevision) { + const actor = revision.actorAgentName ?? (revision.createdByUserId ? "board" : "system"); + return `rev ${revision.revisionNumber} - ${relativeTime(revision.createdAt)} - ${actor}`; +} + +function CaseDocumentDiffModal({ + documentKey, + revisions, + latestRevisionNumber, + open, + onOpenChange, +}: { + documentKey: string; + revisions: CaseDocumentRevision[]; + latestRevisionNumber: number; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [leftRevisionId, setLeftRevisionId] = useState(null); + const [rightRevisionId, setRightRevisionId] = useState(null); + + const effectiveLeftId = leftRevisionId ?? revisions.find( + (revision) => revision.revisionNumber === latestRevisionNumber - 1, + )?.id ?? null; + const effectiveRightId = rightRevisionId ?? revisions.find( + (revision) => revision.revisionNumber === latestRevisionNumber, + )?.id ?? null; + const leftRevision = revisions.find((revision) => revision.id === effectiveLeftId) ?? null; + const rightRevision = revisions.find((revision) => revision.id === effectiveRightId) ?? null; + const diffRows = buildLineDiff(leftRevision?.body ?? "", rightRevision?.body ?? ""); + const lineClassesByKind: Record = { + context: "bg-transparent", + removed: "bg-red-500/10 text-red-900 dark:text-red-100", + added: "bg-green-500/10 text-green-900 dark:text-green-100", + }; + const markerByKind: Record = { + context: " ", + removed: "-", + added: "+", + }; + + return ( + + +
+ + + Diff - {documentKey} + + +
+
+ Old + +
+
+ New + +
+
+
+ +
+ {!leftRevision || !rightRevision ? ( +
Select two revisions to compare.
+ ) : leftRevision.id === rightRevision.id ? ( +
Both sides are the same revision.
+ ) : ( +
+
+ Old + New + + Content +
+ {diffRows.map((row, index) => ( +
+ + {row.oldLineNumber ?? ""} + + + {row.newLineNumber ?? ""} + + + {markerByKind[row.kind]} + +
+                    {row.text.length > 0 ? row.text : " "}
+                  
+
+ ))} +
+ )} +
+
+
+ ); +} + +/** + * Revision rail (P4 §2): read-only body document with a per-revision list. The + * newest revision is selected by default; picking another swaps the rendered + * body. No editing UI in v1. + */ +export function CaseRevisionRail({ + caseIdentifier, + documentKey = "body", +}: { + caseIdentifier: string; + documentKey?: string; +}) { + const revisionsQuery = useQuery({ + queryKey: queryKeys.cases.revisions(caseIdentifier, documentKey), + queryFn: () => casesApi.listRevisions(caseIdentifier, documentKey), + }); + const revisions = revisionsQuery.data?.revisions ?? []; + const [selectedId, setSelectedId] = useState(null); + const [diffOpen, setDiffOpen] = useState(false); + + // Default to the latest revision once loaded; keep a valid selection if the + // list changes underneath us. + useEffect(() => { + if (revisions.length === 0) return; + if (!selectedId || !revisions.some((r) => r.id === selectedId)) { + setSelectedId(revisions[0]!.id); + } + }, [revisions, selectedId]); + + if (revisionsQuery.isLoading) { + return

Loading revisions…

; + } + if (revisionsQuery.isError) { + return

Could not load revisions.

; + } + if (revisions.length === 0) { + return

No revisions yet.

; + } + + const selected = revisions.find((r) => r.id === selectedId) ?? revisions[0]!; + const latestRevisionNumber = revisionsQuery.data?.document.latestRevisionNumber ?? selected.revisionNumber; + + return ( +
+ + + +
+ rev {selected.revisionNumber} + +
+ {selected.body ? ( + + {selected.body} + + ) : ( +

This revision has no body.

+ )} +
+ {revisions.length > 1 ? ( + + ) : null} +
+ ); +} diff --git a/ui/src/components/CasesExperimentalGate.tsx b/ui/src/components/CasesExperimentalGate.tsx new file mode 100644 index 0000000000..07bd76d1f2 --- /dev/null +++ b/ui/src/components/CasesExperimentalGate.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Navigate } from "@/lib/router"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Route guard for the experimental Cases feature (PAP-12947). Redirects to the + * dashboard when `enableCases` is off, mirroring {@link PipelinesExperimentalGate}. + */ +export function CasesExperimentalGate({ children }: { children: ReactNode }) { + const { data: experimentalSettings, isFetched } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + + if (!isFetched) return null; + if (experimentalSettings?.enableCases !== true) { + return ; + } + return <>{children}; +} diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index d18607cb46..e2c030ae69 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -146,6 +146,8 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { const annotationsQueryKey = useMemo( () => annotationTarget.kind === "routine" ? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all") + : annotationTarget.kind === "case" + ? queryKeys.cases.documentAnnotations(annotationTarget.caseId, annotationTarget.documentKey, "all") : queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"), [annotationTarget], ); @@ -160,6 +162,12 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { && query.queryKey[2] === annotationTarget.routineId && query.queryKey[3] === annotationTarget.documentKey; } + if (annotationTarget.kind === "case") { + return query.queryKey[0] === "cases" + && query.queryKey[1] === "document-annotations" + && query.queryKey[2] === annotationTarget.caseId + && query.queryKey[3] === annotationTarget.documentKey; + } return query.queryKey[0] === "issues" && query.queryKey[1] === "document-annotations" && query.queryKey[2] === annotationTarget.issueId @@ -709,6 +717,7 @@ function buildOptimisticComment(input: { threadId: input.threadId, issueId: input.target.kind === "issue" ? input.target.issueId : null, routineId: input.target.kind === "routine" ? input.target.routineId : null, + caseId: input.target.kind === "case" ? input.target.caseId : null, documentId: "", body: input.body, authorType: "user", @@ -747,6 +756,7 @@ function buildOptimisticThread(input: { id, issueId: input.target.kind === "issue" ? input.target.issueId : null, routineId: input.target.kind === "routine" ? input.target.routineId : null, + caseId: input.target.kind === "case" ? input.target.caseId : null, documentKey: input.documentKey, status: "open", anchorState: "active", diff --git a/ui/src/components/DocumentDiffModal.tsx b/ui/src/components/DocumentDiffModal.tsx index 8c406749bb..68f3880d44 100644 --- a/ui/src/components/DocumentDiffModal.tsx +++ b/ui/src/components/DocumentDiffModal.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import type { QueryKey } from "@tanstack/react-query"; import type { DocumentRevision } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; @@ -35,16 +36,20 @@ export function DocumentDiffModal({ latestRevisionNumber, open, onOpenChange, + revisionsQueryKey, + revisionsQueryFn, }: { - issueId: string; + issueId?: string; documentKey: string; latestRevisionNumber: number; open: boolean; onOpenChange: (open: boolean) => void; + revisionsQueryKey?: QueryKey; + revisionsQueryFn?: () => Promise; }) { const { data: revisions } = useQuery({ - queryKey: queryKeys.issues.documentRevisions(issueId, documentKey), - queryFn: () => issuesApi.listDocumentRevisions(issueId, documentKey), + queryKey: revisionsQueryKey ?? queryKeys.issues.documentRevisions(issueId ?? "", documentKey), + queryFn: () => revisionsQueryFn ? revisionsQueryFn() : issuesApi.listDocumentRevisions(issueId ?? "", documentKey), enabled: open, }); diff --git a/ui/src/components/IssueCasesPanel.test.tsx b/ui/src/components/IssueCasesPanel.test.tsx new file mode 100644 index 0000000000..b9d88c9a51 --- /dev/null +++ b/ui/src/components/IssueCasesPanel.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { IssueCaseLink } from "@/api/cases"; +import { IssueCasesPanel } from "./IssueCasesPanel"; + +function act(callback: () => void) { + flushSync(callback); +} + +const mockCasesApi = vi.hoisted(() => ({ listForIssue: vi.fn() })); +const mockInstanceApi = vi.hoisted(() => ({ getExperimental: vi.fn() })); + +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceApi })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} + +const links: IssueCaseLink[] = [ + { + id: "l1", + role: "work", + createdAt: "2026-07-07T00:00:00.000Z", + case: { id: "c1", identifier: "PAP-C7", title: "Launch post", caseType: "blog_post", status: "in_review" }, + }, +]; + +describe("IssueCasesPanel", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockCasesApi.listForIssue.mockReset(); + mockInstanceApi.getExperimental.mockReset(); + }); + afterEach(() => container.remove()); + + async function render() { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + await flush(); + return root; + } + + it("renders nothing when the Cases flag is off", async () => { + mockInstanceApi.getExperimental.mockResolvedValue({ enableCases: false }); + mockCasesApi.listForIssue.mockResolvedValue(links); + const root = await render(); + expect(container.textContent).toBe(""); + expect(mockCasesApi.listForIssue).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("renders linked cases with role + status when enabled", async () => { + mockInstanceApi.getExperimental.mockResolvedValue({ enableCases: true }); + mockCasesApi.listForIssue.mockResolvedValue(links); + const root = await render(); + const text = container.textContent ?? ""; + expect(text).toContain("Cases"); + expect(text).toContain("PAP-C7"); + expect(text).toContain("Launch post"); + expect(text).toContain("work"); + expect(text).toContain("in review"); + expect(container.querySelector('a[href="/PAP/cases/PAP-C7"]')).not.toBeNull(); + act(() => root.unmount()); + }); + + it("renders nothing when enabled but no cases are linked", async () => { + mockInstanceApi.getExperimental.mockResolvedValue({ enableCases: true }); + mockCasesApi.listForIssue.mockResolvedValue([]); + const root = await render(); + expect(container.textContent).toBe(""); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/IssueCasesPanel.tsx b/ui/src/components/IssueCasesPanel.tsx new file mode 100644 index 0000000000..0c1ca68b76 --- /dev/null +++ b/ui/src/components/IssueCasesPanel.tsx @@ -0,0 +1,57 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useCaseHref } from "@/lib/router"; +import { casesApi, type CaseLinkRole } from "@/api/cases"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; +import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/StatusBadge"; + +const ROLE_LABEL: Record = { + origin: "origin", + work: "work", + reference: "reference", +}; + +/** + * Issue-page right-rail section (P4 §5): the cases linked to this issue, each + * with its link role + case status. Self-gates on the experimental Cases flag + * and renders nothing when the flag is off or no cases are linked, so it can be + * dropped into the issue properties panel unconditionally. + */ +export function IssueCasesPanel({ issueId }: { issueId: string }) { + const caseHref = useCaseHref(); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + const enabled = experimentalSettings?.enableCases === true; + + const casesQuery = useQuery({ + queryKey: queryKeys.cases.forIssue(issueId), + queryFn: () => casesApi.listForIssue(issueId), + enabled: enabled && !!issueId, + }); + + const links = casesQuery.data ?? []; + if (!enabled || links.length === 0) return null; + + return ( +
+

Cases

+
+ {links.map((link) => ( + + {link.case.identifier} + {link.case.title} + {ROLE_LABEL[link.role]} + + + ))} +
+
+ ); +} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 1b7f5ebf41..d5e233887d 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -225,6 +225,8 @@ interface IssueChatMessageContext { issueStatus?: string; successfulRunHandoff?: SuccessfulRunHandoffState | null; externalReferences?: MarkdownExternalReferenceMap; + /** Linkify `PAP-C7` case chips in comment bodies (experimental Cases flag). */ + linkCaseReferences?: boolean; } const IssueChatCtx = createContext({ @@ -523,6 +525,8 @@ interface IssueChatThreadProps { */ onRefreshLatestComments?: () => Promise | void; externalReferences?: MarkdownExternalReferenceMap; + /** Linkify `PAP-C7` case chips in comment bodies (experimental Cases flag). */ + linkCaseReferences?: boolean; } type IssueChatErrorBoundaryProps = { @@ -784,7 +788,7 @@ function commentDateLabel(date: Date | string | undefined): string { } const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAccent }: { text: string; recessed?: boolean; onAccent?: boolean }) { - const { onImageClick, externalReferences } = useContext(IssueChatCtx); + const { onImageClick, externalReferences, linkCaseReferences } = useContext(IssueChatCtx); if (isSuccessfulRunHandoffComment(text)) { return ; } @@ -795,6 +799,7 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAc softBreaks onImageClick={onImageClick} externalReferences={externalReferences} + linkCaseReferences={linkCaseReferences} > {text} @@ -4237,6 +4242,7 @@ export function IssueChatThread({ onResumeFromBacklog, resumeFromBacklogPending = false, externalReferences, + linkCaseReferences = false, }: IssueChatThreadProps) { const location = useLocation(); const lastScrolledHashRef = useRef(null); @@ -4777,6 +4783,7 @@ export function IssueChatThread({ issueStatus, successfulRunHandoff, externalReferences, + linkCaseReferences, }), [ feedbackDataSharingPreference, @@ -4803,6 +4810,7 @@ export function IssueChatThread({ issueStatus, successfulRunHandoff, externalReferences, + linkCaseReferences, ], ); diff --git a/ui/src/components/IssueDocumentAnnotations.test.tsx b/ui/src/components/IssueDocumentAnnotations.test.tsx index b99165579c..1af735cd53 100644 --- a/ui/src/components/IssueDocumentAnnotations.test.tsx +++ b/ui/src/components/IssueDocumentAnnotations.test.tsx @@ -27,15 +27,35 @@ const mockAnnotationsApi = vi.hoisted(() => { updateStatusForTarget: vi.fn(), }; api.listForTarget.mockImplementation((target, options) => - target.kind === "issue" ? api.list(target.issueId, target.documentKey, options) : api.list(target.routineId, target.documentKey, options)); + target.kind === "issue" + ? api.list(target.issueId, target.documentKey, options) + : target.kind === "case" + ? api.list(target.caseId, target.documentKey, options) + : api.list(target.routineId, target.documentKey, options)); api.getForTarget.mockImplementation((target, threadId) => - target.kind === "issue" ? api.get(target.issueId, target.documentKey, threadId) : api.get(target.routineId, target.documentKey, threadId)); + target.kind === "issue" + ? api.get(target.issueId, target.documentKey, threadId) + : target.kind === "case" + ? api.get(target.caseId, target.documentKey, threadId) + : api.get(target.routineId, target.documentKey, threadId)); api.createForTarget.mockImplementation((target, data) => - target.kind === "issue" ? api.create(target.issueId, target.documentKey, data) : api.create(target.routineId, target.documentKey, data)); + target.kind === "issue" + ? api.create(target.issueId, target.documentKey, data) + : target.kind === "case" + ? api.create(target.caseId, target.documentKey, data) + : api.create(target.routineId, target.documentKey, data)); api.addCommentForTarget.mockImplementation((target, threadId, data) => - target.kind === "issue" ? api.addComment(target.issueId, target.documentKey, threadId, data) : api.addComment(target.routineId, target.documentKey, threadId, data)); + target.kind === "issue" + ? api.addComment(target.issueId, target.documentKey, threadId, data) + : target.kind === "case" + ? api.addComment(target.caseId, target.documentKey, threadId, data) + : api.addComment(target.routineId, target.documentKey, threadId, data)); api.updateStatusForTarget.mockImplementation((target, threadId, status) => - target.kind === "issue" ? api.updateStatus(target.issueId, target.documentKey, threadId, status) : api.updateStatus(target.routineId, target.documentKey, threadId, status)); + target.kind === "issue" + ? api.updateStatus(target.issueId, target.documentKey, threadId, status) + : target.kind === "case" + ? api.updateStatus(target.caseId, target.documentKey, threadId, status) + : api.updateStatus(target.routineId, target.documentKey, threadId, status)); return api; }); diff --git a/ui/src/components/IssueDocumentAnnotations.tsx b/ui/src/components/IssueDocumentAnnotations.tsx index 2e959c15bd..cff00f8f55 100644 --- a/ui/src/components/IssueDocumentAnnotations.tsx +++ b/ui/src/components/IssueDocumentAnnotations.tsx @@ -167,6 +167,8 @@ export function IssueDocumentAnnotations({ const annotationsQuery = useQuery({ queryKey: target?.kind === "routine" ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") + : target?.kind === "case" + ? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all") : queryKeys.issues.documentAnnotations(issueId, doc.key, "all"), queryFn: () => target ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) @@ -406,6 +408,8 @@ export function DocumentAnnotationsCountChip({ const annotationsQuery = useQuery({ queryKey: target?.kind === "routine" ? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all") + : target?.kind === "case" + ? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all") : queryKeys.issues.documentAnnotations(issueId, docKey, "all"), queryFn: () => target ? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true }) diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 7a1c54339d..cc3639120d 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -152,9 +152,6 @@ vi.mock("@/components/ui/dropdown-menu", async () => { }; }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - const localStorageEntries = new Map(); function ensureLocalStorageMock() { @@ -592,6 +589,7 @@ describe("IssueDocumentsSection", () => { await act(async () => { restoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flush(); expect(mockIssuesApi.restoreDocumentRevision).toHaveBeenCalledWith("issue-1", "plan", "revision-3"); expect(container.textContent).toContain("Restored plan body"); @@ -899,4 +897,84 @@ describe("IssueDocumentsSection", () => { }); queryClient.clear(); }); + + it("renders and locks documents for a non-issue document subject", async () => { + const caseDocument = createIssueDocument({ + id: "case-document-1", + issueId: "case-1", + key: "body", + title: "Body", + body: "Reusable case document body", + latestRevisionId: "case-revision-2", + latestRevisionNumber: 2, + updatedByAgentId: "agent-1", + updatedByUserId: null, + }); + const lockedCaseDocument = { + ...caseDocument, + lockedAt: new Date("2026-03-31T12:06:00.000Z"), + lockedByUserId: "user-1", + updatedAt: new Date("2026-03-31T12:06:00.000Z"), + }; + const listDocuments = vi.fn() + .mockResolvedValueOnce([caseDocument]) + .mockResolvedValue([lockedCaseDocument]); + const setDocumentLock = vi.fn().mockResolvedValue(lockedCaseDocument); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + ["cases", "revisions", "case-1", key], + listDocuments, + listDocumentRevisions: vi.fn().mockResolvedValue([]), + getDocument: vi.fn().mockResolvedValue(caseDocument), + upsertDocument: vi.fn().mockResolvedValue(caseDocument), + deleteDocument: vi.fn().mockResolvedValue({ ok: true }), + restoreDocumentRevision: vi.fn().mockResolvedValue(caseDocument), + setDocumentLock, + hideSystemDocuments: false, + legacyPlanDocument: null, + annotations: null, + }} + canDeleteDocuments + canManageDocumentLocks + /> + , + ); + }); + await flush(); + await flush(); + + expect(listDocuments).toHaveBeenCalled(); + expect(container.textContent).toContain("Reusable case document body"); + expect(container.textContent).toContain("body"); + + const lockButton = container.querySelector('button[title="Lock document"]'); + expect(lockButton).toBeTruthy(); + + await act(async () => { + lockButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(setDocumentLock).toHaveBeenCalledWith("body", true); + expect(container.querySelector('button[title="Unlock document"]')).toBeTruthy(); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); }); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 64f2b3ae89..1e1938e58f 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { Agent, DocumentRevision, @@ -20,6 +21,7 @@ import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { FoldCurtain } from "./FoldCurtain"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations"; +import type { DocumentAnnotationTarget } from "@/api/document-annotations"; import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption } from "./MarkdownEditor"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; @@ -53,6 +55,33 @@ type DocumentConflictState = { showRemote: boolean; }; +type DocumentSubjectConfig = { + id: string; + detailQueryKey?: QueryKey; + documentsQueryKey: QueryKey; + idleDocumentRevisionsQueryKey: QueryKey; + documentRevisionsQueryKey: (key: string) => QueryKey; + listDocuments: () => Promise; + listDocumentRevisions: (key: string) => Promise; + getDocument: (key: string) => Promise; + upsertDocument: (key: string, data: { + title: string | null; + format: "markdown"; + body: string; + baseRevisionId: string | null; + }) => Promise; + deleteDocument?: (key: string) => Promise; + restoreDocumentRevision?: (key: string, revisionId: string) => Promise; + setDocumentLock?: (key: string, locked: boolean) => Promise; + syncDetailCache?: (queryClient: QueryClient, document: IssueDocument) => void; + hideSystemDocuments?: boolean; + legacyPlanDocument?: { body: string } | null; + annotations?: { + issueId: string; + target?: DocumentAnnotationTarget | ((documentKey: string) => DocumentAnnotationTarget); + } | null; +}; + const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900; const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`; @@ -170,8 +199,50 @@ function toDocumentSummary(document: IssueDocument) { }; } +function makeIssueDocumentSubject(issue: Issue): DocumentSubjectConfig { + return { + id: issue.id, + detailQueryKey: queryKeys.issues.detail(issue.id), + documentsQueryKey: queryKeys.issues.documents(issue.id), + idleDocumentRevisionsQueryKey: ["issues", "document-revisions", issue.id, "__idle__"], + documentRevisionsQueryKey: (key) => queryKeys.issues.documentRevisions(issue.id, key), + listDocuments: () => issuesApi.listDocuments(issue.id), + listDocumentRevisions: (key) => issuesApi.listDocumentRevisions(issue.id, key), + getDocument: (key) => issuesApi.getDocument(issue.id, key), + upsertDocument: (key, data) => issuesApi.upsertDocument(issue.id, key, data), + deleteDocument: (key) => issuesApi.deleteDocument(issue.id, key), + restoreDocumentRevision: (key, revisionId) => issuesApi.restoreDocumentRevision(issue.id, key, revisionId), + setDocumentLock: (key, locked) => + locked ? issuesApi.lockDocument(issue.id, key) : issuesApi.unlockDocument(issue.id, key), + syncDetailCache: (queryClient, document) => { + queryClient.setQueryData( + queryKeys.issues.detail(issue.id), + (current) => { + if (!current) return current; + const nextSummaries = (() => { + const summary = toDocumentSummary(document); + const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key); + if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary]; + return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry); + })(); + return { + ...current, + planDocument: document.key === "plan" ? document : current.planDocument ?? null, + documentSummaries: nextSummaries, + legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null, + }; + }, + ); + }, + hideSystemDocuments: true, + legacyPlanDocument: issue.legacyPlanDocument, + annotations: { issueId: issue.id }, + }; +} + export function IssueDocumentsSection({ issue, + subject, canDeleteDocuments, canManageDocumentLocks = false, feedbackVotes = [], @@ -188,7 +259,8 @@ export function IssueDocumentsSection({ forceEditDocumentKey, externalReferences, }: { - issue: Issue; + issue?: Issue; + subject?: DocumentSubjectConfig; canDeleteDocuments: boolean; canManageDocumentLocks?: boolean; feedbackVotes?: FeedbackVote[]; @@ -217,11 +289,24 @@ export function IssueDocumentsSection({ }) { const queryClient = useQueryClient(); const location = useLocation(); + const documentSubject = useMemo(() => { + if (subject) return subject; + if (!issue) throw new Error("IssueDocumentsSection requires either issue or subject"); + return makeIssueDocumentSubject(issue); + }, [issue, subject]); + const annotationTargetForKey = useCallback((documentKey: string) => { + const configured = documentSubject.annotations?.target; + if (!configured) return undefined; + if (typeof configured === "function") return configured(documentKey); + if (configured.kind === "issue") return { ...configured, documentKey }; + if (configured.kind === "case") return { ...configured, documentKey }; + return configured; + }, [documentSubject]); const [confirmDeleteKey, setConfirmDeleteKey] = useState(null); const [error, setError] = useState(null); const [draft, setDraft] = useState(null); const [documentConflict, setDocumentConflict] = useState(null); - const [foldedDocumentKeys, setFoldedDocumentKeys] = useState(() => loadFoldedDocumentKeys(issue.id)); + const [foldedDocumentKeys, setFoldedDocumentKeys] = useState(() => loadFoldedDocumentKeys(documentSubject.id)); const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState( () => (defaultAnnotationPanelOpenKeys ?? []), ); @@ -242,39 +327,42 @@ export function IssueDocumentsSection({ } = useAutosaveIndicator(); const { data: documents } = useQuery({ - queryKey: queryKeys.issues.documents(issue.id), - queryFn: () => issuesApi.listDocuments(issue.id), + queryKey: documentSubject.documentsQueryKey, + queryFn: documentSubject.listDocuments, }); const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({ queryKey: revisionMenuOpenKey - ? queryKeys.issues.documentRevisions(issue.id, revisionMenuOpenKey) - : ["issues", "document-revisions", issue.id, "__idle__"], + ? documentSubject.documentRevisionsQueryKey(revisionMenuOpenKey) + : documentSubject.idleDocumentRevisionsQueryKey, queryFn: async () => { if (!revisionMenuOpenKey) return []; - return issuesApi.listDocumentRevisions(issue.id, revisionMenuOpenKey); + return documentSubject.listDocumentRevisions(revisionMenuOpenKey); }, enabled: Boolean(revisionMenuOpenKey), }); const invalidateIssueDocuments = useCallback(() => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) }); + if (documentSubject.detailQueryKey) { + queryClient.invalidateQueries({ queryKey: documentSubject.detailQueryKey }); + } + queryClient.invalidateQueries({ queryKey: documentSubject.documentsQueryKey }); queryClient.invalidateQueries({ predicate: (query) => Array.isArray(query.queryKey) - && query.queryKey[0] === "issues" + && query.queryKey.includes(documentSubject.id) && ( - (query.queryKey[1] === "document-revisions" && query.queryKey[2] === issue.id) - || (query.queryKey[1] === "document-annotations" && query.queryKey[2] === issue.id) + query.queryKey.includes("document-revisions") + || query.queryKey.includes("document-annotations") + || query.queryKey.includes("revisions") ), }); - }, [issue.id, queryClient]); + }, [documentSubject, queryClient]); const syncDocumentCaches = useCallback((document: IssueDocument) => { - if (isSystemIssueDocumentKey(document.key)) return; + if (documentSubject.hideSystemDocuments && isSystemIssueDocumentKey(document.key)) return; queryClient.setQueryData( - queryKeys.issues.documents(issue.id), + documentSubject.documentsQueryKey, (current) => { if (!current) return [document]; const existingIndex = current.findIndex((entry) => entry.key === document.key); @@ -282,29 +370,12 @@ export function IssueDocumentsSection({ return current.map((entry, index) => index === existingIndex ? document : entry); }, ); - queryClient.setQueryData( - queryKeys.issues.detail(issue.id), - (current) => { - if (!current) return current; - const nextSummaries = (() => { - const summary = toDocumentSummary(document); - const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key); - if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary]; - return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry); - })(); - return { - ...current, - planDocument: document.key === "plan" ? document : current.planDocument ?? null, - documentSummaries: nextSummaries, - legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null, - }; - }, - ); - }, [issue.id, queryClient]); + documentSubject.syncDetailCache?.(queryClient, document); + }, [documentSubject, queryClient]); const upsertDocument = useMutation({ mutationFn: async (nextDraft: DraftState) => - issuesApi.upsertDocument(issue.id, nextDraft.key, { + documentSubject.upsertDocument(nextDraft.key, { title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null, format: "markdown", body: nextDraft.body, @@ -313,7 +384,9 @@ export function IssueDocumentsSection({ }); const deleteDocument = useMutation({ - mutationFn: (key: string) => issuesApi.deleteDocument(issue.id, key), + mutationFn: (key: string) => documentSubject.deleteDocument + ? documentSubject.deleteDocument(key) + : Promise.reject(new Error("Document deletion is not available")), onSuccess: () => { setError(null); setConfirmDeleteKey(null); @@ -326,7 +399,9 @@ export function IssueDocumentsSection({ const restoreDocumentRevision = useMutation({ mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) => - issuesApi.restoreDocumentRevision(issue.id, key, revisionId), + documentSubject.restoreDocumentRevision + ? documentSubject.restoreDocumentRevision(key, revisionId) + : Promise.reject(new Error("Document revision restore is not available")), onSuccess: (document, variables) => { syncDocumentCaches(document); setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null })); @@ -343,7 +418,9 @@ export function IssueDocumentsSection({ const setDocumentLock = useMutation({ mutationFn: ({ key, locked }: { key: string; locked: boolean }) => - locked ? issuesApi.lockDocument(issue.id, key) : issuesApi.unlockDocument(issue.id, key), + documentSubject.setDocumentLock + ? documentSubject.setDocumentLock(key, locked) + : Promise.reject(new Error("Document locking is not available")), onSuccess: (document) => { syncDocumentCaches(document); setDraft((current) => current?.key === document.key ? null : current); @@ -358,12 +435,12 @@ export function IssueDocumentsSection({ }); const sortedDocuments = useMemo(() => { - return (documents ?? []).filter((doc) => !isSystemIssueDocumentKey(doc.key)).sort((a, b) => { + return (documents ?? []).filter((doc) => !documentSubject.hideSystemDocuments || !isSystemIssueDocumentKey(doc.key)).sort((a, b) => { if (a.key === "plan" && b.key !== "plan") return -1; if (a.key !== "plan" && b.key === "plan") return 1; return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); }); - }, [documents]); + }, [documentSubject.hideSystemDocuments, documents]); const feedbackVoteByTargetId = useMemo(() => { const map = new Map(); @@ -375,7 +452,7 @@ export function IssueDocumentsSection({ }, [feedbackVotes]); const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan"); - const isEmpty = sortedDocuments.length === 0 && !issue.legacyPlanDocument; + const isEmpty = sortedDocuments.length === 0 && !documentSubject.legacyPlanDocument; const newDocumentKeyError = draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim()) ? "Use lowercase letters, numbers, -, or _, and start with a letter or number." @@ -539,7 +616,7 @@ export function IssueDocumentsSection({ } if (isDocumentConflictError(err)) { try { - const latestDocument = await issuesApi.getDocument(issue.id, normalizedKey); + const latestDocument = await documentSubject.getDocument(normalizedKey); setDocumentConflict({ key: normalizedKey, serverDocument: latestDocument, @@ -564,7 +641,7 @@ export function IssueDocumentsSection({ setError(err instanceof Error ? err.message : "Failed to save document"); return false; } - }, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]); + }, [documentConflict, documentSubject, invalidateIssueDocuments, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]); const reloadDocumentFromServer = useCallback((key: string) => { if (documentConflict?.key !== key) return; @@ -627,11 +704,11 @@ export function IssueDocumentsSection({ }, []); const getDocumentRevisions = useCallback((key: string) => { - const cached = queryClient.getQueryData(queryKeys.issues.documentRevisions(issue.id, key)); + const cached = queryClient.getQueryData(documentSubject.documentRevisionsQueryKey(key)); if (cached) return cached; if (revisionMenuOpenKey === key) return activeDocumentRevisions ?? []; return []; - }, [activeDocumentRevisions, issue.id, queryClient, revisionMenuOpenKey]); + }, [activeDocumentRevisions, documentSubject, queryClient, revisionMenuOpenKey]); const returnToLatestRevision = useCallback((key: string) => { setSelectedRevisionIds((current) => ({ ...current, [key]: null })); @@ -691,27 +768,27 @@ export function IssueDocumentsSection({ }; useEffect(() => { - setFoldedDocumentKeys(loadFoldedDocumentKeys(issue.id)); - }, [issue.id]); + setFoldedDocumentKeys(loadFoldedDocumentKeys(documentSubject.id)); + }, [documentSubject.id]); useEffect(() => { hasScrolledToHashRef.current = false; - }, [issue.id, location.hash]); + }, [documentSubject.id, location.hash]); useEffect(() => { const validKeys = new Set(sortedDocuments.map((doc) => doc.key)); setFoldedDocumentKeys((current) => { const next = current.filter((key) => validKeys.has(key)); if (next.length !== current.length) { - saveFoldedDocumentKeys(issue.id, next); + saveFoldedDocumentKeys(documentSubject.id, next); } return next; }); - }, [issue.id, sortedDocuments]); + }, [documentSubject.id, sortedDocuments]); useEffect(() => { - saveFoldedDocumentKeys(issue.id, foldedDocumentKeys); - }, [foldedDocumentKeys, issue.id]); + saveFoldedDocumentKeys(documentSubject.id, foldedDocumentKeys); + }, [documentSubject.id, foldedDocumentKeys]); useEffect(() => { if (!documentConflict) return; @@ -729,7 +806,7 @@ export function IssueDocumentsSection({ if (!hash.startsWith("#document-")) return; const documentKey = decodeURIComponent(hash.slice("#document-".length)); const targetExists = sortedDocuments.some((doc) => doc.key === documentKey) - || (documentKey === "plan" && Boolean(issue.legacyPlanDocument)); + || (documentKey === "plan" && Boolean(documentSubject.legacyPlanDocument)); if (!targetExists || hasScrolledToHashRef.current) return; setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey)); const element = document.getElementById(`document-${documentKey}`); @@ -739,7 +816,7 @@ export function IssueDocumentsSection({ element.scrollIntoView({ behavior: "smooth", block: "center" }); const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000); return () => clearTimeout(timer); - }, [issue.legacyPlanDocument, location.hash, sortedDocuments]); + }, [documentSubject.legacyPlanDocument, location.hash, sortedDocuments]); useEffect(() => { return () => { @@ -892,7 +969,7 @@ export function IssueDocumentsSection({ )} - {!hasRealPlan && issue.legacyPlanDocument ? ( + {!hasRealPlan && documentSubject.legacyPlanDocument ? (
- {renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)} + {renderFoldableBody(documentSubject.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)}
) : null} @@ -936,6 +1013,7 @@ export function IssueDocumentsSection({ const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key); const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote); const lockActionPending = setDocumentLock.isPending && setDocumentLock.variables?.key === doc.key; + const annotationTarget = annotationTargetForKey(doc.key); return (
previewRevision(doc, revisionId), }} updatedAt={displayedUpdatedAt} - annotationSlot={!isSystemIssueDocumentKey(doc.key) ? ( + annotationSlot={documentSubject.annotations && !isSystemIssueDocumentKey(doc.key) ? ( toggleAnnotationPanel(doc.key)} @@ -1193,24 +1272,8 @@ export function IssueDocumentsSection({ activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10" }`} > - setAnnotationPanelOpen(doc.key, next)} - agentMap={agentMap} - userProfileMap={userProfileMap} - defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]} - > - {isHistoricalPreview ? ( + {(() => { + const renderedDocumentBody = isHistoricalPreview ? ( renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences) ) : activeDraft ? ( ) : ( renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences) - )} - + ); + + return documentSubject.annotations ? ( + setAnnotationPanelOpen(doc.key, next)} + agentMap={agentMap} + userProfileMap={userProfileMap} + defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]} + > + {renderedDocumentBody} + + ) : renderedDocumentBody; + })()}
documentSubject.listDocumentRevisions(diffDoc.key)} open onOpenChange={(open) => { if (!open) setDiffViewKey(null); }} /> diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 73b5f8a968..497250de9c 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -134,6 +134,7 @@ vi.mock("./AgentIconPicker", () => ({ vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: ReactNode; to: string } & ComponentProps<"a">) => {children}, + useCaseHref: () => (caseId: string) => `/cases/${caseId}`, })); vi.mock("@/components/ui/separator", () => ({ diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index b64f31ef27..cdcd49ed46 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -4,13 +4,23 @@ import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; -import { Link } from "@/lib/router"; +import { Link, useCaseHref } from "@/lib/router"; import { useTheme } from "../context/ThemeContext"; import { useOptionalCompany } from "../context/CompanyContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; +import { remarkLinkCaseReferences } from "../lib/case-reference"; + +const CASE_HREF_RE = /^\/cases\/([A-Z][A-Z0-9]*-C\d+)$/i; + +/** Recover the case identifier from a `/cases/PAP-C7` href produced by the plugin. */ +function caseIdentifierFromHref(href: string | undefined): string | null { + if (!href) return null; + const match = decodeURIComponent(href.trim()).match(CASE_HREF_RE); + return match ? match[1]!.toUpperCase() : null; +} import { parseWorkspaceFileHref, remarkWorkspaceFileRefs, WORKSPACE_FILE_HREF_PREFIX } from "../lib/remark-workspace-file-refs"; import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; import { StatusIcon } from "./StatusIcon"; @@ -52,6 +62,11 @@ interface MarkdownBodyProps { style?: React.CSSProperties; softBreaks?: boolean; linkIssueReferences?: boolean; + /** + * Linkify bare case identifiers (`PAP-C7`) to the case detail page. Off by + * default; enabled on surfaces behind the experimental Cases flag (PAP-12969). + */ + linkCaseReferences?: boolean; /** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */ enableWikiLinks?: boolean; /** Base href used for wikilinks when no resolver is supplied. */ @@ -110,6 +125,28 @@ function MarkdownIssueLink({ ); } +function MarkdownCaseLink({ + identifier, + children, +}: { + identifier: string; + children: ReactNode; +}) { + // Cases resolve via the get-by-identifier route; navigate there on click. + // Kept boxless/underlined to match the issue mention treatment. + const caseHref = useCaseHref(); + return ( + + {children} + + ); +} + function MarkdownExternalLink({ href, reference, @@ -652,6 +689,7 @@ function MarkdownBodyImpl({ style, softBreaks = true, linkIssueReferences = true, + linkCaseReferences = false, enableWikiLinks = false, wikiLinkRoot, resolveWikiLinkHref, @@ -698,11 +736,14 @@ function MarkdownBodyImpl({ if (linkIssueReferences) { plugins.push([remarkLinkIssueReferences, { knownPrefixes }]); } + if (linkCaseReferences) { + plugins.push([remarkLinkCaseReferences, { knownPrefixes }]); + } if (softBreaks) { plugins.push(remarkSoftBreaks); } return plugins; - }, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, linkWorkspaceFileRefs, linkIssueReferences, knownPrefixes, softBreaks]); + }, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, linkWorkspaceFileRefs, linkIssueReferences, linkCaseReferences, knownPrefixes, softBreaks]); const components = useMemo(() => { const map: Components = { p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => ( @@ -785,6 +826,11 @@ function MarkdownBodyImpl({ ); } + const caseIdentifier = linkCaseReferences ? caseIdentifierFromHref(href) : null; + if (caseIdentifier) { + return {linkChildren}; + } + const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { const targetHref = parsed.kind === "project" @@ -861,7 +907,7 @@ function MarkdownBodyImpl({ }; } return map; - }, [theme, linkIssueReferences, externalReferenceLookup, resolveImageSrc, onImageClick]); + }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick]); return (
+ {showCases ? ( + + ) : null} {showPipelines ? ( diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index e9f41bd41e..911228a24b 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -73,6 +73,7 @@ import { } from "./helpers"; import { PropertyPicker } from "./property-picker"; import { PropertyChip, PropertyRow, PropertySection } from "./primitives"; +import { IssueCasesPanel } from "../IssueCasesPanel"; import { ExpandRelationListButton, RemovableIssueReferencePill } from "./relation-controls"; import { Badge } from "@/components/ui/badge"; @@ -2298,6 +2299,12 @@ export function IssueProperties({ )} + + {/* Experimental Cases rail (PAP-12969) — self-gates on the flag and + renders nothing when no cases are linked. */} +
+ +
); } diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 1fe52db88c..422046a52c 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -441,6 +441,47 @@ describe("LiveUpdatesProvider issue invalidation", () => { }); }); + it("refreshes case document annotation caches when case annotation activity arrives", () => { + const invalidations: unknown[] = []; + const queryClient = { + invalidateQueries: (input: unknown) => { + invalidations.push(input); + }, + getQueryData: () => undefined, + }; + + __liveUpdatesTestUtils.invalidateActivityQueries( + queryClient as never, + "company-1", + { + entityType: "case", + entityId: "case-1", + action: "case.document_annotation_comment_added", + actorType: "user", + actorId: "user-2", + details: { + documentKey: "body", + threadId: "thread-1", + commentId: "comment-1", + }, + }, + { userId: "user-1", agentId: null }, + ); + + expect(invalidations).toContainEqual({ + queryKey: queryKeys.cases.list("company-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.cases.detail("case-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.cases.events("case-1"), + }); + expect(invalidations).toContainEqual({ + queryKey: ["cases", "document-annotations", "case-1", "body"], + }); + }); + it("keeps self-authored comment events from refetching the active issue tree", () => { const invalidations: unknown[] = []; const queryClient = { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 7a3976ba08..cef24b1f13 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -587,6 +587,13 @@ const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ "routine.document_annotation_thread_reopened", "routine.document_annotation_remapped", ]); +const CASE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([ + "case.document_annotation_thread_created", + "case.document_annotation_comment_added", + "case.document_annotation_thread_resolved", + "case.document_annotation_thread_reopened", + "case.document_annotation_remapped", +]); const AGENT_TOAST_STATUSES = new Set(["error"]); const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); @@ -975,6 +982,25 @@ function invalidateActivityQueries( return; } + if (entityType === "case") { + queryClient.invalidateQueries({ queryKey: queryKeys.cases.list(companyId) }); + if (entityId) { + queryClient.invalidateQueries({ queryKey: queryKeys.cases.detail(entityId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.cases.events(entityId) }); + if (action && CASE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action)) { + const documentKey = readString(details?.key) ?? readString(details?.documentKey); + const caseInvalidationOptions = ownActorActivity ? { refetchType: "inactive" as const } : undefined; + queryClient.invalidateQueries({ + queryKey: documentKey + ? ["cases", "document-annotations", entityId, documentKey] + : ["cases", "document-annotations", entityId], + ...caseInvalidationOptions, + }); + } + } + return; + } + if (entityType === "company") { queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); } diff --git a/ui/src/index.css b/ui/src/index.css index c76beed8ae..43430a2fda 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1682,6 +1682,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --pct-90: 90%; /* Extracted from ui/src/components/DocumentDiffModal.tsx (max-w-[90%]). */ --pct-50: 50%; /* Extracted from ui/src/components/ImageGalleryModal.tsx (max-w-[50%]). */ --pct-85: 85%; /* Extracted from ui/src/components/IssueChatThread.test.tsx (max-w-[85%]). */ + --pct-70: 70%; /* Extracted from ui/src/components/CaseFieldsPanel.tsx (max-w-[70%]). */ --pct-neg-50: -50%; /* Extracted from ui/src/components/ui/alert-dialog.tsx (translate-x-[-50%]). */ --pct-72: 72%; /* Extracted from ui/src/pages/IssueDetail.tsx (w-[72%]). */ --shadow-extract-1: 0 16px 40px rgba(37,99,235,0.08); /* ActiveAgentsPanel.tsx live-box glow. Gallery feedback r2: liveness glow recolored cyan->status blue (value edit, site unchanged; originally extracted as rgba(6,182,212,0.08)). */ @@ -1739,6 +1740,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { */ :root { --gtc-1: 56px 56px 24px minmax(0,1fr); /* Extracted from ui/src/components/DocumentDiffModal.tsx (grid-cols-[56px_56px_24px_minmax(0,1fr)]). */ + --gtc-case-revisions: 16rem 1fr; /* Extracted from ui/src/components/CaseRevisionRail.tsx (grid-cols-[16rem_1fr]). */ --gtc-2: auto minmax(0,1fr) 2.25rem; /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[auto_minmax(0,1fr)_2.25rem]). */ --gtc-3: minmax(0,1fr) 2.25rem; /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[minmax(0,1fr)_2.25rem]). */ --gtc-4: minmax(0,1fr); /* Extracted from ui/src/components/FileTree.tsx (grid-cols-[minmax(0,1fr)]). */ @@ -1825,6 +1827,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --z-9999: 9999; /* Extracted from ui/src/components/MarkdownEditor.tsx (z-[9999]). */ --z-120: 120; /* Extracted from ui/src/components/ToastViewport.tsx (z-[120]). */ --s-0_98: 0.98; /* Extracted from ui/src/pages/Inbox.tsx (scale-[0.98]). */ + --s-1_02: 1.02; /* Extracted from ui/src/components/CaseAttachmentsGallery.tsx (scale-[1.02]). */ --e-cubic-bezier-0_16-1-0_3-1: cubic-bezier(0.16,1,0.3,1); /* Extracted from ui/src/components/ui/alert-dialog.tsx (ease-[cubic-bezier(0.16,1,0.3,1)]). */ --va-0_125em: -0.125em; /* Extracted from ui/src/components/ExternalObjectStatusIcon.tsx (align-[-0.125em]). */ --sw-2_3: 2.3; /* Extracted from ui/src/components/MobileBottomNav.tsx (stroke-[2.3]). */ diff --git a/ui/src/lib/case-reference.test.ts b/ui/src/lib/case-reference.test.ts new file mode 100644 index 0000000000..e139b7f2f1 --- /dev/null +++ b/ui/src/lib/case-reference.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { parseCaseReferenceFromHref, remarkLinkCaseReferences } from "./case-reference"; + +describe("parseCaseReferenceFromHref", () => { + it("linkifies a bare case identifier to the case detail path", () => { + expect(parseCaseReferenceFromHref("PAP-C7")).toEqual({ + identifier: "PAP-C7", + href: "/cases/PAP-C7", + }); + }); + + it("normalizes case to upper", () => { + expect(parseCaseReferenceFromHref("pap-c12")?.identifier).toBe("PAP-C12"); + }); + + it("ignores plain issue identifiers (no -C infix)", () => { + expect(parseCaseReferenceFromHref("PAP-123")).toBeNull(); + expect(parseCaseReferenceFromHref("PAP-7")).toBeNull(); + }); + + it("respects the known-prefix allowlist when provided", () => { + expect(parseCaseReferenceFromHref("FOO-C1", new Set(["PAP"]))).toBeNull(); + expect(parseCaseReferenceFromHref("PAP-C1", new Set(["PAP"]))?.identifier).toBe("PAP-C1"); + }); + + it("stays permissive when no prefixes are known", () => { + expect(parseCaseReferenceFromHref("FOO-C1")?.identifier).toBe("FOO-C1"); + }); +}); + +describe("remarkLinkCaseReferences", () => { + it("rewrites a bare token inside a text node into a link node", () => { + const tree = { + type: "root", + children: [ + { + type: "paragraph", + children: [{ type: "text", value: "see PAP-C7 for details" }], + }, + ], + }; + remarkLinkCaseReferences()(tree as never); + const paragraph = (tree.children[0] as { children: Array<{ type: string; url?: string }> }); + const link = paragraph.children.find((c) => c.type === "link"); + expect(link?.url).toBe("/cases/PAP-C7"); + // Surrounding text is preserved on both sides. + expect(paragraph.children[0]).toMatchObject({ type: "text", value: "see " }); + expect(paragraph.children.at(-1)).toMatchObject({ type: "text", value: " for details" }); + }); + + it("rewrites inline code that is exactly a case identifier", () => { + const tree = { + type: "root", + children: [ + { type: "paragraph", children: [{ type: "inlineCode", value: "PAP-C42" }] }, + ], + }; + remarkLinkCaseReferences()(tree as never); + const paragraph = tree.children[0] as { children: Array<{ type: string; url?: string }> }; + expect(paragraph.children[0]).toMatchObject({ type: "link", url: "/cases/PAP-C42" }); + }); + + it("does not descend into existing links", () => { + const tree = { + type: "root", + children: [ + { + type: "link", + url: "https://example.com", + children: [{ type: "text", value: "PAP-C7" }], + }, + ], + }; + remarkLinkCaseReferences()(tree as never); + const link = tree.children[0] as { url: string; children: Array<{ type: string }> }; + expect(link.url).toBe("https://example.com"); + expect(link.children[0]!.type).toBe("text"); + }); +}); diff --git a/ui/src/lib/case-reference.ts b/ui/src/lib/case-reference.ts new file mode 100644 index 0000000000..233d5d03a7 --- /dev/null +++ b/ui/src/lib/case-reference.ts @@ -0,0 +1,109 @@ +// Linkify bare case identifiers (e.g. `PAP-C7`) inside markdown so they render +// as clickable chips pointing at the case detail page. Mirrors the sibling +// issue-reference plugin, but the `-C` infix keeps case tokens from ever +// colliding with plain issue identifiers (`PREFIX-`), so the two plugins can +// run side by side on the same tree. (PAP-12969 — Cases P4 comment chips.) + +type MarkdownNode = { + type: string; + value?: string; + url?: string; + children?: MarkdownNode[]; +}; + +const BARE_CASE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]*-C\d+$/i; +const CASE_REFERENCE_TOKEN_RE = /\b[A-Z][A-Z0-9]*-C\d+\b/gi; + +export function parseCaseReferenceFromHref( + value: string | null | undefined, + knownPrefixes?: Set, +): { identifier: string; href: string } | null { + if (!value) return null; + const trimmed = value.trim(); + if (!BARE_CASE_IDENTIFIER_RE.test(trimmed)) return null; + const normalized = trimmed.toUpperCase(); + // Only auto-link when the prefix belongs to a known company (mirrors the + // issue-reference gate). An empty/omitted set stays permissive so provider-less + // render surfaces still linkify deliberate references. + if (knownPrefixes && knownPrefixes.size > 0) { + const prefix = normalized.split("-")[0]; + if (!prefix || !knownPrefixes.has(prefix)) return null; + } + return { identifier: normalized, href: `/cases/${encodeURIComponent(normalized)}` }; +} + +function createCaseLinkNode( + value: string, + href: string, + childType: "text" | "inlineCode" = "text", +): MarkdownNode { + return { type: "link", url: href, children: [{ type: childType, value }] }; +} + +function linkifyCaseReferencesInText(value: string, knownPrefixes?: Set): MarkdownNode[] | null { + const nodes: MarkdownNode[] = []; + let cursor = 0; + let matched = false; + for (const match of value.matchAll(CASE_REFERENCE_TOKEN_RE)) { + const raw = match[0]; + if (!raw) continue; + const caseRef = parseCaseReferenceFromHref(raw, knownPrefixes); + if (!caseRef) continue; + const start = match.index ?? 0; + matched = true; + if (start > cursor) nodes.push({ type: "text", value: value.slice(cursor, start) }); + nodes.push(createCaseLinkNode(raw, caseRef.href)); + cursor = start + raw.length; + } + if (!matched) return null; + if (cursor < value.length) nodes.push({ type: "text", value: value.slice(cursor) }); + return nodes; +} + +function rewriteMarkdownTree(node: MarkdownNode, knownPrefixes?: Set) { + if (!Array.isArray(node.children) || node.children.length === 0) return; + if ( + node.type === "link" || + node.type === "linkReference" || + node.type === "code" || + node.type === "definition" || + node.type === "html" + ) { + return; + } + const nextChildren: MarkdownNode[] = []; + for (const child of node.children) { + if (child.type === "inlineCode" && typeof child.value === "string") { + const caseRef = parseCaseReferenceFromHref(child.value, knownPrefixes); + if (caseRef) { + nextChildren.push(createCaseLinkNode(child.value, caseRef.href, "inlineCode")); + continue; + } + } + if (child.type === "text" && typeof child.value === "string") { + const linked = linkifyCaseReferencesInText(child.value, knownPrefixes); + if (linked) { + nextChildren.push(...linked); + continue; + } + } + rewriteMarkdownTree(child, knownPrefixes); + nextChildren.push(child); + } + node.children = nextChildren; +} + +export interface RemarkLinkCaseReferencesOptions { + /** Company prefixes eligible for auto-linking (see parseCaseReferenceFromHref). */ + knownPrefixes?: string[]; +} + +export function remarkLinkCaseReferences(options?: RemarkLinkCaseReferencesOptions) { + const knownPrefixes = + options?.knownPrefixes && options.knownPrefixes.length > 0 + ? new Set(options.knownPrefixes.map((prefix) => prefix.toUpperCase())) + : undefined; + return (tree: MarkdownNode) => { + rewriteMarkdownTree(tree, knownPrefixes); + }; +} diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 05a2c6a0b7..220c6ed16d 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -83,6 +83,27 @@ export function applyCompanyPrefix(path: string, companyPrefix: string | null | return `/${prefix}${pathname}${search}${hash}`; } +/** + * Build a company-prefixed href for an experimental Cases route, e.g. + * `caseHref("PAP", "PAP-C5")` → `/PAP/cases/PAP-C5`. + * + * Case paths carry identifiers like `PAP-C5` in the first segment, which the + * generic {@link applyCompanyPrefix} mistakes for a company prefix ("CASES") and + * therefore leaves `/cases/...` unprefixed — every case link then only resolves + * via the PAP-13002 unprefixed→prefixed redirect. This builder emits the + * prefixed href directly so case-to-case navigation matches the rest of the app. + * Falls back to the unprefixed path (still valid via the redirect) when no + * company is active. + */ +export function caseHref( + companyPrefix: string | null | undefined, + ...segments: string[] +): string { + const suffix = ["cases", ...segments].filter(Boolean).join("/"); + if (!companyPrefix) return `/${suffix}`; + return `/${normalizeCompanyPrefix(companyPrefix)}/${suffix}`; +} + export function toCompanyRelativePath(path: string): string { const { pathname, search, hash } = splitPath(path); const segments = pathname.split("/").filter(Boolean); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index edfe492644..457863ac54 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -185,6 +185,17 @@ export const queryKeys = { list: (companyId: string) => ["projects", companyId] as const, detail: (id: string) => ["projects", "detail", id] as const, }, + cases: { + list: (companyId: string) => ["cases", companyId] as const, + detail: (id: string) => ["cases", "detail", id] as const, + documents: (id: string) => ["cases", "documents", id] as const, + documentAnnotations: (caseId: string, key: string, status: "open" | "resolved" | "all" = "all") => + ["cases", "document-annotations", caseId, key, status] as const, + events: (id: string) => ["cases", "events", id] as const, + children: (parentId: string) => ["cases", "children", parentId] as const, + revisions: (id: string, key: string) => ["cases", "revisions", id, key] as const, + forIssue: (issueId: string) => ["cases", "for-issue", issueId] as const, + }, externalObjects: { byIssue: (issueId: string) => ["external-objects", "by-issue", issueId] as const, issueSummary: (issueId: string) => ["external-objects", "issue-summary", issueId] as const, diff --git a/ui/src/lib/router.tsx b/ui/src/lib/router.tsx index 488911441a..5870865204 100644 --- a/ui/src/lib/router.tsx +++ b/ui/src/lib/router.tsx @@ -6,6 +6,7 @@ import { useCompany } from "@/context/CompanyContext"; import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook"; import { applyCompanyPrefix, + caseHref, extractCompanyPrefixFromPath, normalizeCompanyPrefix, } from "@/lib/company-routes"; @@ -26,7 +27,7 @@ function resolveTo(to: To, companyPrefix: string | null): To { return to; } -function useActiveCompanyPrefix(): string | null { +export function useActiveCompanyPrefix(): string | null { const { selectedCompany } = useCompany(); const params = RouterDom.useParams<{ companyPrefix?: string }>(); const location = RouterDom.useLocation(); @@ -41,6 +42,19 @@ function useActiveCompanyPrefix(): string | null { return selectedCompany ? normalizeCompanyPrefix(selectedCompany.issuePrefix) : null; } +/** + * Returns a builder for company-prefixed Cases hrefs bound to the active company + * (e.g. `/PAP/cases/PAP-C5`). Use for all case-to-case links so they emit + * prefixed paths directly instead of leaning on the PAP-13002 redirect. + */ +export function useCaseHref(): (...segments: string[]) => string { + const companyPrefix = useActiveCompanyPrefix(); + return React.useCallback( + (...segments: string[]) => caseHref(companyPrefix, ...segments), + [companyPrefix], + ); +} + export * from "react-router-dom"; type CompanyLinkProps = React.ComponentProps & { diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts index fa6b2d3b73..bdedbf3d7d 100644 --- a/ui/src/lib/status-colors.ts +++ b/ui/src/lib/status-colors.ts @@ -104,6 +104,11 @@ export const statusBadge: Record = { approved: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", rejected: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", + // Case statuses (PAP-12968 E3) — `draft` is the neutral pre-work state, + // rendered as a muted gray alias of `backlog`/`planned`. The other case + // statuses (in_progress/in_review/approved/done/cancelled) already map above. + draft: "bg-muted text-muted-foreground", + // Issue statuses — consistent hues with issueStatusIcon above (PAP-75 brand // mapping: todo → amber, in_progress → blue "liveness"). backlog: "bg-muted text-muted-foreground", diff --git a/ui/src/pages/CaseDetail.test.tsx b/ui/src/pages/CaseDetail.test.tsx new file mode 100644 index 0000000000..88a4c9a283 --- /dev/null +++ b/ui/src/pages/CaseDetail.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaseDetail as CaseDetailData, CaseSummary } from "@/api/cases"; +import { CaseDetail } from "./CaseDetail"; + +function act(callback: () => void) { + flushSync(callback); +} + +const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" })); +const mockCasesApi = vi.hoisted(() => ({ + get: vi.fn(), + listEvents: vi.fn(), + list: vi.fn(), + listChildren: vi.fn(), + patch: vi.fn(), + getDocument: vi.fn(), + listRevisions: vi.fn(), + upsertDocument: vi.fn(), + lockDocument: vi.fn(), + unlockDocument: vi.fn(), + restoreDocumentRevision: vi.fn(), + deleteDocument: vi.fn(), +})); +const mockIssuesApi = vi.hoisted(() => ({ listLabels: vi.fn(), createLabel: vi.fn() })); +const panelState = vi.hoisted(() => ({ openPanel: vi.fn(), closePanel: vi.fn() })); +const mockCopyTextToClipboard = vi.hoisted(() => vi.fn(() => Promise.resolve())); + +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => companyState })); +vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); +vi.mock("@/context/PanelContext", () => ({ usePanel: () => panelState })); +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi })); +vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: mockCopyTextToClipboard })); +vi.mock("@/components/MarkdownBody", () => ({ + MarkdownBody: ({ children }: { children: string }) =>
{children}
, +})); +vi.mock("@/components/IssueDocumentsSection", () => ({ + IssueDocumentsSection: ({ subject }: { subject?: { id: string } }) => ( +
Documents {subject?.id}
+ ), +})); +vi.mock("@/lib/router", () => ({ + useParams: () => ({ caseIdentifier: "PAP-C7" }), + useLocation: () => ({ hash: "" }), + Navigate: () => null, + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + assertion(); + return; + } catch (e) { + lastError = e; + await flush(); + } + } + throw lastError; +} + +function detail(): CaseDetailData { + return { + id: "case-1", + companyId: "company-1", + projectId: null, + caseNumber: 7, + identifier: "PAP-C7", + caseType: "blog_post", + key: "v2026.707/hermes-agent-post", + title: "Hermes agent launch post", + summary: null, + status: "in_review", + fields: { + slug: "hermes-agent-post", + body: "Legacy body field", + runbook: "Legacy runbook field", + word_count: 1850, + published: true, + description: "Launch narrative", + issue_identifiers: ["PAP-12947"], + }, + parent: null, + parentCaseId: null, + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + labels: [], + issueLinks: [ + { + id: "link-1", + caseId: "case-1", + issueId: "issue-1", + role: "reference", + createdAt: "2026-07-07T00:00:00.000Z", + issue: { + id: "issue-1", + identifier: "PAP-12947", + title: "Case object exploration", + status: "in_progress", + }, + }, + ], + documents: [ + { + key: "body", + document: { + id: "doc-1", + companyId: "company-1", + title: "body", + format: "markdown", + latestBody: "# Draft body\n\nSome content.", + latestRevisionId: "rev-8", + latestRevisionNumber: 8, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + }, + }, + { + key: "runbook", + document: { + id: "doc-2", + companyId: "company-1", + title: "runbook", + format: "markdown", + latestBody: "# Runbook\n\nSteps.", + latestRevisionId: "rev-2", + latestRevisionNumber: 2, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: "agent-1", + updatedByUserId: null, + lockedAt: null, + lockedByAgentId: null, + lockedByUserId: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + }, + }, + ], + attachments: [], + }; +} + +function childCase(index: number): CaseSummary { + return { + id: `child-${index}`, + companyId: "company-1", + projectId: null, + caseNumber: index, + identifier: `PAP-C${index}`, + caseType: "child_case", + key: null, + title: `Child case ${index}`, + summary: null, + status: "in_progress", + fields: {}, + parentCaseId: "case-1", + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + }; +} + +function renderPage(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + return root; +} + +describe("CaseDetail", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + panelState.openPanel.mockClear(); + panelState.closePanel.mockClear(); + mockCasesApi.get.mockReset().mockResolvedValue(detail()); + mockCasesApi.listEvents.mockReset().mockResolvedValue([]); + mockCasesApi.list.mockReset().mockResolvedValue([]); + mockCasesApi.listChildren.mockReset().mockResolvedValue([]); + mockCasesApi.getDocument.mockReset(); + mockCasesApi.listRevisions.mockReset().mockResolvedValue({ + key: "body", + document: { + id: "doc-1", + title: "body", + format: "markdown", + latestRevisionId: "rev-8", + latestRevisionNumber: 8, + }, + revisions: [], + }); + mockCasesApi.upsertDocument.mockReset(); + mockCasesApi.lockDocument.mockReset(); + mockCasesApi.unlockDocument.mockReset(); + mockCasesApi.restoreDocumentRevision.mockReset(); + mockCasesApi.deleteDocument.mockReset(); + mockIssuesApi.listLabels.mockReset().mockResolvedValue([]); + mockCopyTextToClipboard.mockClear(); + }); + afterEach(() => { + container.remove(); + }); + + it("renders the case header and body-first overview without duplicating generic fields", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + // header + expect(container.textContent).toContain("PAP-C7"); + expect(container.textContent).toContain("blog_post"); + expect(container.textContent).toContain("Hermes agent launch post"); + // upsert key (detail-only) + expect(container.textContent).toContain("v2026.707/hermes-agent-post"); + // shared document section + expect(container.textContent).toContain("Documents case-1"); + expect(container.textContent).toContain("Launch narrative"); + expect(container.textContent).not.toContain("Revisions"); + expect(container.textContent).not.toContain("1,850"); + }); + + act(() => root.unmount()); + }); + + it("copies the case identifier from the header", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("PAP-C7"); + }); + + const caseIdButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "PAP-C7" + ); + expect(caseIdButton).toBeTruthy(); + act(() => { + caseIdButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("PAP-C7"); + expect(caseIdButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("keeps the case identifier and key in one copyable header group", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("v2026.707/hermes-agent-post"); + }); + + const identityGroup = container.querySelector('[data-case-identity-group="true"]'); + expect(identityGroup).not.toBeNull(); + expect(identityGroup?.className).toContain("whitespace-nowrap"); + + const keyButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "v2026.707/hermes-agent-post" + ); + expect(keyButton).toBeTruthy(); + act(() => { + keyButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("v2026.707/hermes-agent-post"); + expect(keyButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("shows parent and capped children together above the tabs", async () => { + const caseWithParent = { + ...detail(), + parentCaseId: "case-parent", + parent: { + id: "case-parent", + identifier: "PAP-C2", + title: "Parent case", + caseType: "campaign", + status: "approved" as const, + }, + }; + mockCasesApi.get.mockResolvedValue(caseWithParent); + mockCasesApi.listChildren.mockResolvedValue(Array.from({ length: 6 }, (_, index) => childCase(index + 3))); + + const root = renderPage(container); + + await waitForAssertion(() => { + const text = container.textContent ?? ""; + expect(text).toContain("Parent"); + expect(text).toContain("PAP-C2"); + expect(text).toContain("Parent case"); + expect(text).toContain("Children 6"); + expect(text).toContain("Child case 7"); + expect(text).not.toContain("Child case 8"); + expect(text).toContain("Show 1 more"); + expect(text.indexOf("Parent")).toBeLessThan(text.indexOf("Overview")); + expect(text.indexOf("Children 6")).toBeLessThan(text.indexOf("Overview")); + }); + + const showMore = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Show 1 more") + ); + expect(showMore).toBeTruthy(); + act(() => { + showMore!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Child case 8"); + expect(container.textContent).not.toContain("Show 1 more"); + }); + + act(() => root.unmount()); + }); + + it("renders primary fields and task references in the compact properties panel", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(panelState.openPanel).toHaveBeenCalled(); + }); + + const panelContainer = document.createElement("div"); + document.body.appendChild(panelContainer); + const panelRoot = createRoot(panelContainer); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + panelRoot.render( + + {panelState.openPanel.mock.calls.at(-1)?.[0]} + , + ); + }); + + await waitForAssertion(() => { + const text = panelContainer.textContent ?? ""; + expect(text).toContain("Fields"); + expect(text).toContain("v2026.707/hermes-agent-post"); + expect(text).toContain("title"); + expect(text).toContain("Hermes agent launch post"); + expect(text).toContain("description"); + expect(text).toContain("Launch narrative"); + expect(text).toContain("word_count"); + expect(text).toContain("Linked tasks"); + expect(text).toContain("PAP-12947"); + expect(text).not.toContain("body"); + expect(text).not.toContain("Legacy body field"); + expect(text).not.toContain("runbook"); + expect(text).not.toContain("Legacy runbook field"); + expect(text).not.toContain("Documents"); + expect(text).not.toContain("reference"); + expect(text).not.toContain("Activity"); + }); + + const keyValue = Array.from(panelContainer.querySelectorAll("button")).find((button) => + button.textContent === "v2026.707/hermes-agent-post" + ); + expect(keyValue).toBeTruthy(); + act(() => { + keyValue!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("v2026.707/hermes-agent-post"); + expect(keyValue!.parentElement?.textContent).toContain("Copied"); + }); + + const titleValue = Array.from(panelContainer.querySelectorAll("button")).find((button) => + button.textContent === "Hermes agent launch post" + ); + expect(titleValue).toBeTruthy(); + act(() => { + titleValue!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("Hermes agent launch post"); + expect(titleValue!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => panelRoot.unmount()); + panelContainer.remove(); + act(() => root.unmount()); + }); + + it("adds a full properties tab with expanded values", async () => { + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Properties"); + }); + + const propertiesTab = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Properties") + ); + expect(propertiesTab).toBeTruthy(); + act(() => { + propertiesTab!.focus(); + propertiesTab!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + await waitForAssertion(() => { + const text = container.textContent ?? ""; + expect(text).toContain("title"); + expect(text).toContain("Hermes agent launch post"); + expect(text).toContain("issue_identifiers"); + expect(container.querySelector('a[data-mention-kind="issue"][href="/issues/PAP-12947"]')).not.toBeNull(); + expect(text).not.toContain("body"); + expect(text).not.toContain("Legacy body field"); + expect(text).not.toContain("runbook"); + expect(text).not.toContain("Legacy runbook field"); + }); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/pages/CaseDetail.tsx b/ui/src/pages/CaseDetail.tsx new file mode 100644 index 0000000000..222845d975 --- /dev/null +++ b/ui/src/pages/CaseDetail.tsx @@ -0,0 +1,719 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, ChevronDown, Copy, MoreVertical, Plus, SlidersHorizontal } from "lucide-react"; +import { Link, Navigate, useCaseHref, useParams } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { usePanel } from "@/context/PanelContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { + casesApi, + CASE_STATUSES, + caseDocumentToIssueDocument, + caseRevisionToDocumentRevision, + type CaseDocument, + type CaseDetail as CaseDetailData, + type CaseParentRef, + type CaseStatus, + type CaseSummary, +} from "@/api/cases"; +import { issuesApi } from "@/api/issues"; +import type { IssueDocument } from "@paperclipai/shared"; +import { PROJECT_COLORS, type IssueLabel } from "@paperclipai/shared"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { StatusBadge } from "@/components/StatusBadge"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { CaseFieldValue } from "@/components/CaseFieldsPanel"; +import { CaseActivityFeed } from "@/components/CaseActivityFeed"; +import { CaseChildrenTree } from "@/components/CaseChildrenTree"; +import { CaseAttachmentsGallery } from "@/components/CaseAttachmentsGallery"; +import { IssueReferencePill } from "@/components/IssueReferencePill"; +import { PropertyChip, PropertyRow, PropertySection } from "@/components/issue-properties"; +import { IssueDocumentsSection } from "@/components/IssueDocumentsSection"; +import { CaseCopyableToken, CaseIdentifierKey } from "@/components/CaseIdentifierKey"; +import { copyTextToClipboard } from "@/lib/clipboard"; +import { cn } from "@/lib/utils"; + +const STATUS_LABEL: Record = { + draft: "Draft", + in_progress: "In progress", + in_review: "In review", + approved: "Approved", + done: "Done", + cancelled: "Cancelled", +}; + +const PRIMARY_FIELD_KEYS = ["name", "title", "body", "description"] as const; +const ISSUE_REFERENCE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"] as const; + +type CasePropertyDisplayMode = "compact" | "full"; +type IssueReferenceStatus = (typeof ISSUE_REFERENCE_STATUSES)[number]; + +function issueReferenceStatus(status: string): IssueReferenceStatus | undefined { + return ISSUE_REFERENCE_STATUSES.includes(status as IssueReferenceStatus) + ? status as IssueReferenceStatus + : undefined; +} + +function fieldValueByName(fields: Record, name: string): unknown { + return fields[name] ?? fields[name.charAt(0).toUpperCase() + name.slice(1)]; +} + +function caseFieldKeyVariants(key: string): string[] { + if (!key) return [key]; + return [key, key.charAt(0).toUpperCase() + key.slice(1), key.charAt(0).toLowerCase() + key.slice(1)]; +} + +function hasFieldValue(value: unknown): boolean { + return value !== null && value !== undefined && !(typeof value === "string" && value.trim() === ""); +} + +function casePropertyRows(caseData: CaseDetailData) { + const reservedKeys = new Set(PRIMARY_FIELD_KEYS.flatMap((key) => caseFieldKeyVariants(key))); + const documentKeys = new Set(caseData.documents.flatMap((documentRef) => caseFieldKeyVariants(documentRef.key))); + const primary = PRIMARY_FIELD_KEYS.map((key) => { + if (caseFieldKeyVariants(key).some((variant) => documentKeys.has(variant))) return null; + let value: unknown; + if (key === "title") value = fieldValueByName(caseData.fields, key) ?? caseData.title; + else if (key === "body") value = fieldValueByName(caseData.fields, key); + else value = fieldValueByName(caseData.fields, key); + return { key, label: key, value }; + }).filter((row): row is { key: typeof PRIMARY_FIELD_KEYS[number]; label: typeof PRIMARY_FIELD_KEYS[number]; value: unknown } => + row !== null && hasFieldValue(row.value) + ); + + const generic = Object.entries(caseData.fields) + .filter(([key]) => !reservedKeys.has(key) && !documentKeys.has(key)) + .map(([key, value]) => ({ key, label: key, value })); + + return [...primary, ...generic]; +} + +function issueDocumentToCaseDocument(document: IssueDocument): CaseDocument { + return { + id: document.id, + companyId: document.companyId, + title: document.title, + format: document.format, + latestBody: document.body, + latestRevisionId: document.latestRevisionId, + latestRevisionNumber: document.latestRevisionNumber, + createdByAgentId: document.createdByAgentId, + createdByUserId: document.createdByUserId, + updatedByAgentId: document.updatedByAgentId, + updatedByUserId: document.updatedByUserId, + lockedAt: document.lockedAt ? new Date(document.lockedAt).toISOString() : null, + lockedByAgentId: document.lockedByAgentId, + lockedByUserId: document.lockedByUserId, + sourceTrust: document.sourceTrust, + createdAt: new Date(document.createdAt).toISOString(), + updatedAt: new Date(document.updatedAt).toISOString(), + }; +} + +function CaseRelationshipsSection({ + parent, + children, +}: { + parent: CaseParentRef | null; + children: CaseSummary[]; +}) { + if (!parent && children.length === 0) return null; + + return ( +
+ {parent ? ( +
+

Parent

+ +
+ ) : null} + {children.length > 0 ? ( +
+

Children {children.length}

+ +
+ ) : null} +
+ ); +} + +function CasePropertyRow({ + label, + children, + wrap, + mode, +}: { + label: string; + children: ReactNode; + wrap?: boolean; + mode: CasePropertyDisplayMode; +}) { + if (mode === "compact") { + return ( + + {children} + + ); + } + + return ( +
+ + {label} + +
{children}
+
+ ); +} + +/** Status dropdown — the primary human write in v1 (§3). */ +function CaseStatusPicker({ + status, + onChange, + disabled, +}: { + status: CaseStatus; + onChange: (next: CaseStatus) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + + {CASE_STATUSES.map((s) => ( + + ))} + + + ); +} + +/** Label editor — the second human write in v1 (§3). Reuses company labels. */ +function CaseLabelsPicker({ + companyId, + selected, + onChange, +}: { + companyId: string; + selected: IssueLabel[]; + onChange: (labelIds: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [newColor, setNewColor] = useState(PROJECT_COLORS[0]); + const queryClient = useQueryClient(); + const labelsQuery = useQuery({ + queryKey: queryKeys.issues.labels(companyId), + queryFn: () => issuesApi.listLabels(companyId), + enabled: open, + }); + const selectedIds = new Set(selected.map((l) => l.id)); + const createLabel = useMutation({ + mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId, data), + onSuccess: (label) => { + queryClient.setQueryData(queryKeys.issues.labels(companyId), (prev) => + prev ? [...prev, label] : [label], + ); + onChange([...selectedIds, label.id]); + setSearch(""); + }, + }); + + const all = labelsQuery.data ?? []; + const filtered = search.trim() + ? all.filter((l) => l.name.toLowerCase().includes(search.trim().toLowerCase())) + : all; + + function toggle(id: string) { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + onChange([...next]); + } + + return ( + + + + + + setSearch(e.target.value)} + placeholder="Search labels…" + className="mb-2 h-7 text-xs" + /> +
+ {filtered.map((l) => ( + + ))} + {filtered.length === 0 && !search.trim() && ( +

No labels yet.

+ )} +
+ {search.trim() && !all.some((l) => l.name.toLowerCase() === search.trim().toLowerCase()) && ( +
+ setNewColor(e.target.value)} + className="h-6 w-6 shrink-0 cursor-pointer rounded border border-border bg-transparent" + aria-label="New label color" + /> + +
+ )} +
+
+ ); +} + +/** Right-rail content pushed into the shared PropertiesPanel (§3). */ +function CasePropertiesContent({ + caseData, + childCases, + companyId, + labelsPending, + onLabelIdsChange, + mode, +}: { + caseData: CaseDetailData; + childCases: CaseSummary[]; + companyId: string | null | undefined; + labelsPending?: boolean; + onLabelIdsChange: (labelIds: string[]) => void; + mode: CasePropertyDisplayMode; +}) { + const propertyRows = casePropertyRows(caseData); + const isFull = mode === "full"; + + return ( +
+ + + {caseData.caseType} + + {caseData.key ? ( + + + + ) : null} + + {caseData.labels.length > 0 ? ( + caseData.labels.map((label) => ( + + {label.name} + + )) + ) : ( + None + )} + {companyId ? ( + + ) : null} + {labelsPending ? Saving... : null} + + + + {propertyRows.length > 0 ? ( + + {propertyRows.map(({ key, label, value }) => ( + + + + + + ))} + + ) : null} + + + {caseData.issueLinks.length === 0 ? ( + + None yet + + ) : ( + +
+ {caseData.issueLinks.map((link) => ( + + ))} +
+
+ )} +
+ + 0 ? ` ${childCases.length}` : ""}`}> + + + + {caseData.attachments.length > 0 ? ( + + + + {caseData.attachments.length} {caseData.attachments.length === 1 ? "file" : "files"} + + + + ) : null} +
+ ); +} + +export function CaseDetail() { + const { caseIdentifier } = useParams<{ caseIdentifier: string }>(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { openPanel, closePanel } = usePanel(); + const queryClient = useQueryClient(); + const caseHref = useCaseHref(); + const [copied, setCopied] = useState(false); + + const caseQuery = useQuery({ + queryKey: queryKeys.cases.detail(caseIdentifier ?? ""), + queryFn: () => casesApi.get(caseIdentifier!), + enabled: !!caseIdentifier, + }); + const caseData = caseQuery.data; + const caseDetailQueryKey = queryKeys.cases.detail(caseIdentifier ?? ""); + + const eventsQuery = useQuery({ + queryKey: queryKeys.cases.events(caseIdentifier ?? ""), + queryFn: () => casesApi.listEvents(caseIdentifier!, 100), + enabled: !!caseIdentifier, + }); + + // Children come from the server-side parent filter (P4). All statuses, so the + // tree shows completed/cancelled children too — it's a structural view, not a + // work queue. + const childrenQuery = useQuery({ + queryKey: queryKeys.cases.children(caseData?.id ?? ""), + queryFn: () => casesApi.listChildren(selectedCompanyId!, caseData!.id), + enabled: !!selectedCompanyId && !!caseData?.id, + }); + const children = useMemo(() => childrenQuery.data ?? [], [childrenQuery.data]); + + const patchMutation = useMutation({ + mutationFn: (input: { status?: CaseStatus; labelIds?: string[] }) => + casesApi.patch(caseIdentifier!, input), + onSuccess: (updated) => { + queryClient.setQueryData(queryKeys.cases.detail(caseIdentifier ?? ""), updated); + queryClient.invalidateQueries({ queryKey: queryKeys.cases.events(caseIdentifier ?? "") }); + }, + }); + + const handleLabelIdsChange = useCallback((labelIds: string[]) => { + patchMutation.mutate({ labelIds }); + }, [patchMutation.mutate]); + + useEffect(() => { + setBreadcrumbs([ + { label: "Cases", href: caseHref() }, + { label: caseData ? `${caseData.identifier} — ${caseData.title}` : (caseIdentifier ?? "Case") }, + ]); + }, [setBreadcrumbs, caseData, caseIdentifier, caseHref]); + + const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery.data]); + const caseDocumentSubject = useMemo(() => { + if (!caseData || !caseIdentifier) return null; + return { + id: caseData.id, + detailQueryKey: caseDetailQueryKey, + documentsQueryKey: queryKeys.cases.documents(caseData.id), + idleDocumentRevisionsQueryKey: ["cases", "revisions", caseData.id, "__idle__"] as const, + documentRevisionsQueryKey: (key: string) => queryKeys.cases.revisions(caseData.id, key), + listDocuments: async () => { + const cached = queryClient.getQueryData(caseDetailQueryKey); + const detail = cached ?? await casesApi.get(caseIdentifier); + return detail.documents.map((documentRef) => + caseDocumentToIssueDocument(detail.id, documentRef.key, documentRef.document) + ); + }, + listDocumentRevisions: async (key: string) => { + const revisions = await casesApi.listRevisions(caseIdentifier, key); + return revisions.revisions.map((revision) => caseRevisionToDocumentRevision(caseData.id, key, revision)); + }, + getDocument: async (key: string) => { + const document = await casesApi.getDocument(caseIdentifier, key); + return caseDocumentToIssueDocument(caseData.id, document.key, document); + }, + upsertDocument: async (key: string, data: { title: string | null; format: "markdown"; body: string; baseRevisionId: string | null }) => { + const result = await casesApi.upsertDocument(caseIdentifier, key, data); + return caseDocumentToIssueDocument(caseData.id, result.document.key, result.document); + }, + deleteDocument: (key: string) => casesApi.deleteDocument(caseIdentifier, key), + restoreDocumentRevision: async (key: string, revisionId: string) => { + const result = await casesApi.restoreDocumentRevision(caseIdentifier, key, revisionId); + return caseDocumentToIssueDocument(caseData.id, result.document.key, result.document); + }, + setDocumentLock: async (key: string, locked: boolean) => { + const document = locked + ? await casesApi.lockDocument(caseIdentifier, key) + : await casesApi.unlockDocument(caseIdentifier, key); + return caseDocumentToIssueDocument(caseData.id, document.key, document); + }, + syncDetailCache: (cache: typeof queryClient, document: IssueDocument) => { + cache.setQueryData(caseDetailQueryKey, (current) => { + if (!current) return current; + const nextDocumentRef = { + key: document.key, + document: issueDocumentToCaseDocument(document), + }; + const existingIndex = current.documents.findIndex((entry) => entry.key === document.key); + const documents = existingIndex === -1 + ? [...current.documents, nextDocumentRef] + : current.documents.map((entry, index) => index === existingIndex ? nextDocumentRef : entry); + return { + ...current, + documents, + updatedAt: new Date(document.updatedAt).toISOString(), + }; + }); + }, + hideSystemDocuments: false, + legacyPlanDocument: null, + annotations: { + issueId: caseData.id, + target: (documentKey: string) => ({ kind: "case" as const, caseId: caseData.id, documentKey }), + }, + }; + }, [caseData, caseDetailQueryKey, caseIdentifier, queryClient]); + const panelContent = useMemo(() => { + if (!caseData) return null; + return ( + + ); + }, [caseData, children, selectedCompanyId, patchMutation.isPending, handleLabelIdsChange]); + + useEffect(() => { + if (!panelContent) return; + openPanel(panelContent); + return () => closePanel(); + }, [panelContent, openPanel, closePanel]); + + if (!caseIdentifier) return ; + if (caseQuery.isLoading) return ; + if (caseQuery.isError || !caseData) { + return ( +
+

Case not found.

+ + ← Back to cases + +
+ ); + } + + const description = caseData.fields.description ?? caseData.fields.Description ?? null; + + function copyCaseToClipboard(currentCase: CaseDetailData) { + const markdown = [ + `# ${currentCase.identifier} ${currentCase.title}`, + "", + `- Key: ${currentCase.key ?? "none"}`, + `- Type: ${currentCase.caseType}`, + `- Status: ${STATUS_LABEL[currentCase.status]}`, + currentCase.labels.length > 0 ? `- Labels: ${currentCase.labels.map((label) => label.name).join(", ")}` : "- Labels: none", + ].join("\n"); + void copyTextToClipboard(markdown).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + } + + return ( +
+
+
+
+ +

{caseData.title}

+
+
+ patchMutation.mutate({ status })} + /> + + + + + + + + + +
+
+ +
+ {caseData.caseType} +
+ + +
+ + + + Overview + Properties + + Activity{events.length > 0 && {events.length}} + + + + + {caseDocumentSubject ? ( + + ) : null} + + {description ? ( +
+

Description

+ + + +
+ ) : null} + + {caseData.attachments.length > 0 && ( +
+

Attachments ({caseData.attachments.length})

+ +
+ )} +
+ + + + + + + + +
+
+ ); +} diff --git a/ui/src/pages/Cases.test.tsx b/ui/src/pages/Cases.test.tsx new file mode 100644 index 0000000000..3caec1c8cf --- /dev/null +++ b/ui/src/pages/Cases.test.tsx @@ -0,0 +1,599 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { AnchorHTMLAttributes } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CaseSummary } from "@/api/cases"; +import { Cases } from "./Cases"; + +function act(callback: () => void) { + flushSync(callback); +} + +const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" })); +const mockCasesApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockIssuesApi = vi.hoisted(() => ({ listLabels: vi.fn() })); +const mockCopyTextToClipboard = vi.hoisted(() => vi.fn(() => Promise.resolve())); +const mockNavigate = vi.hoisted(() => vi.fn()); +const generalSettingsState = vi.hoisted(() => ({ keyboardShortcutsEnabled: false })); + +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => companyState })); +vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) })); +vi.mock("@/context/GeneralSettingsContext", () => ({ useGeneralSettings: () => generalSettingsState })); +vi.mock("@/api/cases", async (importOriginal) => ({ + ...(await importOriginal()), + casesApi: mockCasesApi, +})); +vi.mock("@/api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi })); +vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: mockCopyTextToClipboard })); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useNavigate: () => mockNavigate, + useCaseHref: () => (...segments: string[]) => + `/PAP/${["cases", ...segments].filter(Boolean).join("/")}`, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + await new Promise((r) => setTimeout(r, 0)); + } + flushSync(() => {}); +} +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let i = 0; i < attempts; i += 1) { + try { + assertion(); + return; + } catch (e) { + lastError = e; + await flush(); + } + } + throw lastError; +} +function dispatchShortcut(key: string) { + act(() => { + document.body.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })); + }); +} + +function createCase(overrides: Partial): CaseSummary { + return { + id: overrides.id ?? "case-1", + companyId: "company-1", + projectId: null, + caseNumber: 1, + identifier: overrides.identifier ?? "PAP-C1", + caseType: overrides.caseType ?? "blog_post", + key: null, + title: overrides.title ?? "A case", + summary: null, + status: overrides.status ?? "in_progress", + fields: {}, + parentCaseId: null, + createdByAgentId: null, + createdByUserId: null, + completedAt: null, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + ...overrides, + }; +} + +function renderPage(container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + act(() => { + root.render( + + + , + ); + }); + return root; +} + +describe("Cases list", () => { + let container: HTMLDivElement; + beforeEach(() => { + window.localStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + mockCasesApi.list.mockReset(); + mockProjectsApi.list.mockReset().mockResolvedValue([]); + mockIssuesApi.listLabels.mockReset().mockResolvedValue([]); + mockCopyTextToClipboard.mockClear(); + mockNavigate.mockClear(); + generalSettingsState.keyboardShortcutsEnabled = false; + HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + afterEach(() => { + container.remove(); + }); + + it("loads cases by default and hides terminal cases client-side", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", title: "Active post", status: "in_progress" }), + createCase({ id: "b", identifier: "PAP-C2", title: "Done post", status: "done" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Active post"); + expect(container.textContent).not.toContain("Done post"); + expect(container.textContent).not.toContain("active ·"); + expect(mockCasesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + limit: 200, + })); + }); + + act(() => root.unmount()); + }); + + it("sends search filters to the cases API instead of filtering a fetched page locally", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", title: "Active post", status: "in_progress" }), + ]); + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Active post"); + }); + + const input = container.querySelector("input[placeholder='Search cases...']"); + expect(input).toBeTruthy(); + act(() => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, "launch"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockCasesApi.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ + q: "launch", + limit: 200, + })); + }); + + act(() => root.unmount()); + }); + + it("renders the onboarding hero when there are no cases at all", async () => { + mockCasesApi.list.mockResolvedValue([]); + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("No cases yet"); + expect(container.textContent).toContain("references/cases.md"); + }); + + // No create-case UI anywhere (agent-only v1). + expect(container.textContent).not.toContain("New case"); + expect(container.textContent).not.toContain("Create case"); + + act(() => root.unmount()); + }); + + it("shows default columns in id, title, status, updated order grouped by type without keys", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", key: "launch/post-one", title: "Post one", caseType: "blog_post" }), + createCase({ id: "b", identifier: "PAP-C2", title: "Storm one", caseType: "tweet_storm" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("blog_post"); + expect(container.textContent).toContain("tweet_storm"); + expect(container.textContent).toContain("Post one"); + expect(container.textContent).not.toContain("launch/post-one"); + expect(container.textContent).toContain("Storm one"); + }); + + const text = container.textContent ?? ""; + expect(text.indexOf("ID")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("Title")).toBeGreaterThan(text.indexOf("ID")); + expect(text.indexOf("Status")).toBeGreaterThan(text.indexOf("Title")); + expect(text.indexOf("Updated")).toBeGreaterThan(text.indexOf("Status")); + expect(text).not.toContain("Key"); + expect(text).not.toContain("Project"); + const headerGrid = Array.from(container.querySelectorAll("div > span[style*='grid-template-columns']")).find((element) => + element.textContent?.includes("ID") + && element.textContent.includes("Title") + && element.textContent.includes("Status") + ); + expect(headerGrid?.style.gridTemplateColumns).toBe( + "max-content minmax(12rem, 1fr) minmax(6rem, 7rem) minmax(5rem, 6rem)", + ); + + const blogGroupIndex = text.indexOf("blog_post"); + const tweetGroupIndex = text.indexOf("tweet_storm"); + expect(blogGroupIndex).toBeGreaterThanOrEqual(0); + expect(tweetGroupIndex).toBeGreaterThan(blogGroupIndex); + + act(() => root.unmount()); + }); + + it("copies case list identifiers with feedback without following the row link", async () => { + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", key: "launch/post-one", title: "Post one", caseType: "blog_post" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("PAP-C1"); + expect(container.textContent).not.toContain("launch/post-one"); + }); + + const idButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "PAP-C1" + ); + expect(idButton).toBeTruthy(); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + act(() => { + idButton!.dispatchEvent(click); + }); + + await waitForAssertion(() => { + expect(click.defaultPrevented).toBe(true); + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("PAP-C1"); + expect(idButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("shows and copies keys only when the key column is enabled", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + columns: ["id", "key", "title", "status", "updated"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", key: "launch/post-one", title: "Post one", caseType: "blog_post" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Key"); + expect(container.textContent).toContain("launch/post-one"); + }); + + const keyButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent === "launch/post-one" + ); + expect(keyButton).toBeTruthy(); + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + act(() => { + keyButton!.dispatchEvent(click); + }); + + await waitForAssertion(() => { + expect(click.defaultPrevented).toBe(true); + expect(mockCopyTextToClipboard).toHaveBeenCalledWith("launch/post-one"); + expect(keyButton!.parentElement?.textContent).toContain("Copied"); + }); + + act(() => root.unmount()); + }); + + it("tree mode forces an ungrouped parent-child order and adds the type column", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + treeView: true, + groupBy: "type", + columns: ["id", "title", "status", "updated"], + sortField: "updated", + sortDir: "desc", + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "child", + identifier: "PAP-C2", + title: "Child case", + parentCaseId: "parent", + caseType: "asset", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "parent", + identifier: "PAP-C1", + title: "Parent case", + caseType: "brief", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + createCase({ + id: "sibling", + identifier: "PAP-C3", + title: "Sibling case", + caseType: "brief", + updatedAt: "2026-07-06T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Type"); + expect(container.textContent).toContain("brief"); + expect(container.textContent).toContain("asset"); + expect(container.querySelector('button[title="Show flat case list"]')).not.toBeNull(); + expect(mockCasesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + includeAncestors: true, + limit: 200, + })); + }); + + const text = container.textContent ?? ""; + expect(text.indexOf("Type")).toBeGreaterThan(text.indexOf("Title")); + expect(text.indexOf("Status")).toBeGreaterThan(text.indexOf("Type")); + expect(text.indexOf("Parent case")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("Child case")).toBeGreaterThan(text.indexOf("Parent case")); + expect(text.indexOf("Sibling case")).toBeGreaterThan(text.indexOf("Child case")); + expect(text).not.toContain("1 child"); + + const collapseParent = container.querySelector('button[aria-label="Collapse Parent case"]'); + expect(collapseParent).toBeTruthy(); + expect(collapseParent?.getAttribute("aria-expanded")).toBe("true"); + act(() => { + collapseParent!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Parent case"); + expect(container.textContent).not.toContain("Child case"); + expect(container.querySelector('button[aria-label="Expand Parent case"]')?.getAttribute("aria-expanded")).toBe("false"); + }); + + act(() => root.unmount()); + }); + + it("keeps filtered-out ancestors visible in tree mode when descendants match", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + treeView: true, + columns: ["id", "title", "type", "status", "updated"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "child", + identifier: "PAP-C2", + title: "Active child", + parentCaseId: "parent", + status: "in_progress", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "done-sibling", + identifier: "PAP-C3", + title: "Done sibling", + status: "done", + updatedAt: "2026-07-09T00:00:00.000Z", + }), + createCase({ + id: "parent", + identifier: "PAP-C1", + title: "Done parent", + status: "done", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Done parent"); + expect(container.textContent).toContain("Active child"); + expect(container.textContent).not.toContain("Done sibling"); + }); + + const text = container.textContent ?? ""; + expect(text.indexOf("Done parent")).toBeGreaterThanOrEqual(0); + expect(text.indexOf("Active child")).toBeGreaterThan(text.indexOf("Done parent")); + + act(() => root.unmount()); + }); + + it("supports inbox-style keyboard navigation, group folding, and opening on grouped case rows", async () => { + generalSettingsState.keyboardShortcutsEnabled = true; + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "blog", + identifier: "PAP-C1", + title: "Blog active", + caseType: "blog_post", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "docs", + identifier: "PAP-C2", + title: "Docs active", + caseType: "docs_page", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Blog active"); + expect(container.textContent).toContain("Docs active"); + }); + + dispatchShortcut("j"); + await flush(); + dispatchShortcut("ArrowLeft"); + + await waitForAssertion(() => { + expect(container.textContent).toContain("blog_post"); + expect(container.textContent).not.toContain("Blog active"); + }); + + dispatchShortcut("ArrowRight"); + await waitForAssertion(() => { + expect(container.textContent).toContain("Blog active"); + }); + + dispatchShortcut("j"); + await flush(); + dispatchShortcut("Enter"); + + expect(mockNavigate).toHaveBeenCalledWith("/PAP/cases/PAP-C1"); + + act(() => root.unmount()); + }); + + it("supports keyboard tree folding and opening parent case rows", async () => { + generalSettingsState.keyboardShortcutsEnabled = true; + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + treeView: true, + columns: ["id", "title", "type", "status", "updated"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "child", + identifier: "PAP-C2", + title: "Child case", + parentCaseId: "parent", + caseType: "asset", + updatedAt: "2026-07-08T00:00:00.000Z", + }), + createCase({ + id: "parent", + identifier: "PAP-C1", + title: "Parent case", + caseType: "brief", + updatedAt: "2026-07-07T00:00:00.000Z", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Parent case"); + expect(container.textContent).toContain("Child case"); + }); + + dispatchShortcut("j"); + await flush(); + dispatchShortcut("ArrowLeft"); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Parent case"); + expect(container.textContent).not.toContain("Child case"); + }); + + dispatchShortcut("ArrowRight"); + await waitForAssertion(() => { + expect(container.textContent).toContain("Child case"); + }); + + dispatchShortcut("Enter"); + + expect(mockNavigate).toHaveBeenCalledWith("/PAP/cases/PAP-C1"); + + act(() => root.unmount()); + }); + + it("restores persisted search, filters, group, sort, and columns", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + search: "launch", + statusFilters: ["done"], + typeFilters: ["blog_post"], + projectFilters: [], + labelFilter: "__all__", + groupBy: "status", + sortField: "created", + sortDir: "asc", + columns: ["id", "title", "status", "updated", "created"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ + id: "a", + identifier: "PAP-C1", + title: "Active launch", + status: "in_progress", + caseType: "blog_post", + }), + createCase({ + id: "b", + identifier: "PAP-C2", + title: "Done launch", + status: "done", + caseType: "blog_post", + }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(mockCasesApi.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ + q: "launch", + limit: 200, + })); + expect(container.textContent).toContain("Done launch"); + expect(container.textContent).not.toContain("Active launch"); + expect(container.textContent).toContain("Created at"); + }); + + act(() => root.unmount()); + }); + + it("applies multi-select type and status filters from persisted state", async () => { + window.localStorage.setItem( + "paperclip:cases:company-1:view", + JSON.stringify({ + statusFilters: ["in_progress", "done"], + typeFilters: ["blog_post", "docs_page"], + projectFilters: ["project-1", "__all__"], + }), + ); + mockCasesApi.list.mockResolvedValue([ + createCase({ id: "a", identifier: "PAP-C1", title: "Blog active", status: "in_progress", caseType: "blog_post" }), + createCase({ id: "b", identifier: "PAP-C2", title: "Docs done", status: "done", caseType: "docs_page" }), + createCase({ id: "c", identifier: "PAP-C3", title: "Tweet active", status: "in_progress", caseType: "tweet_storm" }), + createCase({ id: "d", identifier: "PAP-C4", title: "Blog cancelled", status: "cancelled", caseType: "blog_post" }), + ]); + + const root = renderPage(container); + + await waitForAssertion(() => { + expect(mockCasesApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ + types: ["blog_post", "docs_page"], + statuses: ["in_progress", "done"], + projectIds: ["project-1"], + includeNoProject: true, + })); + expect(container.textContent).toContain("Blog active"); + expect(container.textContent).toContain("Docs done"); + expect(container.textContent).not.toContain("Tweet active"); + expect(container.textContent).not.toContain("Blog cancelled"); + }); + + act(() => root.unmount()); + }); +}); diff --git a/ui/src/pages/Cases.tsx b/ui/src/pages/Cases.tsx new file mode 100644 index 0000000000..43fb327007 --- /dev/null +++ b/ui/src/pages/Cases.tsx @@ -0,0 +1,1427 @@ +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowUpDown, Check, ChevronDown, Columns3, Filter, Layers, ListTree, Search, SearchX } from "lucide-react"; +import { Link, useCaseHref, useNavigate } from "@/lib/router"; +import { useCompany } from "@/context/CompanyContext"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useGeneralSettings } from "@/context/GeneralSettingsContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { casesApi, CASE_STATUSES, TERMINAL_CASE_STATUSES, type CaseStatus, type CaseSummary } from "@/api/cases"; +import { projectsApi } from "@/api/projects"; +import { issuesApi } from "@/api/issues"; +import { Badge } from "@/components/ui/badge"; +import { StatusBadge } from "@/components/StatusBadge"; +import { EmptyState } from "@/components/EmptyState"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { FilterBar, type FilterValue } from "@/components/FilterBar"; +import { IssueGroupHeader } from "@/components/IssueGroupHeader"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { CaseCopyableToken } from "@/components/CaseIdentifierKey"; +import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "@/lib/keyboardShortcuts"; +import { cn, relativeTime } from "@/lib/utils"; + +type GroupBy = "type" | "project" | "status" | "none"; +type CaseColumn = "id" | "key" | "title" | "status" | "updated" | "created" | "type" | "project" | "parent"; +type CaseSortField = "updated" | "created" | "title" | "status" | "id" | "type" | "project"; +type CaseViewState = { + search: string; + typeFilters: string[]; + statusFilters: CaseStatus[]; + projectFilters: string[]; + labelFilter: string; + groupBy: GroupBy; + sortField: CaseSortField; + sortDir: "asc" | "desc"; + columns: CaseColumn[]; + treeView: boolean; +}; + +const STATUS_FILTER_OPTIONS: { value: CaseStatus; label: string }[] = [ + { value: "draft", label: "Draft" }, + { value: "in_progress", label: "In progress" }, + { value: "in_review", label: "In review" }, + { value: "approved", label: "Approved" }, + { value: "done", label: "Done" }, + { value: "cancelled", label: "Cancelled" }, +]; + +const ALL = "__all__"; +const DEFAULT_STATUS_FILTERS: CaseStatus[] = CASE_STATUSES.filter((status) => !TERMINAL_CASE_STATUSES.includes(status)); +const DEFAULT_CASE_COLUMNS: CaseColumn[] = ["id", "title", "status", "updated"]; +const CASE_COLUMN_ORDER: CaseColumn[] = ["id", "key", "title", "type", "status", "updated", "created", "project", "parent"]; +const CASE_COLUMN_LABELS: Record = { + id: "ID", + key: "Key", + title: "Title", + status: "Status", + updated: "Updated", + created: "Created at", + type: "Type", + project: "Project", + parent: "Parent case", +}; +const CASE_SORT_LABELS: Record = { + updated: "Last updated", + created: "Created at", + title: "Title", + status: "Status", + id: "ID", + type: "Type", + project: "Project", +}; +const defaultCaseViewState: CaseViewState = { + search: "", + typeFilters: [], + statusFilters: DEFAULT_STATUS_FILTERS, + projectFilters: [], + labelFilter: ALL, + groupBy: "type", + sortField: "updated", + sortDir: "desc", + columns: DEFAULT_CASE_COLUMNS, + treeView: false, +}; + +function getCaseViewStorageKey(companyId: string | null | undefined): string | null { + return companyId ? `paperclip:cases:${companyId}:view` : null; +} + +function normalizeCaseColumns(value: unknown): CaseColumn[] { + if (!Array.isArray(value)) return DEFAULT_CASE_COLUMNS; + const valid = CASE_COLUMN_ORDER.filter((column) => value.includes(column)); + return valid.length > 0 ? valid : DEFAULT_CASE_COLUMNS; +} + +function normalizeCaseStatuses(value: unknown): CaseStatus[] { + if (!Array.isArray(value)) return DEFAULT_STATUS_FILTERS; + const valid = CASE_STATUSES.filter((status) => value.includes(status)); + return valid.length > 0 ? valid : DEFAULT_STATUS_FILTERS; +} + +function normalizeStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function normalizeGroupBy(value: unknown): GroupBy { + return value === "project" || value === "status" || value === "type" || value === "none" ? value : defaultCaseViewState.groupBy; +} + +function normalizeSortField(value: unknown): CaseSortField { + return value === "created" + || value === "title" + || value === "status" + || value === "id" + || value === "type" + || value === "project" + || value === "updated" + ? value + : defaultCaseViewState.sortField; +} + +function loadCaseViewState(storageKey: string | null): CaseViewState { + if (typeof window === "undefined" || !storageKey) return { ...defaultCaseViewState }; + try { + const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "null"); + if (!parsed || typeof parsed !== "object") return { ...defaultCaseViewState }; + const record = parsed as Record; + const treeView = record.treeView === true; + const columns = treeView + ? normalizeCaseColumns([...normalizeCaseColumns(record.columns), "type"]) + : normalizeCaseColumns(record.columns); + return { + search: typeof record.search === "string" ? record.search : defaultCaseViewState.search, + typeFilters: normalizeStringArray(record.typeFilters), + statusFilters: normalizeCaseStatuses(record.statusFilters), + projectFilters: normalizeStringArray(record.projectFilters), + labelFilter: typeof record.labelFilter === "string" ? record.labelFilter : defaultCaseViewState.labelFilter, + groupBy: treeView ? "none" : normalizeGroupBy(record.groupBy), + sortField: normalizeSortField(record.sortField), + sortDir: record.sortDir === "asc" ? "asc" : "desc", + columns, + treeView, + }; + } catch { + return { ...defaultCaseViewState }; + } +} + +function saveCaseViewState(storageKey: string | null, state: CaseViewState) { + if (typeof window === "undefined" || !storageKey) return; + try { + window.localStorage.setItem(storageKey, JSON.stringify(state)); + } catch { + // Ignore localStorage failures; the controls still work for the session. + } +} + +function caseTrailingGridTemplate(columns: CaseColumn[]): string { + return columns + .map((column) => { + if (column === "title") return "minmax(12rem, 1fr)"; + if (column === "id") return "max-content"; + if (column === "key") return "minmax(8rem, 12rem)"; + if (column === "status") return "minmax(6rem, 7rem)"; + if (column === "type") return "minmax(5rem, 8rem)"; + if (column === "project") return "minmax(5rem, 8rem)"; + if (column === "parent") return "minmax(4rem, 6rem)"; + return "minmax(5rem, 6rem)"; + }) + .join(" "); +} + +function sameStringSet(a: readonly string[], b: readonly string[]) { + return a.length === b.length && a.every((value) => b.includes(value)); +} + +function sameStatusSet(a: readonly CaseStatus[], b: readonly CaseStatus[]) { + return a.length === b.length && a.every((value) => b.includes(value)); +} + +function ensureCaseColumn(columns: readonly CaseColumn[], column: CaseColumn): CaseColumn[] { + return columns.includes(column) ? [...columns] : normalizeCaseColumns([...columns, column]); +} + +function caseMatchesViewFilters(caseRow: CaseSummary, viewState: CaseViewState) { + if (caseRow.matchesListFilters === false) return false; + if (viewState.typeFilters.length > 0 && !viewState.typeFilters.includes(caseRow.caseType)) return false; + if (!viewState.statusFilters.includes(caseRow.status)) return false; + if (viewState.projectFilters.length > 0) { + const projectKey = caseRow.projectId ?? ALL; + if (!viewState.projectFilters.includes(projectKey)) return false; + } + return true; +} + +function treeTitleIndentClass(depth: number): string { + if (depth <= 0) return ""; + if (depth === 1) return "pl-4"; + if (depth === 2) return "pl-8"; + if (depth === 3) return "pl-12"; + return "pl-16"; +} + +function CaseStatusPicker({ + status, + onChange, + disabled, +}: { + status: CaseStatus; + onChange: (next: CaseStatus) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + return ( + + + + + { + event.preventDefault(); + event.stopPropagation(); + }} + > + {STATUS_FILTER_OPTIONS.map((option) => ( + + ))} + + + ); +} + +function CaseTrailingColumns({ + row, + columns, + projectName, + onStatusChange, + statusPending, + treeDepth = 0, + childCount = 0, + treeView = false, + treeCollapsed = false, + onTreeToggle, +}: { + row: CaseSummary; + columns: CaseColumn[]; + projectName: string | null; + onStatusChange: (caseId: string, status: CaseStatus) => void; + statusPending: boolean; + treeDepth?: number; + childCount?: number; + treeView?: boolean; + treeCollapsed?: boolean; + onTreeToggle?: (caseId: string) => void; +}) { + return ( + + {columns.map((column) => { + if (column === "id") { + return ( + + ); + } + if (column === "key") { + return row.key ? ( + + ) : ( + None + ); + } + if (column === "title") { + return ( + + {treeView ? ( + + {childCount > 0 ? ( + + ) : treeDepth > 0 ? ( + + ) : null} + + ) : null} + {row.title} + + ); + } + if (column === "status") { + return ( + + onStatusChange(row.id, status)} + /> + + ); + } + if (column === "type") { + return {row.caseType}; + } + if (column === "project") { + return {projectName ?? "No project"}; + } + if (column === "parent") { + return ( + + {row.parentCaseId ? "Parent" : "None"} + + ); + } + if (column === "created") { + return ( + + {relativeTime(row.createdAt)} + + ); + } + return ( + + {relativeTime(row.updatedAt)} + + ); + })} + + ); +} + +function CaseListRow({ + row, + projectName, + visibleColumnSet, + trailingColumns, + onStatusChange, + statusPending, + treeDepth, + childCount, + treeView, + treeCollapsed, + onTreeToggle, + selected = false, + onSelect, +}: { + row: CaseSummary; + projectName: string | null; + visibleColumnSet: ReadonlySet; + trailingColumns: CaseColumn[]; + onStatusChange: (caseId: string, status: CaseStatus) => void; + statusPending: boolean; + treeDepth?: number; + childCount?: number; + treeView?: boolean; + treeCollapsed?: boolean; + onTreeToggle?: (caseId: string) => void; + selected?: boolean; + onSelect?: () => void; +}) { + const caseHref = useCaseHref(); + return ( + + + + {visibleColumnSet.has("title") ? row.title : row.identifier} + + + {visibleColumnSet.has("status") ? ( + onStatusChange(row.id, status)} + /> + ) : null} + {visibleColumnSet.has("id") ? ( + + ) : null} + {visibleColumnSet.has("key") && row.key ? ( + + ) : null} + {relativeTime(row.updatedAt)} + + + {trailingColumns.length > 0 ? ( + + + + ) : null} + + ); +} + +function CaseColumnHeader({ + visibleColumnSet, + trailingColumns, +}: { + visibleColumnSet: ReadonlySet; + trailingColumns: CaseColumn[]; +}) { + return ( +
+ {trailingColumns.length > 0 ? ( + + {trailingColumns.map((column) => ( + + {CASE_COLUMN_LABELS[column]} + + ))} + + ) : null} +
+ ); +} + +function CaseGroup({ + label, + count, + collapsed, + selected, + onToggle, + onSelect, + children, +}: { + label: string; + count: number; + collapsed: boolean; + selected: boolean; + onToggle: () => void; + onSelect: () => void; + children: React.ReactNode; +}) { + return ( +
+
+ + {count} {count === 1 ? "case" : "cases"} + + )} + /> +
+ {!collapsed &&
{children}
} +
+ ); +} + +type CaseGroupedRows = { + key: string; + label: string | null; + rows: CaseSummary[]; +}; + +type CaseTreeRow = { + row: CaseSummary; + depth: number; + childCount: number; + collapsed: boolean; +}; +type CaseKeyboardNavEntry = + | { type: "group"; groupKey: string; collapsed: boolean } + | { type: "case"; row: CaseSummary; childCount: number; collapsed: boolean }; + +function getCaseKeyboardSelectionIndex( + previousIndex: number, + itemCount: number, + direction: "next" | "previous", +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return 0; + return direction === "next" + ? Math.min(previousIndex + 1, itemCount - 1) + : Math.max(previousIndex - 1, 0); +} + +function CaseToolbarButton({ + icon: Icon, + title, + active, + children, +}: { + icon: typeof Filter; + title: string; + active?: boolean; + children: ReactNode; +}) { + return ( + + + + + {children} + + ); +} + +function FilterField({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+ {children} +
+ ); +} + +function FilterCheckboxRow({ + label, + checked, + onCheckedChange, +}: { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + +function CaseColumnPicker({ + visibleColumns, + onToggle, + onReset, +}: { + visibleColumns: ReadonlySet; + onToggle: (column: CaseColumn, enabled: boolean) => void; + onReset: () => void; +}) { + return ( + + +
+
+ Desktop case rows +
+
Choose visible columns
+
+
+ {CASE_COLUMN_ORDER.map((column) => ( + + ))} +
+
+ +
+
+
+ ); +} + +function CaseSortPicker({ + sortField, + sortDir, + onChange, +}: { + sortField: CaseSortField; + sortDir: "asc" | "desc"; + onChange: (patch: Pick) => void; +}) { + return ( + + + {(Object.keys(CASE_SORT_LABELS) as CaseSortField[]).map((field) => ( + + ))} + + + ); +} + +/** Full-page onboarding hero shown when the company has zero cases (§6). */ +function CasesEmptyHero() { + return ( +
+ +

No cases yet

+

+ Cases are durable work products — blog posts, tweet storms, docs pages — that tasks create and + iterate on. In v1 they're created by agents, not from the UI. +

+
+

To start creating cases, add this to a skill:

+
+{`"Create a case of type blog_post with fields
+{slug, target_audience, publish_url} and key /."`}
+        
+

+ See the paperclip skill → references/cases.md for the API. +

+
+

+ Feature is gated by the enableCases experimental flag + (Settings → Experimental). +

+
+ ); +} + +export function Cases() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { keyboardShortcutsEnabled } = useGeneralSettings(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const caseHref = useCaseHref(); + const caseListRef = useRef(null); + + const viewStorageKey = getCaseViewStorageKey(selectedCompanyId); + const [viewState, setViewState] = useState(() => loadCaseViewState(viewStorageKey)); + const [collapsedTreeCaseIds, setCollapsedTreeCaseIds] = useState>(() => new Set()); + const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => new Set()); + const [selectedIndex, setSelectedIndex] = useState(-1); + + useEffect(() => { + setBreadcrumbs([{ label: "Cases" }]); + }, [setBreadcrumbs]); + + useEffect(() => { + setViewState(loadCaseViewState(viewStorageKey)); + setCollapsedGroupKeys(new Set()); + setCollapsedTreeCaseIds(new Set()); + setSelectedIndex(-1); + }, [viewStorageKey]); + + function updateView(patch: Partial) { + setViewState((current) => { + const next = { ...current, ...patch }; + if (current.treeView && patch.groupBy && patch.groupBy !== "none") { + next.treeView = false; + } + if (patch.treeView === true) { + next.groupBy = "none"; + next.columns = ensureCaseColumn(next.columns, "type"); + } else if (next.treeView) { + next.groupBy = "none"; + } + saveCaseViewState(viewStorageKey, next); + return next; + }); + } + + const usesDefaultStatusFilter = sameStatusSet(viewState.statusFilters, DEFAULT_STATUS_FILTERS); + + const listFilters = useMemo(() => { + const projectIds = viewState.projectFilters.filter((projectId) => projectId !== ALL); + const statusFilters = usesDefaultStatusFilter || viewState.statusFilters.length === CASE_STATUSES.length + ? undefined + : viewState.statusFilters; + return { + types: viewState.typeFilters.length > 0 ? viewState.typeFilters : undefined, + status: usesDefaultStatusFilter ? "active" : undefined, + statuses: statusFilters, + projectIds: projectIds.length > 0 ? projectIds : undefined, + includeNoProject: viewState.projectFilters.includes(ALL) || undefined, + labelId: viewState.labelFilter === ALL ? undefined : viewState.labelFilter, + q: viewState.search.trim() || undefined, + includeAncestors: viewState.treeView ? true : undefined, + limit: 200, + }; + }, [usesDefaultStatusFilter, viewState.labelFilter, viewState.projectFilters, viewState.search, viewState.statusFilters, viewState.treeView, viewState.typeFilters]); + const casesQuery = useQuery({ + queryKey: [...queryKeys.cases.list(selectedCompanyId ?? ""), listFilters], + queryFn: () => casesApi.list(selectedCompanyId!, listFilters), + enabled: !!selectedCompanyId, + }); + const projectsQuery = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId ?? ""), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const labelsQuery = useQuery({ + queryKey: queryKeys.issues.labels(selectedCompanyId ?? ""), + queryFn: () => issuesApi.listLabels(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + const patchCase = useMutation({ + mutationFn: ({ caseId, status }: { caseId: string; status: CaseStatus }) => + casesApi.patch(caseId, { status }), + onSuccess: (updated) => { + queryClient.setQueryData( + [...queryKeys.cases.list(selectedCompanyId ?? ""), listFilters], + (current) => current?.map((row) => row.id === updated.id ? { ...row, ...updated } : row), + ); + queryClient.invalidateQueries({ queryKey: queryKeys.cases.list(selectedCompanyId ?? "") }); + }, + }); + + const allCases = useMemo(() => casesQuery.data ?? [], [casesQuery.data]); + const projectName = useMemo(() => { + const map = new Map(); + for (const p of projectsQuery.data ?? []) map.set(p.id, p.name); + return map; + }, [projectsQuery.data]); + + const distinctTypes = useMemo( + () => [...new Set([ + ...allCases.map((c) => c.caseType), + ...viewState.typeFilters, + ])].sort(), + [allCases, viewState.typeFilters], + ); + + const filtered = useMemo(() => { + return allCases.filter((caseRow) => caseMatchesViewFilters(caseRow, viewState)); + }, [allCases, viewState]); + + const caseSortCompare = useMemo(() => { + return (a: CaseSummary, b: CaseSummary) => { + let result = 0; + if (viewState.sortField === "updated") result = Date.parse(a.updatedAt) - Date.parse(b.updatedAt); + else if (viewState.sortField === "created") result = Date.parse(a.createdAt) - Date.parse(b.createdAt); + else if (viewState.sortField === "title") result = a.title.localeCompare(b.title); + else if (viewState.sortField === "status") result = a.status.localeCompare(b.status); + else if (viewState.sortField === "id") result = a.identifier.localeCompare(b.identifier); + else if (viewState.sortField === "type") result = a.caseType.localeCompare(b.caseType); + else { + const aProject = a.projectId ? projectName.get(a.projectId) ?? "" : ""; + const bProject = b.projectId ? projectName.get(b.projectId) ?? "" : ""; + result = aProject.localeCompare(bProject); + } + return viewState.sortDir === "desc" ? -result : result; + }; + }, [projectName, viewState.sortDir, viewState.sortField]); + + const sorted = useMemo(() => { + const rows = [...filtered]; + rows.sort(caseSortCompare); + return rows; + }, [caseSortCompare, filtered]); + + const sortedTreeSource = useMemo(() => { + const rows = [...allCases]; + rows.sort(caseSortCompare); + return rows; + }, [allCases, caseSortCompare]); + + const visibleColumnSet = useMemo(() => new Set(viewState.columns), [viewState.columns]); + const trailingColumns = useMemo( + () => CASE_COLUMN_ORDER.filter((column) => visibleColumnSet.has(column)), + [visibleColumnSet], + ); + + const groupedRows = useMemo((): CaseGroupedRows[] => { + if (viewState.groupBy === "none") { + return [{ key: "__all__", label: null, rows: sorted }]; + } + const map = new Map(); + for (const c of sorted) { + let key: string; + if (viewState.groupBy === "type") key = c.caseType; + else if (viewState.groupBy === "status") key = c.status; + else key = c.projectId ? projectName.get(c.projectId) ?? "Unknown project" : "No project"; + const bucket = map.get(key); + if (bucket) bucket.push(c); + else map.set(key, [c]); + } + return [...map.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([label, rows]) => ({ key: label, label, rows })); + }, [sorted, viewState.groupBy, projectName]); + + const treeRows = useMemo((): CaseTreeRow[] => { + const rowById = new Map(sortedTreeSource.map((row) => [row.id, row])); + const visibleIds = new Set(filtered.map((row) => row.id)); + for (const row of filtered) { + const seen = new Set([row.id]); + let parentCaseId = row.parentCaseId; + while (parentCaseId && !seen.has(parentCaseId)) { + const parent = rowById.get(parentCaseId); + if (!parent) break; + visibleIds.add(parent.id); + seen.add(parent.id); + parentCaseId = parent.parentCaseId; + } + } + + const childrenByParent = new Map(); + for (const row of sortedTreeSource) { + if ( + !visibleIds.has(row.id) + || !row.parentCaseId + || !visibleIds.has(row.parentCaseId) + || !rowById.has(row.parentCaseId) + ) { + continue; + } + const children = childrenByParent.get(row.parentCaseId) ?? []; + children.push(row); + childrenByParent.set(row.parentCaseId, children); + } + + const roots = sortedTreeSource.filter((row) => + visibleIds.has(row.id) + && (!row.parentCaseId || !visibleIds.has(row.parentCaseId) || !rowById.has(row.parentCaseId)) + ); + const rows: CaseTreeRow[] = []; + const visited = new Set(); + const hidden = new Set(); + + function markHidden(row: CaseSummary, ancestors: Set) { + if (hidden.has(row.id) || ancestors.has(row.id)) return; + hidden.add(row.id); + const nextAncestors = new Set(ancestors); + nextAncestors.add(row.id); + for (const child of childrenByParent.get(row.id) ?? []) { + markHidden(child, nextAncestors); + } + } + + function walk(row: CaseSummary, depth: number, ancestors: Set) { + if (visited.has(row.id) || hidden.has(row.id)) return; + visited.add(row.id); + const children = childrenByParent.get(row.id) ?? []; + const collapsed = collapsedTreeCaseIds.has(row.id); + rows.push({ row, depth, childCount: children.length, collapsed }); + if (ancestors.has(row.id)) return; + const nextAncestors = new Set(ancestors); + nextAncestors.add(row.id); + if (collapsed) { + for (const child of children) { + markHidden(child, nextAncestors); + } + return; + } + for (const child of children) { + walk(child, depth + 1, nextAncestors); + } + } + + for (const root of roots) { + walk(root, 0, new Set()); + } + for (const row of sortedTreeSource) { + if (!visibleIds.has(row.id)) continue; + walk(row, 0, new Set()); + } + + return rows; + }, [collapsedTreeCaseIds, filtered, sortedTreeSource]); + + const keyboardNavItems = useMemo((): CaseKeyboardNavEntry[] => { + if (viewState.treeView) { + return treeRows.map(({ row, childCount, collapsed }) => ({ + type: "case", + row, + childCount, + collapsed, + })); + } + + const entries: CaseKeyboardNavEntry[] = []; + for (const group of groupedRows) { + const collapsed = collapsedGroupKeys.has(group.key); + if (group.label) { + entries.push({ type: "group", groupKey: group.key, collapsed }); + } + if (collapsed) continue; + for (const row of group.rows) { + entries.push({ type: "case", row, childCount: 0, collapsed: false }); + } + } + return entries; + }, [collapsedGroupKeys, groupedRows, treeRows, viewState.treeView]); + + useEffect(() => { + setSelectedIndex((current) => { + if (keyboardNavItems.length === 0) return -1; + if (current < 0) return -1; + return Math.min(current, keyboardNavItems.length - 1); + }); + }, [keyboardNavItems.length]); + + useEffect(() => { + if (selectedIndex < 0 || !caseListRef.current) return; + const rows = caseListRef.current.querySelectorAll("[data-case-item]"); + rows[selectedIndex]?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const activeFilters: FilterValue[] = []; + if (viewState.search.trim()) activeFilters.push({ key: "search", label: "Search", value: viewState.search.trim() }); + if (viewState.typeFilters.length > 0) { + activeFilters.push({ key: "type", label: "Type", value: viewState.typeFilters.join(", ") }); + } + if (!usesDefaultStatusFilter) { + activeFilters.push({ + key: "status", + label: "Status", + value: viewState.statusFilters.length === CASE_STATUSES.length + ? "All" + : viewState.statusFilters + .map((status) => STATUS_FILTER_OPTIONS.find((option) => option.value === status)?.label ?? status) + .join(", "), + }); + } + if (viewState.projectFilters.length > 0) { + activeFilters.push({ + key: "project", + label: "Project", + value: viewState.projectFilters + .map((projectId) => projectId === ALL ? "No project" : projectName.get(projectId) ?? "Project") + .join(", "), + }); + } + if (viewState.labelFilter !== ALL) { + const name = (labelsQuery.data ?? []).find((l) => l.id === viewState.labelFilter)?.name ?? "Label"; + activeFilters.push({ key: "label", label: "Label", value: name }); + } + + function removeFilter(key: string) { + if (key === "search") updateView({ search: "" }); + else if (key === "type") updateView({ typeFilters: [] }); + else if (key === "status") updateView({ statusFilters: DEFAULT_STATUS_FILTERS }); + else if (key === "project") updateView({ projectFilters: [] }); + else if (key === "label") updateView({ labelFilter: ALL }); + } + function clearFilters() { + updateView({ + search: "", + typeFilters: [], + statusFilters: DEFAULT_STATUS_FILTERS, + projectFilters: [], + labelFilter: ALL, + }); + } + function toggleColumn(column: CaseColumn, enabled: boolean) { + const next = enabled + ? [...viewState.columns, column] + : viewState.columns.filter((value) => value !== column); + updateView({ columns: normalizeCaseColumns(next) }); + } + function resetColumns() { + updateView({ columns: viewState.treeView ? ensureCaseColumn(DEFAULT_CASE_COLUMNS, "type") : DEFAULT_CASE_COLUMNS }); + } + function toggleTreeRow(caseId: string) { + setCollapsedTreeCaseIds((current) => { + const next = new Set(current); + if (next.has(caseId)) next.delete(caseId); + else next.add(caseId); + return next; + }); + } + function setTreeRowCollapsed(caseId: string, collapsed: boolean) { + setCollapsedTreeCaseIds((current) => { + const next = new Set(current); + if (collapsed) next.add(caseId); + else next.delete(caseId); + return next; + }); + } + function toggleGroup(groupKey: string) { + setCollapsedGroupKeys((current) => { + const next = new Set(current); + if (next.has(groupKey)) next.delete(groupKey); + else next.add(groupKey); + return next; + }); + } + function setGroupCollapsed(groupKey: string, collapsed: boolean) { + setCollapsedGroupKeys((current) => { + const next = new Set(current); + if (collapsed) next.add(groupKey); + else next.delete(groupKey); + return next; + }); + } + function toggleStringFilter(key: "typeFilters" | "projectFilters", value: string, enabled: boolean) { + const current = viewState[key]; + updateView({ + [key]: enabled + ? [...current, value] + : current.filter((item) => item !== value), + }); + } + function toggleStatusFilter(status: CaseStatus, enabled: boolean) { + const next = enabled + ? [...viewState.statusFilters, status] + : viewState.statusFilters.filter((item) => item !== status); + updateView({ statusFilters: normalizeCaseStatuses(next) }); + } + + useEffect(() => { + if (!keyboardShortcutsEnabled) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented) return; + const target = event.target; + if ( + !(target instanceof HTMLElement) + || isKeyboardShortcutTextInputTarget(target) + || hasBlockingShortcutDialog(document) + || event.metaKey + || event.ctrlKey + || event.altKey + ) { + return; + } + + const navCount = keyboardNavItems.length; + if (navCount === 0) return; + + switch (event.key) { + case "j": + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((current) => getCaseKeyboardSelectionIndex(current, navCount, "next")); + break; + case "k": + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((current) => getCaseKeyboardSelectionIndex(current, navCount, "previous")); + break; + case "ArrowLeft": + case "ArrowRight": { + if (selectedIndex < 0 || selectedIndex >= navCount) return; + const entry = keyboardNavItems[selectedIndex]; + if (!entry) return; + if (entry.type === "group") { + event.preventDefault(); + setGroupCollapsed(entry.groupKey, event.key === "ArrowLeft"); + return; + } + if (viewState.treeView && entry.childCount > 0) { + event.preventDefault(); + setTreeRowCollapsed(entry.row.id, event.key === "ArrowLeft"); + } + break; + } + case "Enter": { + if (selectedIndex < 0 || selectedIndex >= navCount) return; + const entry = keyboardNavItems[selectedIndex]; + if (!entry || entry.type !== "case") return; + event.preventDefault(); + navigate(caseHref(entry.row.identifier)); + break; + } + default: + return; + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [caseHref, keyboardNavItems, keyboardShortcutsEnabled, navigate, selectedIndex, viewState.treeView]); + + if (casesQuery.isLoading) return ; + + const noCasesAtAll = allCases.length === 0 && activeFilters.length === 0; + const hasActiveFilters = activeFilters.length > 0; + + return ( +
+
+
+

Cases

+ Experimental +
+
+ + {noCasesAtAll ? ( + + ) : ( + <> +
+
+ + updateView({ search: e.target.value })} + placeholder="Search cases..." + className="pl-7 text-xs sm:text-sm" + aria-label="Search cases" + data-page-search-target="true" + /> +
+ +
+ + + + + + +
+ +
+ {distinctTypes.length === 0 ? ( +

No types yet

+ ) : distinctTypes.map((type) => ( + toggleStringFilter("typeFilters", type, checked)} + /> + ))} +
+
+ +
+ {STATUS_FILTER_OPTIONS.map((option) => ( + toggleStatusFilter(option.value, checked)} + /> + ))} +
+
+ +
+ toggleStringFilter("projectFilters", ALL, checked)} + /> + {(projectsQuery.data ?? []).map((project) => ( + toggleStringFilter("projectFilters", project.id, checked)} + /> + ))} +
+
+ +
+ { + if (checked) updateView({ labelFilter: ALL }); + }} + /> + {(labelsQuery.data ?? []).map((label) => ( + updateView({ labelFilter: checked ? label.id : ALL })} + /> + ))} +
+
+ +
+
+
+ + + + + + {([ + ["type", "Type"], + ["project", "Project"], + ["status", "Status"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} + + +
+
+ + + + {filtered.length === 0 ? ( + + ) : ( +
+ + {viewState.treeView ? ( + treeRows.map(({ row, depth, childCount, collapsed }) => { + const navIndex = keyboardNavItems.findIndex((item) => item.type === "case" && item.row.id === row.id); + return ( + patchCase.mutate({ caseId, status })} + treeDepth={depth} + childCount={childCount} + treeCollapsed={collapsed} + onTreeToggle={toggleTreeRow} + selected={selectedIndex === navIndex} + onSelect={() => setSelectedIndex(navIndex)} + treeView + /> + ); + }) + ) : ( + groupedRows.map((group) => group.label ? ( + item.type === "group" && item.groupKey === group.key)} + onToggle={() => toggleGroup(group.key)} + onSelect={() => setSelectedIndex(keyboardNavItems.findIndex((item) => item.type === "group" && item.groupKey === group.key))} + > + {group.rows.map((row) => { + const navIndex = keyboardNavItems.findIndex((item) => item.type === "case" && item.row.id === row.id); + return ( + patchCase.mutate({ caseId, status })} + selected={selectedIndex === navIndex} + onSelect={() => setSelectedIndex(navIndex)} + /> + ); + })} + + ) : ( +
+ {group.rows.map((row) => { + const navIndex = keyboardNavItems.findIndex((item) => item.type === "case" && item.row.id === row.id); + return ( + patchCase.mutate({ caseId, status })} + selected={selectedIndex === navIndex} + onSelect={() => setSelectedIndex(navIndex)} + /> + ); + })} +
+ )) + )} +
+ )} + + )} +
+ ); +} diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx index 4171bd60bd..4da07676bf 100644 --- a/ui/src/pages/CompanySkills.test.tsx +++ b/ui/src/pages/CompanySkills.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import type { CompanySkillDetail, CompanySkillVersion } from "@paperclipai/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { DiscoveryGrid, SkillDetailPage, getSkillVersionDiffSelection } from "./CompanySkills"; +import { skillStudioNewRoute } from "../lib/company-skill-routes"; vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => ( @@ -327,6 +328,12 @@ describe("DiscoveryGrid Studio entry points", () => { }); }); +describe("skillStudioNewRoute", () => { + it("builds a direct fork draft URL for a specific skill", () => { + expect(skillStudioNewRoute("skill 1")).toBe("/skills/studio/new?forkFrom=skill%201"); + }); +}); + describe("SkillDetailPage versions tab", () => { it("opens per-row version diffs for newest and oldest revisions", async () => { const v1 = makeVersion(1, "# Demo Skill\n\nFirst line"); @@ -358,7 +365,32 @@ describe("SkillDetailPage versions tab", () => { }); describe("SkillDetailPage settings", () => { - it("saves category edits with spaces from the settings dialog", async () => { + it("shows a direct fork action for read-only skills", async () => { + const v1 = makeVersion(1, "# Demo Skill"); + const onFork = vi.fn(); + const node = await renderSkillDetail([v1], { + activeTab: "overview", + detail: makeDetail(v1, { + editable: false, + editableReason: "Remote GitHub skills are read-only. Fork or import locally to edit them.", + sourceBadge: "github", + sourceLabel: "GitHub", + sourceType: "github", + }), + onFork, + }); + + expect(node.textContent).not.toContain("Fork or import locally"); + + const forkButton = buttonsNamed(node, "Fork")[0] as HTMLButtonElement; + expect(forkButton).toBeTruthy(); + + await click(forkButton); + + expect(onFork).toHaveBeenCalledOnce(); + }); + + it("saves normalized category edits from the settings dialog", async () => { const v1 = makeVersion(1, "# Demo Skill"); const onUpdateSettings = vi.fn(); const node = await renderSkillDetail([v1], { diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index a3e9a25180..c907b6cf43 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -8,6 +8,7 @@ import type { CatalogSkillFileDetail, CatalogSkillSource, CompanySkillCompatibility, + CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, CompanySkillFileInventoryEntry, @@ -73,8 +74,15 @@ import { type CompanySkillRouteSubject, } from "../lib/company-skill-routes"; import { + SKILL_CREATE_ACCENTS, + buildBlankSkillDraft, + buildForkSkillDraft, + defaultSkillMarkdown, normalizeSkillDraftSlug, + skillAccentColor, + skillCreateDraftToPayload, splitCategoryDraft, + type SkillCreateDraft, } from "../lib/skill-create"; import { SkillCardIcon } from "../components/SkillCardIcon"; import { Button } from "@/components/ui/button"; @@ -1151,6 +1159,241 @@ export function DiscoveryGrid({ ); } +function NewSkillWizard({ + initialDraft, + onCreate, + isPending, + error, + onCancel, +}: { + initialDraft: SkillCreateDraft; + onCreate: (payload: CompanySkillCreateRequest) => void; + isPending: boolean; + error: string | null; + onCancel: () => void; +}) { + const [step, setStep] = useState(0); + const [draft, setDraft] = useState(initialDraft); + const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0); + const categoryDraft = draft.categories.join(", "); + const steps = ["Basics", "Design", "Content", "Review"]; + + useEffect(() => { + setStep(0); + setDraft(initialDraft); + setSlugDirty(initialDraft.slug.trim().length > 0); + }, [initialDraft]); + + function patchDraft(patch: Partial) { + setDraft((current) => ({ ...current, ...patch })); + } + + const nameValid = draft.name.trim().length > 0; + const effectiveSlug = draft.slug.trim() || normalizeSkillDraftSlug(draft.name); + function submit() { + onCreate(skillCreateDraftToPayload(draft)); + } + + return ( +
+
+ {steps.map((label, index) => ( + + ))} +
+ + {draft.forkedFromName ? ( +
+ + Forking {draft.forkedFromName} +
+ ) : null} + + {step === 0 ? ( +
+ { + const nextName = event.target.value; + patchDraft({ + name: nextName, + slug: slugDirty ? draft.slug : normalizeSkillDraftSlug(nextName), + markdown: draft.markdown === defaultSkillMarkdown(draft.name, draft.tagline) + ? defaultSkillMarkdown(nextName, draft.tagline) + : draft.markdown, + }); + }} + placeholder="Skill name" + className="h-9" + /> + { + const nextSlug = normalizeSkillDraftSlug(event.target.value); + setSlugDirty(nextSlug.length > 0); + patchDraft({ slug: nextSlug }); + }} + placeholder="skill-shortname" + className="h-9 font-mono" + /> +