246 lines
7.3 KiB
TypeScript
246 lines
7.3 KiB
TypeScript
"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 { nextCardNumber } from "@/lib/cards/number";
|
|
import { relinkProject } from "@/lib/linking/engine";
|
|
import { resolveWorkArea } from "@/lib/work-areas/server";
|
|
import type { WorkAreaChoice } from "@/lib/work-areas/core";
|
|
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;
|
|
} & WorkAreaChoice): 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");
|
|
if (!input.areaId && !input.newAreaName?.trim()) throw new Error("Choose an area");
|
|
const workArea = await resolveWorkArea(pb, input.projectId, input);
|
|
|
|
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,
|
|
work_area: workArea,
|
|
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);
|
|
|
|
if (input.scopeRule !== undefined) {
|
|
await relinkProject(card.project);
|
|
}
|
|
|
|
// 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 relinkProject(card.project);
|
|
|
|
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 relinkProject(card.project);
|
|
|
|
await logActivity({
|
|
project: card.project,
|
|
card: card.id,
|
|
actor: session.userId,
|
|
action: "card_archived",
|
|
});
|
|
|
|
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
|
|
}
|
|
|
|
export async function unarchiveCard(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: false });
|
|
|
|
await relinkProject(card.project);
|
|
|
|
await logActivity({
|
|
project: card.project,
|
|
card: card.id,
|
|
actor: session.userId,
|
|
action: "card_updated",
|
|
field: "archived",
|
|
});
|
|
|
|
revalidatePath(boardPath(input.clientSlug, input.projectSlug));
|
|
revalidatePath(`${boardPath(input.clientSlug, input.projectSlug)}/archive`);
|
|
}
|