feat(actions): client and project management with seeded boards

This commit is contained in:
Dave 2026-09-16 11:27:01 +02:00
parent c2446d85eb
commit 294d2aa11e
2 changed files with 180 additions and 0 deletions

View file

@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerPb } from "@/lib/pb/server";
import { requireSession } from "@/lib/auth/session";
import { slugify, uniqueSlug } from "@/lib/slug/slug";
import type { ClientRecord } 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;
}
export async function createClient(input: {
name: string;
brandColor: string;
}): Promise<{ id: string }> {
await requireOwner();
const pb = await getServerPb();
const name = input.name.trim().slice(0, 120);
if (!name) throw new Error("A name is required");
const existing = await pb
.collection("clients")
.getFullList<ClientRecord>({ fields: "slug" });
const client = await pb.collection("clients").create<ClientRecord>({
name,
slug: uniqueSlug(slugify(name), existing.map((c) => c.slug)),
brand_color: input.brandColor || "#2f6dad",
active: true,
});
revalidatePath("/admin/clients");
revalidatePath("/");
return { id: client.id };
}
export async function updateClient(input: {
clientId: string;
name?: string;
brandColor?: string;
active?: boolean;
}): Promise<void> {
await requireOwner();
const pb = await getServerPb();
const patch: Record<string, unknown> = {};
if (input.name !== undefined) {
const name = input.name.trim().slice(0, 120);
if (!name) throw new Error("A name is required");
patch.name = name;
}
if (input.brandColor !== undefined) patch.brand_color = input.brandColor;
if (input.active !== undefined) patch.active = input.active;
// The slug is deliberately not regenerated on rename. It is in every URL the
// client may have bookmarked, and a tidier slug is not worth breaking them.
if (Object.keys(patch).length === 0) return;
await pb.collection("clients").update(input.clientId, patch);
revalidatePath("/admin/clients");
revalidatePath("/");
}

111
src/app/actions/projects.ts Normal file
View file

@ -0,0 +1,111 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerPb } from "@/lib/pb/server";
import { requireSession } from "@/lib/auth/session";
import { slugify, uniqueSlug } from "@/lib/slug/slug";
import { assertColumnInvariants, buildColumnRecords } from "@/lib/columns/seed";
import type { ProjectRecord } 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;
}
/**
* Create a project and seed its board.
*
* The columns are not optional decoration. A project with no `is_inbox` column
* has nowhere to put a client's request, and POST /api/client/cards answers
* 409 — a failure the client discovers, days later, in their own face. The
* invariants are asserted before anything is written.
*
* PocketBase has no multi-record transaction, so if a column create fails
* partway the project is deleted again rather than left half-built.
*/
export async function createProject(input: {
clientId: string;
name: string;
description: string;
repoOwner: string;
repoName: string;
color: string;
}): Promise<{ id: string }> {
await requireOwner();
const pb = await getServerPb();
const name = input.name.trim().slice(0, 120);
if (!name) throw new Error("A name is required");
const siblings = await pb.collection("projects").getFullList<ProjectRecord>({
filter: `client = "${input.clientId}"`,
fields: "slug",
});
const project = await pb.collection("projects").create<ProjectRecord>({
client: input.clientId,
name,
slug: uniqueSlug(slugify(name), siblings.map((p) => p.slug)),
description: input.description.slice(0, 20000),
status: "active",
color: input.color || "#2f6dad",
repo_owner: input.repoOwner.trim(),
repo_name: input.repoName.trim(),
repo_url: "",
});
const columns = buildColumnRecords(project.id);
assertColumnInvariants(columns);
try {
for (const column of columns) {
await pb.collection("columns").create({ ...column });
}
} catch (error) {
await pb.collection("projects").delete(project.id).catch(() => {});
throw error;
}
revalidatePath("/admin/projects");
revalidatePath("/");
return { id: project.id };
}
export async function updateProject(input: {
projectId: string;
name?: string;
description?: string;
status?: ProjectRecord["status"];
repoOwner?: string;
repoName?: string;
color?: string;
}): Promise<void> {
await requireOwner();
const pb = await getServerPb();
const patch: Record<string, unknown> = {};
if (input.name !== undefined) {
const name = input.name.trim().slice(0, 120);
if (!name) throw new Error("A name is required");
patch.name = name;
}
if (input.description !== undefined) {
patch.description = input.description.slice(0, 20000);
}
if (input.status !== undefined) patch.status = input.status;
if (input.repoOwner !== undefined) patch.repo_owner = input.repoOwner.trim();
if (input.repoName !== undefined) patch.repo_name = input.repoName.trim();
if (input.color !== undefined) patch.color = input.color;
// As with clients, the slug is never regenerated on rename — it is in the
// board URL a client may have bookmarked.
if (Object.keys(patch).length === 0) return;
await pb.collection("projects").update(input.projectId, patch);
revalidatePath("/admin/projects");
revalidatePath("/");
}