From 42adc45b166358a9d9fc188f0afb8f776b705e3c Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:27:46 +0200 Subject: [PATCH] feat(actions): column management guarded by board invariants --- src/app/actions/columns.ts | 150 +++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 src/app/actions/columns.ts diff --git a/src/app/actions/columns.ts b/src/app/actions/columns.ts new file mode 100644 index 0000000..73b1c59 --- /dev/null +++ b/src/app/actions/columns.ts @@ -0,0 +1,150 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import { assertColumnInvariants } from "@/lib/columns/seed"; +import { positionBetween, rebalance } from "@/lib/ordering/fractional"; +import type { ColumnRecord } from "@/lib/pb/types"; + +/** See the security note at the top of src/app/actions/cards.ts. */ +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + return session; +} + +async function columnsFor(projectId: string): Promise { + const pb = await getServerPb(); + return pb.collection("columns").getFullList({ + filter: `project = "${projectId}"`, + sort: "position", + }); +} + +export async function addColumn(input: { + projectId: string; + name: string; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + const pb = await getServerPb(); + + const name = input.name.trim().slice(0, 60); + if (!name) throw new Error("A name is required"); + + const existing = await columnsFor(input.projectId); + const done = existing.find((c) => c.is_done); + + // New columns go immediately before Done, not after it. a lane past the + // terminal one reads as "finished, then more work", which nobody means. + const beforeDone = existing.filter((c) => !c.is_done); + const last = beforeDone[beforeDone.length - 1] ?? null; + + const position = done + ? positionBetween(last ? last.position : null, done.position) + : positionBetween(last ? last.position : null, null); + + await pb.collection("columns").create({ + project: input.projectId, + name, + position, + is_inbox: false, + is_done: false, + }); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}`); +} + +export async function renameColumn(input: { + columnId: string; + name: string; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + const pb = await getServerPb(); + + const name = input.name.trim().slice(0, 60); + if (!name) throw new Error("A name is required"); + + await pb.collection("columns").update(input.columnId, { name }); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}`); +} + +/** + * Reorder a project's columns. + * + * `orderedIds` is the full set in the new display order. Anything missing or + * unknown is rejected rather than ignored — a partial write here leaves the + * board in an order nobody chose. + */ +export async function reorderColumns(input: { + projectId: string; + orderedIds: string[]; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + const pb = await getServerPb(); + + const existing = await columnsFor(input.projectId); + const known = new Set(existing.map((c) => c.id)); + + if ( + input.orderedIds.length !== existing.length || + input.orderedIds.some((id) => !known.has(id)) + ) { + throw new Error("The column order does not match this project"); + } + + for (const { id, position } of rebalance(input.orderedIds)) { + await pb.collection("columns").update(id, { position }); + } + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}`); +} + +/** + * Delete a column. + * + * Refused when it holds cards, and refused when removing it would leave the + * project without exactly one inbox and one done column. Both are checked + * before anything is written — `columns.project` cascade-deletes cards, so a + * delete that "works" would take live cards with it silently. + */ +export async function deleteColumn(input: { + columnId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + const pb = await getServerPb(); + + const column = await pb + .collection("columns") + .getOne(input.columnId); + + const cards = await pb.collection("cards").getList(1, 1, { + filter: `column = "${input.columnId}" && archived = false`, + fields: "id", + }); + if (cards.totalItems > 0) { + throw new Error("Move this column's cards elsewhere before deleting it"); + } + + const remaining = (await columnsFor(column.project)).filter( + (c) => c.id !== input.columnId + ); + + // Throws with a readable message when the last inbox or done column is + // about to disappear. + assertColumnInvariants(remaining); + + await pb.collection("columns").delete(input.columnId); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}`); +} \ No newline at end of file