diff --git a/src/app/actions/cards.ts b/src/app/actions/cards.ts new file mode 100644 index 0000000..256b2ac --- /dev/null +++ b/src/app/actions/cards.ts @@ -0,0 +1,205 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { getServerPb } from "@/lib/pb/server"; +import { requireSession } from "@/lib/auth/session"; +import { logActivity } from "@/app/actions/activity"; +import { positionBetween } from "@/lib/ordering/fractional"; +import type { CardRecord, ColumnRecord } from "@/lib/pb/types"; + +/** + * Owner mutations. + * + * SECURITY: a Server Action is a public HTTP endpoint. Not rendering the + * button that calls it protects nothing. Every action below re-checks the + * session and the role before touching anything, and PocketBase's owner-only + * write rules refuse the request a second time if one of them is ever missed. + * + * These use the caller's OWN PocketBase token — no superuser — precisely so + * that second check is real. The single exception is logActivity, which needs + * a superuser because activity.createRule is superuser-only. + */ + +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") { + throw new Error("Not found"); + } + return session; +} + +function boardPath(clientSlug: string, projectSlug: string): string { + return `/p/${clientSlug}/${projectSlug}`; +} + +export async function createCard(input: { + projectId: string; + columnId: string; + title: string; + clientSlug: string; + projectSlug: string; +}): Promise<{ id: string }> { + const session = await requireOwner(); + const pb = await getServerPb(); + + const title = input.title.trim().slice(0, 200); + if (!title) throw new Error("A title is required"); + + const last = await pb + .collection("cards") + .getFirstListItem(`column = "${input.columnId}"`, { + sort: "-position", + }) + .catch(() => null); + + const card = await pb.collection("cards").create({ + project: input.projectId, + column: input.columnId, + title, + description: "", + position: positionBetween(last ? last.position : null, null), + priority: "medium", + created_by: session.userId, + created_by_role: "owner", + accepted: true, + archived: false, + }); + + await logActivity({ + project: input.projectId, + card: card.id, + actor: session.userId, + action: "card_created", + newValue: title, + }); + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); + return { id: card.id }; +} + +export async function updateCard(input: { + cardId: string; + title?: string; + description?: string; + scopeRule?: string; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb.collection("cards").getOne(input.cardId); + + const patch: Record = {}; + if (input.title !== undefined) { + const title = input.title.trim().slice(0, 200); + if (!title) throw new Error("A title is required"); + patch.title = title; + } + if (input.description !== undefined) { + patch.description = input.description.slice(0, 50000); + } + if (input.scopeRule !== undefined) { + patch.scope_rule = input.scopeRule.trim().slice(0, 60); + } + + if (Object.keys(patch).length === 0) return; + + await pb.collection("cards").update(input.cardId, patch); + + // One row per field, matching how client edits are logged, so the timeline + // reads consistently regardless of who made the change. + for (const field of Object.keys(patch)) { + if (field === "scope_rule") continue; // internal, not client-facing + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "card_updated", + field: field === "title" ? "title" : "description", + }); + } + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); + revalidatePath(`${boardPath(input.clientSlug, input.projectSlug)}/c/${input.cardId}`); +} + +/** + * Accept a client's request off the Inbox. + * + * Sets `accepted` and stamps `start_date`. The start date is not decoration: + * Phase 2 uses it to bound which commits a scope rule may claim, so a new card + * cannot retroactively swallow a year of old commits sharing its scope. + * + * The card moves to the column immediately after the Inbox — Backlog in the + * default layout — rather than to a hardcoded name, so a renamed board still + * works. + */ +export async function acceptCard(input: { + cardId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb.collection("cards").getOne(input.cardId); + + const columns = await pb.collection("columns").getFullList({ + filter: `project = "${card.project}"`, + sort: "position", + }); + + const inboxIndex = columns.findIndex((c) => c.is_inbox); + const destination = columns[inboxIndex + 1] ?? columns[inboxIndex]; + if (!destination) throw new Error("This project has no column to accept into"); + + const last = await pb + .collection("cards") + .getFirstListItem(`column = "${destination.id}"`, { + sort: "-position", + }) + .catch(() => null); + + const today = new Date().toISOString().slice(0, 10); + + await pb.collection("cards").update(input.cardId, { + accepted: true, + start_date: card.start_date || today, + column: destination.id, + position: positionBetween(last ? last.position : null, null), + }); + + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "card_accepted", + newValue: destination.name, + }); + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); +} + +export async function archiveCard(input: { + cardId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + const session = await requireOwner(); + const pb = await getServerPb(); + + const card = await pb.collection("cards").getOne(input.cardId); + + await pb.collection("cards").update(input.cardId, { archived: true }); + + await logActivity({ + project: card.project, + card: card.id, + actor: session.userId, + action: "card_archived", + }); + + revalidatePath(boardPath(input.clientSlug, input.projectSlug)); +} \ No newline at end of file