feat(actions): owner card create, update, accept and archive

This commit is contained in:
Dave 2026-09-16 10:59:16 +02:00
parent e191b87c80
commit 32927ed350

205
src/app/actions/cards.ts Normal file
View file

@ -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<CardRecord>(`column = "${input.columnId}"`, {
sort: "-position",
})
.catch(() => null);
const card = await pb.collection("cards").create<CardRecord>({
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<void> {
const session = await requireOwner();
const pb = await getServerPb();
const card = await pb.collection("cards").getOne<CardRecord>(input.cardId);
const patch: Record<string, unknown> = {};
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<void> {
const session = await requireOwner();
const pb = await getServerPb();
const card = await pb.collection("cards").getOne<CardRecord>(input.cardId);
const columns = await pb.collection("columns").getFullList<ColumnRecord>({
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<CardRecord>(`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<void> {
const session = await requireOwner();
const pb = await getServerPb();
const card = await pb.collection("cards").getOne<CardRecord>(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));
}