Audit pipeline case issue unlink events

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-09 23:29:42 +00:00
1 parent f570e14d02
commit 0ee0b2891c
5 files changed
+113 -22

No files matched your search

@@ -0,0 +1,20 @@
ALTER TABLE "pipeline_case_events" DROP CONSTRAINT "pipeline_case_events_type_check";--> statement-breakpoint
ALTER TABLE "pipeline_case_events" ADD CONSTRAINT "pipeline_case_events_type_check" CHECK ("pipeline_case_events"."type" in (
'ingested',
'updated',
'claimed',
'lease_released',
'lease_expired',
'transitioned',
'transition_suggested',
'suggestion_resolved',
'review_decided',
'conversation_opened',
'issue_linked',
'issue_unlinked',
'automation_executed',
'automation_failed',
'blockers_set',
'blockers_resolved',
'children_terminal'
));
@@ -701,6 +701,13 @@
"when": 1781044473360,
"tag": "0099_moaning_ricochet",
"breakpoints": true
},
{
"idx": 100,
"version": "7",
"when": 1781059200000,
"tag": "0100_pipeline_case_issue_unlinked_event",
"breakpoints": true
}
]
}
@@ -39,6 +39,7 @@ export const pipelineCaseEvents = pgTable(
'review_decided',
'conversation_opened',
'issue_linked',
'issue_unlinked',
'automation_executed',
'automation_failed',
'blockers_set',
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
@@ -235,6 +236,54 @@ describeEmbeddedPostgres("pipeline routes", () => {
await http.delete(`/api/pipelines/${pipelineId}/stages/${stageId}?moveCasesToStageId=${qaStage.body.id}`).expect(200);
});
it("writes an audit event when an agent removes a case issue link", async () => {
const company = await seedCompany();
const [agent] = await db.insert(agents).values({
companyId: company.id,
name: "Pipeline Agent",
role: "engineer",
adapterType: "codex_local",
}).returning();
const runId = randomUUID();
const agentActor: Express.Request["actor"] = {
type: "agent",
agentId: agent!.id,
companyId: company.id,
runId,
source: "agent_key",
};
const http = request(app(agentActor));
const pipeline = await http.post(`/api/companies/${company.id}/pipelines`).send({ key: "unlink", name: "Unlink audit" }).expect(201);
const created = await http
.post(`/api/pipelines/${pipeline.body.id}/cases`)
.send({ caseKey: "unlink", title: "Unlink audit" })
.expect(201);
const [issue] = await db.insert(issues).values({
companyId: company.id,
title: "Linked work",
status: "todo",
priority: "medium",
}).returning();
const link = await http
.post(`/api/cases/${created.body.case.id}/issue-links`)
.send({ issueId: issue!.id, role: "work" })
.expect(201);
await http.delete(`/api/cases/${created.body.case.id}/issue-links/${link.body.id}`).expect(200);
const events = await http.get(`/api/cases/${created.body.case.id}/events`).expect(200);
const linkEvents = events.body.items.filter((event: { type: string }) => event.type === "issue_linked" || event.type === "issue_unlinked");
expect(linkEvents.map((event: { type: string }) => event.type)).toEqual(["issue_linked", "issue_unlinked"]);
expect(linkEvents[1]).toMatchObject({
actorType: "agent",
actorAgentId: agent!.id,
runId,
payload: { issueId: issue!.id, role: "work", linkId: link.body.id },
});
const remainingLinks = await db.select().from(pipelineCaseIssueLinks).where(eq(pipelineCaseIssueLinks.id, link.body.id));
expect(remainingLinks).toHaveLength(0);
});
it("paginates and caps case event responses", async () => {
const company = await seedCompany();
const http = request(app(boardActor));
+36 -22
View File
@@ -732,19 +732,22 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
.then((rows) => rows[0] ?? null);
if (!targetIssue || targetIssue.companyId !== companyId) throw notFound("Issue not found");
try {
const [link] = await db.insert(pipelineCaseIssueLinks).values({
companyId,
caseId,
issueId: req.body.issueId,
role: req.body.role,
createdByRunId: actor.type === "agent" ? actor.runId : null,
}).returning();
await writeRouteEvent(db, {
companyId,
caseId,
type: "issue_linked",
actor,
payload: { issueId: req.body.issueId, role: req.body.role },
const link = await db.transaction(async (tx) => {
const [created] = await tx.insert(pipelineCaseIssueLinks).values({
companyId,
caseId,
issueId: req.body.issueId,
role: req.body.role,
createdByRunId: actor.type === "agent" ? actor.runId : null,
}).returning();
await writeRouteEvent(tx, {
companyId,
caseId,
type: "issue_linked",
actor,
payload: { issueId: req.body.issueId, role: req.body.role },
});
return created!;
});
res.status(201).json(link);
} catch (error) {
@@ -756,15 +759,26 @@ export function pipelineRoutes(db: Db, options: Parameters<typeof pipelineServic
const caseId = req.params.caseId as string;
const linkId = req.params.linkId as string;
const companyId = await assertCaseAccess(db, req, caseId);
actorForMutation(req);
const [deleted] = await db
.delete(pipelineCaseIssueLinks)
.where(and(
eq(pipelineCaseIssueLinks.id, linkId),
eq(pipelineCaseIssueLinks.companyId, companyId),
eq(pipelineCaseIssueLinks.caseId, caseId),
))
.returning();
const actor = actorForMutation(req);
const deleted = await db.transaction(async (tx) => {
const [removed] = await tx
.delete(pipelineCaseIssueLinks)
.where(and(
eq(pipelineCaseIssueLinks.id, linkId),
eq(pipelineCaseIssueLinks.companyId, companyId),
eq(pipelineCaseIssueLinks.caseId, caseId),
))
.returning();
if (!removed) return null;
await writeRouteEvent(tx, {
companyId,
caseId,
type: "issue_unlinked",
actor,
payload: { issueId: removed.issueId, role: removed.role, linkId: removed.id },
});
return removed;
});
if (!deleted) throw notFound("Pipeline case issue link not found");
res.json({ deleted: true });
});