From cfe3decc7cc7a7439905fdd7f57c63791399f717 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Nov 2026 14:30:50 -0600 Subject: [PATCH] fix(status-cards): resolve the card link before creating the card A failed company read for the href ran inside the create route's cleanup path, which deleted the new card while its compile task stayed queued. The issue prefix is now read before the card is created; a regression test makes that read fail and checks that no card or task is left. Co-Authored-By: Claude Opus 5.5 --- server/src/__tests__/status-cards.test.ts | 26 +++++++++++++++++++++++ server/src/routes/status-cards.ts | 20 +++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/status-cards.test.ts b/server/src/__tests__/status-cards.test.ts index 34aa2d7f7ce..68245be4f69 100644 --- a/server/src/__tests__/status-cards.test.ts +++ b/server/src/__tests__/status-cards.test.ts @@ -168,6 +168,32 @@ describeEmbeddedPostgres("status card routes", () => { expect(await db.select().from(issues).then((rows) => rows[0])).toMatchObject({ status: "cancelled" }); }); + it("creates nothing when the card link cannot be resolved", async () => { + const company = await seedCompany(); + await enableStatusCards(); + await seedSummarizer(company.id); + // The link lookup runs before the card is created, so its failure cannot strand a queued compile task. + const failingPrefixDb = new Proxy(db, { + get(target, property) { + if (property === "select") { + return (fields?: Record) => { + if (fields && "issuePrefix" in fields) throw new Error("company read unavailable"); + return target.select(fields as never); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Db; + const response = await request(createApp(failingPrefixDb, localBoardActor())) + .post(`/api/companies/${company.id}/status-cards`) + .send({ interestPrompt: "Recently updated launch tasks" }); + + expect(response.status).toBe(500); + expect(await db.select().from(statusCards)).toEqual([]); + expect(await db.select().from(issues)).toEqual([]); + }); + it("creates, patches, archives, restores, lists updates, and deletes a card", async () => { const company = await seedCompany(); await enableStatusCards(); diff --git a/server/src/routes/status-cards.ts b/server/src/routes/status-cards.ts index 9e40dc99def..f244942edb3 100644 --- a/server/src/routes/status-cards.ts +++ b/server/src/routes/status-cards.ts @@ -33,9 +33,12 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake // People view, restore and refresh a card on the company's Status page. Single-card responses carry that // route so an agent can give the person a usable link instead of an API instruction. - async function withHref(card: T) { - const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, card.companyId)).limit(1); - return company ? { ...card, href: `/${company.issuePrefix}/status/${card.id}` } : card; + async function companyIssuePrefix(companyId: string) { + const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1); + return company?.issuePrefix ?? null; + } + function withHref(card: T, issuePrefix: string | null) { + return issuePrefix ? { ...card, href: `/${issuePrefix}/status/${card.id}` } : card; } async function assertStatusCardsEnabled() { @@ -169,6 +172,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake await assertCanMutate(req, companyId); assertAgentPromptLimit(req, req.body.interestPrompt); const actor = getActorInfo(req); + // Resolve the link before creating the card: the catch below deletes the card, so nothing that + // runs after the compile is queued may fail there. + const issuePrefix = await companyIssuePrefix(companyId); const card = await service.create(companyId, req.body, { agentId: actor.actorType === "agent" ? actor.actorId : null, userId: actor.actorType === "user" ? actor.actorId : null, @@ -176,7 +182,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake try { const compile = await enqueueCompile(req, card.id); await logMutation(req, companyId, "status_card.created", card.id, { state: card.state }); - res.status(201).json(await withHref(publicCard(compile.card))); + res.status(201).json(withHref(publicCard(compile.card), issuePrefix)); } catch (error) { await service.remove(card.id); throw error; @@ -187,9 +193,9 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake await assertStatusCardsEnabled(); const card = await getAccessibleResource(req, res, service.getById(req.params.id as string), "Status card not found"); if (!card) return; - res.json(await withHref(publicCard(await service.hydrate(card, { + res.json(withHref(publicCard(await service.hydrate(card, { issueReadCondition: await issueReadSqlCondition(db, req.actor), - })))); + })), await companyIssuePrefix(card.companyId))); }); router.patch("/status-cards/:id", validate(patchStatusCardSchema), async (req, res) => { @@ -211,7 +217,7 @@ export function statusCardRoutes(db: Db, opts: { heartbeat?: IssueAssignmentWake fields: Object.keys(req.body), archived: Boolean(updated.archivedAt), }); - res.json(await withHref(publicCard(compile?.card ?? restore?.card ?? updated))); + res.json(withHref(publicCard(compile?.card ?? restore?.card ?? updated), await companyIssuePrefix(card.companyId))); }); router.delete("/status-cards/:id", async (req, res) => {