pelagia-portal/App/app/(portal)/admin/terms/actions.ts
Hardik 3babfe26ef
All checks were successful
PR checks / checks (pull_request) Successful in 45s
PR checks / integration (pull_request) Successful in 32s
feat(po): user-defined T&C categories + dynamic PO terms editor (#11)
Follow-up to the merged #11 PR (which shipped the enum-based catalogue): make
categories user-defined data and the PO T&C a dynamic editor.

- categories are a TermsCategory TABLE (not an enum) — admins add new ones;
- every PO T&C line is catalogued, incl. the previously-fixed boilerplate
  (seeded under a "General" category) and an "Others" bucket;
- the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a
  clause (components/po/po-terms-editor.tsx), used by new/edit/manager-edit.

Migration: the already-released 20260624140000 migration is untouched; a new
20260624150000 FORWARD migration renames the enum, creates the table, migrates
existing enum clauses onto category rows, adds isDefault/sortOrder + the two
fixed lines under General, and adds PurchaseOrder.terms (JSON snapshot that
supersedes the legacy tc* columns for export/detail; old POs fall back to tc*).

Tests rewritten for category creation + catalogue/default helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 04:43:24 +05:30

99 lines
3.3 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const schema = z.object({
// A category NAME — picked from the existing list or typed to create a new one.
categoryName: z.string().trim().min(1, "Category is required"),
text: z.string().trim().min(1, "Clause text is required"),
isDefault: z.boolean().default(false),
});
type Result = { ok: true } | { error: string };
async function guard(): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_terms")) {
return { error: "Forbidden" };
}
return { ok: true };
}
function parse(formData: FormData) {
return schema.safeParse({
categoryName: formData.get("categoryName"),
text: formData.get("text"),
isDefault: formData.get("isDefault") === "on" || formData.get("isDefault") === "true",
});
}
// Find a category by name (case-insensitive), creating it (appended to the end)
// if it doesn't exist — this is how new categories are added "along with clauses".
async function ensureCategory(name: string): Promise<string> {
const existing = await db.termsCategory.findFirst({
where: { name: { equals: name, mode: "insensitive" } },
select: { id: true },
});
if (existing) return existing.id;
const max = await db.termsCategory.aggregate({ _max: { sortOrder: true } });
const created = await db.termsCategory.create({
data: { name, sortOrder: (max._max.sortOrder ?? 0) + 1 },
});
return created.id;
}
export async function createTerm(formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0].message };
const categoryId = await ensureCategory(parsed.data.categoryName);
await db.termsCondition.create({
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
});
revalidatePath("/admin/terms");
return { ok: true };
}
export async function updateTerm(id: string, formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0].message };
const categoryId = await ensureCategory(parsed.data.categoryName);
await db.termsCondition.update({
where: { id },
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
});
revalidatePath("/admin/terms");
return { ok: true };
}
export async function toggleTermActive(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const term = await db.termsCondition.findUnique({ where: { id }, select: { isActive: true } });
if (!term) return { error: "Not found" };
await db.termsCondition.update({ where: { id }, data: { isActive: !term.isActive } });
revalidatePath("/admin/terms");
return { ok: true };
}
export async function deleteTerm(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
// Safe to delete: POs keep their T&C as a JSON snapshot, so no PO references this row.
await db.termsCondition.delete({ where: { id } });
revalidatePath("/admin/terms");
return { ok: true };
}