feat(actions): client and project management with seeded boards
This commit is contained in:
parent
c2446d85eb
commit
294d2aa11e
2 changed files with 180 additions and 0 deletions
69
src/app/actions/clients.ts
Normal file
69
src/app/actions/clients.ts
Normal 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
111
src/app/actions/projects.ts
Normal 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("/");
|
||||
}
|
||||
Loading…
Reference in a new issue