feat(actions): column management guarded by board invariants
This commit is contained in:
parent
101112f566
commit
42adc45b16
1 changed files with 150 additions and 0 deletions
150
src/app/actions/columns.ts
Normal file
150
src/app/actions/columns.ts
Normal file
|
|
@ -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<ColumnRecord[]> {
|
||||
const pb = await getServerPb();
|
||||
return pb.collection("columns").getFullList<ColumnRecord>({
|
||||
filter: `project = "${projectId}"`,
|
||||
sort: "position",
|
||||
});
|
||||
}
|
||||
|
||||
export async function addColumn(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
clientSlug: string;
|
||||
projectSlug: string;
|
||||
}): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await requireOwner();
|
||||
const pb = await getServerPb();
|
||||
|
||||
const column = await pb
|
||||
.collection("columns")
|
||||
.getOne<ColumnRecord>(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}`);
|
||||
}
|
||||
Loading…
Reference in a new issue