feat(actions): owner card create, update, accept and archive
This commit is contained in:
parent
e191b87c80
commit
32927ed350
1 changed files with 205 additions and 0 deletions
205
src/app/actions/cards.ts
Normal file
205
src/app/actions/cards.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue