mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2010e43746
commit
cfe3decc7c
2 files changed
+39
-7
No files matched your search
@@ -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<string, unknown>) => {
|
||||
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();
|
||||
|
||||
@@ -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<T extends { id: string; companyId: string }>(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<T extends { id: string }>(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) => {
|
||||
|
||||
Reference in new issue
Block a user