/** * TASK-0106 §3 — form slug generation. * * Kebab-case from the form name, ≤ 50 chars (LIMITS.MAX_SLUG), with a * deterministic `-2`/`-3`/… suffix on collision. The collision check + * insert is NOT atomic here — `FormsService` wraps the whole thing in a * transaction and the `forms.slug` unique index is the final arbiter * (P2002 → regenerate, max 3 attempts; spec §Security "race-safe"). */ import { Prisma } from "@prisma/client"; /** A slug the client may provide (already kebab-shaped by its own UI). */ export const SLUG_INPUT_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const MAX_SLUG = 50; /** Kebab-case a display name: lowercase, non-alphanumerics → `-`. */ export function kebabSlug(name: string): string { return name .toLowerCase() .normalize("NFKD") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } /** A valid slug candidate: 2..50 chars, kebab. (Empty names → ""). */ export function normalizeSlug(name: string): string { return kebabSlug(name).slice(0, MAX_SLUG).replace(/-+$/g, ""); } /** * `slug` with the next free collision suffix: `slug` itself when free, * else `slug-2`, `slug-3`, … (spec: "-2/-3 suffix on collision"). * * `prisma` is the live client (or a transaction); `taken(slugs)` must * report which candidates are already used platform-wide (one query). */ export async function nextFreeSlug( base: string, taken: (slugs: string[]) => Promise> ): Promise { const clean = base || "form"; const first = await taken([clean]); if (!first.has(clean)) return clean; for (let n = 2; ; n++) { // Keep the total ≤ MAX_SLUG (suffix eats into the budget). const candidate = `${clean.slice(0, MAX_SLUG - String(n).length - 1)}-${n}`; const used = await taken([candidate]); if (!used.has(candidate)) return candidate; if (n > 1000) throw new Error(`slug generation exhausted for ${clean}`); } } /** True when a Prisma error is the `forms.slug` unique-constraint violation. */ export function isSlugConstraintError(err: unknown): boolean { return ( err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" && (Array.isArray(err.meta?.target) ? (err.meta.target as string[]).includes("slug") : err.meta?.target === "slug") ); }