feat(forms): form definitions with per-field validation #497
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-731-form-definitions"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements PMS-731: form definitions with per-field validation, the substrate the PMS-730 MACD request flow consumes. Built in mokosh rather than extracted from eForm, per the decision recorded on the issue: eForm's whole form-definition surface is a three-field struct with no required flag and no rules, stored as an opaque JSON blob in a column added by an error-swallowing ALTER, and its validation is one hardcoded "email is required". There was nothing to extract.
Scope is bounded by the MACD field list reviewed on the issue, so every type and rule here traces to a line in that list.
What is here
migrations/100_form_definitions.sql:form_definitions,form_fields,form_submissions, each with its fail-closedtenant_isolationpolicy attached explicitly.src/modules/forms/validation.rs) interpreting the stored rule set, reporting failures asAppError::Validationwith aVec<FieldError>./api/v1/forms: definition CRUD, submit, list submissions.Field types: text, textarea, email, date, select, boolean. Rules: required, length, email pattern, date-not-in-past, option-set membership.
Decisions worth reviewing
Numeric range and file upload are deliberately absent. The issue's proposed approach listed numeric range; nothing in the MACD set has a number field at all, and no field wants an attachment. A rule with no caller is a liability rather than an affordance, and both are one migration away when a form needs them.
The
validatorcrate could not carry the per-field rules, and the issue's assumption that it would is corrected here.validator0.19 is derive-based, so#[derive(Validate)]expands rules known at compile time, while these rules are rows a tenant authored at runtime. What is reused is the error vocabulary:AppError::ValidationcarryingVec<FieldError>is the exact wire shape derive-based request validation already produces, so a client renders a form error and a DTO error identically. Note also thatvalidator::ValidationErrors::addtakes&'static strand cannot carry a runtime field name without leaking, which is whyFieldErroris the right target rather than the crate's own error type.Conditional requiredness is one form-level rule, not a condition engine. The MACD departure form needs
forward_toonly whenmailbox_handling = forward.form_definitions.rulesholds a rule list with exactly one kind,required_if, which buys that single behaviour. Conditional display stays out of scope.Submissions are a JSONB payload on a tenant-scoped row, not a row per value. The issue asked for relational storage "so RLS covers them", but a policy attaches to the table via
tenant_idand is indifferent to the payload's shape. Nothing in PMS-730's acceptance criteria queries across submissions by field value; normalise later if PMS-732 reporting actually needs it.The change-type to article mapping is a column here, not a table.
form_definitions.kb_article_idis PMS-730's "KB article selected by the requested change type". This row IS the request-type vocabulary, so the mapping is a column rather than a join, and a standalone table would have duplicated the vocabulary. A ticket created from a submission copies it intotickets.procedure_kb_article_id(migration 099, merged in #495).Behaviour that is easy to get wrong, and is pinned by tests
falseIS an answer. Treating it as absent would make a required checkbox impossible to say "no" to.fieldsreplaces the set rather than merging: field identity is the payload key, and a merge cannot express a rename or deletion unambiguously. Submissions keep their own stored payload, so this does not rewrite history.is_active = falseretires it, and a retired definition refuses new submissions rather than accepting them silently.required_ifwhoseequalsis not an option of the field it reads.RLS
All three tables attach
ENABLE+FORCE+ theNULLIF'd GUC comparison explicitly, because the DO-block loops in migrations 024 and 038 have already run and a table created now inherits no policy at all.tests/rls_coverage.rsverifies this generically and passes with the allowlist still empty. Every serving query goes throughDatabase::begin_with_tenant; there is no pre-auth path in this PR, since the public magic-link submission arrives with PMS-730 and will resolve its tenant from the token before calling in.Verification
just test-integrationgreen end to end (23 test binaries), pluscargo fmt --all --check,cargo clippy --all-targets -- -D warnings, and all six guard scripts (check-migration-prefixes,check-migration-immutability,check-pool-safety,check-no-duplicate-mail-copy,check-runner-labels,check-oci-build-cache).Open questions from the field-list review
Still unanswered on the issue and NOT blocking this PR, because the four MACD forms are data rather than code: whether moves are actually requested this way, whether
departmentneeds a per-client option set, and whether a request ever covers more than one person. The last one is the only one that would reshape the model, since a bulk onboarding is a repeat group.Note on CI
The Playwright job fails on staging for a reason unrelated to this branch: the bunyip hub rejects the E2E account's TOTP at
/login/2fa, which reproduces onmainand on every open PR. Diagnosis and suggested checks are on #496.