diff --git a/src/app/actions/clients.ts b/src/app/actions/clients.ts new file mode 100644 index 0000000..5b555a0 --- /dev/null +++ b/src/app/actions/clients.ts @@ -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({ fields: "slug" }); + + const client = await pb.collection("clients").create({ + 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 { + await requireOwner(); + const pb = await getServerPb(); + + const patch: Record = {}; + 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("/"); +} \ No newline at end of file diff --git a/src/app/actions/projects.ts b/src/app/actions/projects.ts new file mode 100644 index 0000000..0391870 --- /dev/null +++ b/src/app/actions/projects.ts @@ -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({ + filter: `client = "${input.clientId}"`, + fields: "slug", + }); + + const project = await pb.collection("projects").create({ + 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 { + await requireOwner(); + const pb = await getServerPb(); + + const patch: Record = {}; + 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("/"); +} \ No newline at end of file