diff --git a/apps/api/src/storage/task-board-columns.ts b/apps/api/src/storage/task-board-columns.ts index ee7cde9853..32bc05dcda 100644 --- a/apps/api/src/storage/task-board-columns.ts +++ b/apps/api/src/storage/task-board-columns.ts @@ -117,18 +117,37 @@ export class BoardColumnStorage { }); } - /** Say what one of this board's columns means to Studio, or unsay it. */ + /** + * Say what one of this board's columns means to Studio, or unsay it. + * + * A role means one column, not a set of them — `archiveColumn` and + * `automationFor` both read it that way, picking whichever row happens to + * match first. So giving a role to a column strips it from whichever column + * held it before, in the same transaction, rather than leaving two columns + * quietly claiming the same meaning. + */ async setRole( organizationId: string, key: string, role: string | null, ): Promise { - const result = await this.db - .updateTable("task_board_columns") - .set({ role, updated_at: new Date() }) - .where("organization_id", "=", organizationId) - .where("key", "=", key) - .executeTakeFirst(); - return (result.numUpdatedRows ?? 0n) > 0n; + return await this.db.transaction().execute(async (tx) => { + if (role !== null) { + await tx + .updateTable("task_board_columns") + .set({ role: null, updated_at: new Date() }) + .where("organization_id", "=", organizationId) + .where("role", "=", role) + .where("key", "!=", key) + .execute(); + } + const result = await tx + .updateTable("task_board_columns") + .set({ role, updated_at: new Date() }) + .where("organization_id", "=", organizationId) + .where("key", "=", key) + .executeTakeFirst(); + return (result.numUpdatedRows ?? 0n) > 0n; + }); } } diff --git a/apps/api/src/tools/task-board/board-handler.integration.test.ts b/apps/api/src/tools/task-board/board-handler.integration.test.ts index aeffd68d05..b5897e22c9 100644 --- a/apps/api/src/tools/task-board/board-handler.integration.test.ts +++ b/apps/api/src/tools/task-board/board-handler.integration.test.ts @@ -193,6 +193,17 @@ describe("boardHandler — a board whose columns are the org's own", () => { expect(await board().archiveColumn()).toBe(null); }); + /** A role names one column. Re-pointing it must strip it from wherever it + * used to be, or `archiveColumn` picks between two columns that both + * quietly claim "archived" — whichever the query happens to return first. */ + it("moves a role rather than duplicating it onto a second column", async () => { + await boardColumns.setRole(ORG_M, "BACKLOG", "archived"); + await boardColumns.setRole(ORG_M, "Code Review", "archived"); + const columns = await board().columns(); + expect(columns.filter((c) => c.role === "archived")).toHaveLength(1); + expect(await board().archiveColumn()).toBe("Code Review"); + }); + /** * The obligation the foreign key creates. A column the tracker dropped that * still holds cards cannot be deleted — RESTRICT refuses — and moving those