/** * TASK-0106 — form question operations. * * Add / update / delete / reorder questions on a form. Every write re-runs * the SHARED validation (per-type schema + worker ReDoS probe) and the * property-compatibility matrix — the server is authoritative on each write * (spec §1: "a mismatched propertyId is 422 PROPERTY_TYPE_MISMATCH"). * * Ordering uses the Phase-4 fractional positions engine (`between`) — a * question's `position` is a base-62 string; reorder computes a new midpoint * and never renumbers the rest. */ import { Inject, Injectable } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { ApiError, type ErrorDetail } from "../../common/errors.js"; import { assertCan, type AuthorizePrisma } from "../../lib/authorize.js"; import { between, isValid } from "../../lib/positions.js"; import { FORM_ERROR_CODES, QUESTION_PROPERTY_COMPATIBILITY, compatiblePropertyTypes, isCompatibleMapping, validateQuestionValidation, type FormQuestionType, type DbPropertyType } from "@factory/shared"; import type { CreateQuestionInput, UpdateQuestionInput } from "./question.dto.js"; export interface QuestionRow { id: string; formId: string; label: string; description: string | null; type: string; required: boolean; validation: unknown; propertyId: string | null; placeholder: string | null; position: string; } @Injectable() export class QuestionsService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} /** The form's questions in display order (position asc). */ async list(formId: string): Promise { return (await this.prisma.formQuestion.findMany({ where: { formId }, orderBy: { position: "asc" } })) as unknown as QuestionRow[]; } private async loadFormChecked( formId: string, userId: string, need: "VIEW" | "EDIT" ): Promise<{ id: string; workspaceId: string; databaseId: string }> { 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 { id: form.id, workspaceId: form.workspaceId, databaseId: form.databaseId }; } /** The form's database properties (id/type/isTitle) for mapping + compat. */ private async properties(databaseId: string) { return this.prisma.databaseProperty.findMany({ where: { databaseId, deletedAt: null }, select: { id: true, name: true, type: true, isTitle: true } }); } /** * Validate one question's `validation` config + `propertyId` compatibility. * Throws 422 with structured `details[]` on any failure. */ private async validateQuestion( form: { id: string; databaseId: string }, type: FormQuestionType, validation: unknown, propertyId: string | null | undefined ): Promise { const errors: ErrorDetail[] = []; // 1. per-type validation config (shared schema + ReDoS probe). const check = await validateQuestionValidation(type, validation ?? undefined); if (!check.valid) { errors.push({ field: "validation", issue: check.issue ?? "invalid", message: check.message }); } // 2. property mapping compatibility. if (propertyId) { const props = await this.properties(form.databaseId); const prop = props.find((p) => p.id === propertyId); if (!prop) { errors.push({ field: "propertyId", issue: FORM_ERROR_CODES.UNKNOWN_PROPERTY, message: "property does not exist on the form's database" }); } else if (!isCompatibleMapping(type, prop.type as DbPropertyType)) { errors.push({ field: "propertyId", issue: FORM_ERROR_CODES.PROPERTY_TYPE_MISMATCH, message: `${type} cannot map to a ${prop.type} property`, allowedTypes: this.compatibleList(type) }); } } if (errors.length > 0) { throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Question is invalid.", errors); } } /** Allowed property types for a question type (for 422 details). */ private compatibleList(type: FormQuestionType): string[] { return [...compatiblePropertyTypes(type)]; } async create(formId: string, userId: string, input: CreateQuestionInput) { const form = await this.loadFormChecked(formId, userId, "EDIT"); await this.validateQuestion(form, input.type, input.validation, input.propertyId); const existing = await this.list(formId); const last = existing.length > 0 ? existing[existing.length - 1]?.position : undefined; // New questions always APPEND (the reorder endpoint owns explicit // positioning — create has no position field). const position = between(last); const q = await this.prisma.formQuestion.create({ data: { formId, label: input.label, description: input.description ?? null, type: input.type, required: input.required, validation: (input.validation ?? {}) as object, propertyId: input.propertyId ?? null, placeholder: input.placeholder ?? null, position } }); return q as unknown as QuestionRow; } async update(formId: string, qid: string, userId: string, input: UpdateQuestionInput) { const form = await this.loadFormChecked(formId, userId, "EDIT"); const existing = await this.prisma.formQuestion.findFirst({ where: { id: qid, formId } }); if (!existing) throw ApiError.notFound("Question not found."); const type = (input.type ?? existing.type) as FormQuestionType; const validation = input.validation !== undefined ? input.validation : existing.validation; const propertyId = input.propertyId !== undefined ? input.propertyId : existing.propertyId; await this.validateQuestion(form, type, validation, propertyId); const data: Prisma.FormQuestionUpdateInput = {}; if (input.type !== undefined) data.type = input.type as never; if (input.label !== undefined) data.label = input.label; if (input.description !== undefined) data.description = input.description; if (input.required !== undefined) data.required = input.required; if (input.validation !== undefined) { data.validation = input.validation === null ? Prisma.JsonNull : (input.validation as Prisma.InputJsonValue); } if (input.propertyId !== undefined) data.propertyId = input.propertyId; if (input.placeholder !== undefined) data.placeholder = input.placeholder; if (input.position !== undefined && isValid(input.position)) data.position = input.position; const updated = await this.prisma.formQuestion.update({ where: { id: qid }, data }); return updated as unknown as QuestionRow; } async delete(formId: string, qid: string, userId: string) { const form = await this.loadFormChecked(formId, userId, "EDIT"); const existing = await this.prisma.formQuestion.findFirst({ where: { id: qid, formId } }); if (!existing) throw ApiError.notFound("Question not found."); await this.prisma.formQuestion.delete({ where: { id: qid } }); // Orphaned answers remain in FormSubmission.answers (documented, spec §3). return { deleted: true }; } /** * Reorder: move `fromId` so it sits before `toId` (or append when * `toId` is null/omitted). Returns the full question list in the new * order (the API's source of truth — the client reconciles to it). */ async reorder( formId: string, userId: string, body: { fromId: string; toId?: string | null } ): Promise { const form = await this.loadFormChecked(formId, userId, "EDIT"); const questions = await this.list(formId); const order = questions.map((q) => q.id); const fromIdx = order.indexOf(body.fromId); if (fromIdx === -1) throw ApiError.notFound("Question not found."); const withoutFrom = order.filter((id) => id !== body.fromId); let insertAt = withoutFrom.length; // default: append if (body.toId) { const toIdx = withoutFrom.indexOf(body.toId); if (toIdx === -1) throw ApiError.notFound("Target question not found."); insertAt = toIdx; // drop before `toId` } const newOrder = [...withoutFrom.slice(0, insertAt), body.fromId, ...withoutFrom.slice(insertAt)]; // Re-assign positions in the new order, left-to-right with a running // cursor (`between(cursor)` is strictly after the previous position). // Re-numbering is acceptable at form scale (≤ 50 questions) and keeps // the position space tight. let cursor: string | undefined; const finalPositions = newOrder.map((id) => { const position = between(cursor); cursor = position; return { id, position }; }); await this.prisma.$transaction( finalPositions.map((p) => this.prisma.formQuestion.update({ where: { id: p.id }, data: { position: p.position } })) ); return this.list(formId); } }