/** * TASK-0106 — form CRUD (workspace-scoped, database-bound). * * Permission model (spec §3): create/edit/publish/delete require `EDIT` on * the form's DATABASE (not just the workspace); read requires `VIEW` on the * database. The builder UI hides the entry for non-editors, but THIS service * is the gate (AGENTS.md: the frontend is never trusted). * * Publishing preconditions (spec §4), enforced on any transition INTO * PUBLISHED: * - the database exists and is not archived (trash), * - ≥ 1 question, * - every mapped question's property exists and is type-compatible, * - the slug is unique platform-wide (unique index is the final arbiter). * A failed publish → 422 with a structured `issues[]` (no partial publish). * * Delete is SOFT (spec §3): status → DRAFT + the slug is released with a * `-deleted-` suffix so the public URL dies and the name can be * reused; questions + submissions are RETAINED (data retention — documented). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError, type ErrorDetail } from "../../common/errors.js"; import { assertCan } from "../../lib/authorize.js"; import { between } from "../../lib/positions.js"; import { FORM_CONFIG_SCHEMA, FORM_STATUSES, isCompatibleMapping, type FormQuestionType, type FormStatus, type DbPropertyType } from "@factory/shared"; import { nextFreeSlug, normalizeSlug, isSlugConstraintError, SLUG_INPUT_REGEX } from "./slug.js"; import type { CreateFormInput, UpdateFormInput } from "./form.dto.js"; /** Allowed status transitions (spec §3: DRAFT→PUBLISHED→CLOSED→PUBLISHED). */ export const FORM_STATUS_TRANSITIONS: Record = { DRAFT: ["PUBLISHED"], PUBLISHED: ["CLOSED"], CLOSED: ["PUBLISHED"] }; interface InlineQuestion { type: string; label: string; description?: string; required?: boolean; validation?: unknown; propertyId?: string | null; placeholder?: string; position?: string; } @Injectable() export class FormsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService ) {} private readonly formInclude = { database: { select: { id: true, title: true } }, questions: { orderBy: { position: "asc" } }, _count: { select: { submissions: true } } } as const; // ---------- loaders ---------- /** Load the form row and assert the database level (workspace resolved * from the row — the form routes are workspace-free). */ private async loadRowChecked(formId: string, userId: string, need: "VIEW" | "EDIT") { const form = await this.prisma.form.findUnique({ where: { id: formId } }); if (!form) throw ApiError.notFound("Form not found."); await assertCan(this.prisma, { workspaceId: form.workspaceId, userId, resource: { databaseId: form.databaseId } }, need); return form; } // ---------- CRUD ---------- /** GET /databases/:databaseId/forms — the forms bound to a database. */ async listByDatabase(databaseId: string, userId: string) { const db = await this.prisma.database.findUnique({ where: { id: databaseId } }); if (!db) throw ApiError.notFound("Database not found."); await assertCan(this.prisma, { workspaceId: db.workspaceId, userId, resource: { databaseId: db.id } }, "VIEW"); const forms = await this.prisma.form.findMany({ where: { databaseId }, include: { _count: { select: { questions: true, submissions: true } } }, orderBy: { createdAt: "asc" } }); return { data: forms.map((f) => ({ id: f.id, databaseId: f.databaseId, name: f.name, slug: f.slug, description: f.description, status: f.status, config: f.config, questionCount: f._count.questions, submissionCount: f._count.submissions, createdById: f.createdById, createdAt: f.createdAt, updatedAt: f.updatedAt })) }; } /** POST /databases/:databaseId/forms — create a form (questions inline). */ async create(databaseId: string, userId: string, input: CreateFormInput, req: Request) { const db = await this.prisma.database.findUnique({ where: { id: databaseId } }); if (!db) throw ApiError.notFound("Database not found."); await assertCan(this.prisma, { workspaceId: db.workspaceId, userId, resource: { databaseId: db.id } }, "EDIT"); const config = this.parseConfig(input.config); const slug = await this.generateSlug(input.name, input.slug); let form; try { form = await this.prisma.$transaction(async (tx) => { const created = await tx.form.create({ data: { workspaceId: db.workspaceId, databaseId: db.id, name: input.name, slug, description: input.description ?? null, config: config as object, createdById: userId } }); if (input.questions) { await this.insertQuestions(tx, created.id, input.questions); } return tx.form.findUniqueOrThrow({ where: { id: created.id }, include: this.formInclude }); }); } catch (err) { if (isSlugConstraintError(err)) { throw new ApiError("CONFLICT", 409, "Form slug already exists.", [{ field: "slug", issue: "already_exists" }]); } throw err; } await this.audit.audit(req, req.auth, { action: "form.created", resourceType: "form", resourceId: (form as { id: string }).id, workspaceId: db.workspaceId, metadata: { name: input.name, slug, databaseId, questionCount: input.questions?.length ?? 0 } }); return form; } /** GET /forms/:formId — detail incl. questions + database property list. */ async get(formId: string, userId: string) { const form = await this.loadRowChecked(formId, userId, "VIEW"); const full = await this.prisma.form.findUniqueOrThrow({ where: { id: formId }, include: this.formInclude }); const properties = await this.prisma.databaseProperty.findMany({ where: { databaseId: form.databaseId, deletedAt: null }, orderBy: { position: "asc" }, select: { id: true, name: true, type: true, isTitle: true } }); return { ...full, properties }; } /** * PATCH /forms/:formId — name/description/config/slug/status (+ optional * full questions rebuild). Status transitions are validated against the * DRAFT→PUBLISHED→CLOSED→PUBLISHED graph; publishing runs the full * precondition gate (spec §4) — a failed publish rejects the WHOLE patch. */ async update(formId: string, userId: string, input: UpdateFormInput, req: Request) { const form = await this.loadRowChecked(formId, userId, "EDIT"); const db = await this.prisma.database.findFirst({ where: { id: form.databaseId, workspaceId: form.workspaceId } }); if (!db) throw ApiError.notFound("Form's database not found in this workspace."); // 1. status transition (validated BEFORE any side effect). if (input.status !== undefined && input.status !== form.status) { const allowed = FORM_STATUS_TRANSITIONS[form.status]; if (!allowed.includes(input.status)) { throw new ApiError("UNPROCESSABLE_ENTITY", 422, `Cannot transition form from ${form.status} to ${input.status}.`, [ { field: "status", issue: "invalid_transition", allowed: [...allowed] } ]); } if (input.status === "PUBLISHED") { await this.assertPublishable(form); } } // 2. slug change (uniqueness pre-check; the index is the final arbiter). if (input.slug !== undefined && input.slug !== form.slug) { if (!SLUG_INPUT_REGEX.test(input.slug)) { throw new ApiError("UNPROCESSABLE_ENTITY", 422, "slug must be kebab-case (a-z, 0-9, single dashes).", [ { field: "slug", issue: "invalid_format" } ]); } const taken = await this.prisma.form.findUnique({ where: { slug: input.slug } }); if (taken) { throw new ApiError("CONFLICT", 409, "Form slug already exists.", [{ field: "slug", issue: "already_exists" }]); } } const config = input.config !== undefined ? this.parseConfig(input.config) : undefined; const updated = await this.prisma.$transaction(async (tx) => { await tx.form.update({ where: { id: formId }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.description !== undefined ? { description: input.description } : {}), ...(input.status !== undefined ? { status: input.status } : {}), ...(input.slug !== undefined ? { slug: input.slug } : {}), ...(config !== undefined ? { config: config as object } : {}) } }); if (input.questions) { await tx.formQuestion.deleteMany({ where: { formId } }); await this.insertQuestions(tx, formId, input.questions); } return tx.form.findUniqueOrThrow({ where: { id: formId }, include: this.formInclude }); }); const auditMeta: Record = { fields: Object.keys(input) }; if (input.status !== undefined && input.status !== form.status) { auditMeta.status = { from: form.status, to: input.status }; } const action = input.status === "PUBLISHED" && form.status !== "PUBLISHED" ? "form.published" : "form.updated"; await this.audit.audit(req, req.auth, { action, resourceType: "form", resourceId: formId, workspaceId: form.workspaceId, metadata: auditMeta }); return updated; } /** * DELETE /forms/:formId — SOFT delete (spec §3): status → DRAFT + slug * released with `-deleted-` (frees the name, kills the public URL). * Questions + submissions are RETAINED (data retention — documented). */ async remove(formId: string, userId: string, req: Request) { const form = await this.loadRowChecked(formId, userId, "EDIT"); const suffix = `-deleted-${form.id.replace(/[^a-z0-9]/gi, "").slice(-8)}`; const newSlug = `${form.slug.slice(0, 50 - suffix.length)}${suffix}`; await this.prisma.form.update({ where: { id: formId }, data: { status: "DRAFT", slug: newSlug } }); await this.audit.audit(req, req.auth, { action: "form.deleted", resourceType: "form", resourceId: formId, workspaceId: form.workspaceId, metadata: { name: form.name, previousSlug: form.slug, newSlug } }); return { deleted: true }; } // ---------- publish gate (spec §4) ---------- /** * Publishing preconditions (throws 422 with structured `issues[]`): * 1. database exists + not archived (trash); * 2. ≥ 1 question; * 3. every mapped question → existing property of a compatible type; * 4. slug is unique platform-wide (the index backstops the race). */ async assertPublishable(form: { id: string; databaseId: string; workspaceId: string; slug: string }): Promise { const issues: ErrorDetail[] = []; const db = await this.prisma.database.findFirst({ where: { id: form.databaseId, workspaceId: form.workspaceId }, select: { id: true, isArchived: true } }); if (!db) { issues.push({ field: "database", issue: "database_missing", message: "the form's database no longer exists" }); } else if (db.isArchived) { issues.push({ field: "database", issue: "database_archived", message: "the form's database is in the trash" }); } const [questions, slugTaken] = await Promise.all([ this.prisma.formQuestion.findMany({ where: { formId: form.id } }), this.prisma.form.count({ where: { slug: form.slug, NOT: { id: form.id } } }) ]); if (questions.length === 0) { issues.push({ field: "questions", issue: "NO_QUESTIONS", message: "a form needs at least one question before publishing" }); } const props = db ? await this.prisma.databaseProperty.findMany({ where: { databaseId: db.id, deletedAt: null }, select: { id: true, name: true, type: true } }) : []; const byId = new Map(props.map((p) => [p.id, p])); for (const q of questions) { if (!q.propertyId) continue; const prop = byId.get(q.propertyId); if (!prop) { issues.push({ field: `questions:${q.id}`, issue: "UNKNOWN_PROPERTY", message: `question "${q.label}" maps to a property that no longer exists` }); } else if (!isCompatibleMapping(q.type as FormQuestionType, prop.type as DbPropertyType)) { issues.push({ field: `questions:${q.id}`, issue: "PROPERTY_TYPE_MISMATCH", message: `question "${q.label}" (${q.type}) maps to an incompatible ${prop.type} property` }); } } if (slugTaken > 0) { issues.push({ field: "slug", issue: "SLUG_TAKEN", message: "the slug is no longer unique" }); } if (issues.length > 0) { throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Form cannot be published.", issues); } } // ---------- helpers ---------- /** Insert inline questions in order (positions via the Phase-4 engine). */ private async insertQuestions( tx: Prisma.TransactionClient, formId: string, questions: InlineQuestion[] ): Promise { let cursor: string | undefined; for (const q of questions) { const position = q.position ?? between(cursor); cursor = position; await tx.formQuestion.create({ data: { formId, label: q.label, description: q.description ?? null, type: q.type as never, required: q.required ?? false, validation: (q.validation ?? {}) as object, propertyId: q.propertyId ?? null, placeholder: q.placeholder ?? null, position } }); } } /** Parse + validate the branding config (shared strict schema). */ private parseConfig(config: unknown): Record { const parsed = FORM_CONFIG_SCHEMA.safeParse(config ?? {}); if (!parsed.success) { const first = parsed.error.issues[0]; throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Form config is invalid.", [ { field: `config${first && first.path.length > 0 ? `.${first.path.join(".")}` : ""}`, issue: first?.code ?? "invalid", message: first?.message } ]); } return parsed.data as Record; } /** Slug: client-provided (regex-checked) or generated + collision-suffixed. */ private async generateSlug(name: string, provided?: string): Promise { if (provided) { // A client-provided slug is used VERBATIM. If it collides, the unique // index is the arbiter → the insert throws P2002 → the caller maps it // to a 409 (spec: "race-safe, unique index is the final arbiter"). // It must NOT be auto-suffixed (that would silently accept a slug the // caller asked for and rejected). if (!SLUG_INPUT_REGEX.test(provided)) { throw new ApiError("UNPROCESSABLE_ENTITY", 422, "slug must be kebab-case (a-z, 0-9, single dashes).", [ { field: "slug", issue: "invalid_format" } ]); } return normalizeSlug(provided); } // Auto-generated (from the name, or a random base on empty): the -2/-3 // suffix avoids a visible collision in the common case; the unique index // remains the final arbiter for the race window. const base = normalizeSlug(name) || (await this.randomSlugBase()); const taken = async (slugs: string[]) => new Set( (await this.prisma.form.findMany({ where: { slug: { in: slugs } }, select: { slug: true } })).map( (f) => f.slug ) ); return nextFreeSlug(base, taken); } private async randomSlugBase(): Promise { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let out = "form"; for (let i = 0; i < 6; i += 1) out += chars[Math.floor(Math.random() * chars.length)]; return out; } } export { FORM_STATUSES }; export type { FormStatus };