mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Cases: experimental first-class case object (#9198)
## 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 <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
71 files changed
+10342
-114
No files matched your search
@@ -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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 });
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ describe("instance settings service", () => {
|
||||
enableConferenceRoomChat: false,
|
||||
enableExternalObjects: false,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<CaseDocumentRow | null> {
|
||||
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<DocumentAnnotationThread | null> {
|
||||
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<DocumentAnnotationComment[]> {
|
||||
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<string, DocumentAnnotationComment[]>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user