Phase 10 — Forms (Task Specs)
Purpose
Phase 10 delivers forms: an in-app builder that binds a form to a database
(each question mapped to a database property), unauthenticated public form
pages with branding and abuse protection, a submission pipeline that creates
database records + FormSubmission rows and fires the form.submitted
domain event (so Phase-11 automations can react), and a responses view for
managing submissions.
Phase Exit Criteria (from ROADMAP.md § Phase 10)
- [x] Form builder (questions: type, required, validation, DB property mapping)
- [x] Public form pages (slug, branding, closed state)
- [x] Submission → database record (+ automation trigger)
- [x] Form responses view
- [x] Exit: public submission without auth creates a record and fires
form.submitted; validation rejects bad input server-side. ✅ proven bytests/e2e/specs/forms.spec.ts(full lifecycle a–g, desktop + mobile).
Tasks
| ID | Title | Estimate |
|---|---|---|
| TASK-0106 | Form builder: forms bound to databases, question CRUD, property mapping | L |
| TASK-0107 | Public form pages (/f/[slug]): branding, states, abuse protection |
M |
| TASK-0108 | Submission pipeline: validation → record + submission → form.submitted |
L |
| TASK-0109 | Form responses view + Phase-10 E2E | M |
Phase Completion Checklist
- [x] TASK-0106 complete: form CRUD bound to a database; 10 question types with required + validation config; property mapping filtered to compatible property types; builder UI with add/reorder/edit + preview.
- [x] TASK-0107 complete:
/f/[slug]renders without auth; branding (icon, intro, accent); PUBLISHED/CLOSED/DRAFT-404 states; mobile-first; honeypot + 5/min per-IP rate limit + reCAPTCHA config stub; zero workspace data leaked by the public endpoint. - [x] TASK-0108 complete:
POST /f/[slug]/submitvalidates server-side (zod from form config), creates the database record +FormSubmission(anonymous or attributed), firesform.submitted(automation + owner notification), shows the success state. - [x] TASK-0109 complete: responses view (table, record link, delete with cascade + confirm, CSV export stub); full E2E green (publish → incognito submit → record + response + event; invalid rejected; closed state; anonymous event verified).
- [x] Full CI green incl.
tests/e2e/forms.spec.ts(created in parallel). Verified against the live stack: E2E 4/4 (chromium-desktop + chromium-mobile), integration 26/26, API unit (forms + shared) green, web typecheck at the 210 pre-existing baseline (zero new), API typecheck at the 84 pre-existing baseline (zero in the forms module). ⚠️ Two TASK-0108 DoD items remain open by design (recorded, not silently shipped): the mid-transaction fault-injection test and the N+1 query-count / p95-under-50-rps guard — see the Phase-10 Notes.
Phase 10 Notes — recorded decisions & conflicts (AGENTS.md rule 29)
Phase 10 shipped green (E2E 4/4 desktop+mobile, integration 26/26, unit green, typechecks at baseline). The E2E surfaced a stack of web↔API wire drifts that pre-dated this phase. Per rule 29 the conflicts are recorded here; the load-bearing ones were reconciled in the web (single read/write point each) rather than by special-casing the form surfaces.
D1 — GET /databases/:id returns NESTED, the web read FLAT (fixed, web)
The API's database.service.get() returns
{ database: { id, workspaceId, title, … }, properties, views, recordsCount, … } — id/workspaceId/title live under .database, while the web's
DatabaseDto and every consumer read them flat. Result: database.id was
undefined for every view type, so no database page (TABLE included) could
render. Fix: flattenDatabase() in use-database.ts (the single read
point) normalizes the nested envelope to the flat DatabaseDto (tolerant of
an already-flat body). Recorded because the API and the web DTO disagree on
the wire shape; we reconciled at the boundary instead of forking the API.
D2 — ?view= deep link deleted on mount (StrictMode race, fixed, web)
The database page's URL-sync effect ran while views were still loading
(activeViewId === null) and executed params.delete("view"), wiping the
deep link before the views resolved; activeViewId then fell back to
views[0] (the seeded TABLE) and the URL was rewritten to the table. So a
/databases/<db>?view=<formViewId> link never opened the FORM view. Fix:
the effect returns early until an active view id resolves
(if (!activeViewId) return; // views still loading — keep the URL).
D3 — double-encoded request bodies (fixed, web)
doFetch in lib/api.ts did JSON.stringify(init.body) unconditionally, and
the use-database.ts call sites also passed JSON.stringify(payload) → the
server received a string of a string and answered 400 VALIDATION_ERROR: Unexpected token '"' on POST /databases/:id/query (and every other
write). Fix: doFetch now stringifies only non-string bodies (the
call sites keep their JSON.stringify). Central fix covering all ~13 call
sites.
D4 — POST /databases/:id/query does not exist (recorded, NOT fixed)
The web's useDatabaseRecords (and the table view) calls
POST /databases/:id/query, which the API has never implemented (404).
The API's record engine is only exposed as GET /databases/:id/records
(view-rendered listing, { data, nextCursor }). This is a core Phase-4/5
gap (affects every database view, not just forms), so it is out of Phase-10
scope. Recorded, not fixed. The forms E2E asserts the record + its
answer→property mapping via GET /databases/:id/records (the GET variant of
the same engine) instead of the in-browser table render. The responses surface
itself (GET /forms/:formId/submissions) is unaffected.
D5 — select validation.options is a STRING array, the web modeled it as
OBJECTS (fixed, web)
The server + @factory/shared/forms.ts store select options as
options: string[] (the submit validator filters non-strings; the shared
tests use { options: ["a","b"] }). The web's FormOption/questionOptions
modeled them as { name } objects, so form-field.tsx rendered
<option value={o.name}> → undefined for every option (selects unusable on
the public page and in the builder), and the builder wrote an object array the
server rejects. Fix: questionOptions() normalizes strings → { name: s }
on read (the single read point); the builder emits names (strings) on write;
FormValidation.options is typed (FormOption | string)[] to reflect the
wire.
D6 — GET /workspaces/:id resolves by ID only; app links use slugs
(recorded, NOT fixed)
workspaces.service.get() → requireMembership(workspaceId) resolves by
id only; a slug → 403 not_workspace_member. The web's useWorkspace(slug)
therefore 403s on a slug and the (app)/w/[workspace] route bounces to
/w/default. Some app links use the slug (sidebar/tree), others the id
(register/home) — a pre-existing inconsistency. Recorded, not fixed.
The forms E2E navigates with the workspace id (/w/<wsId>/…), which is
the only resolution that works.
D7 — the chromium-mobile Playwright project actually launched WebKit
(fixed, e2e config)
devices["iPhone 13"] carries defaultBrowserType: "webkit"; spread into the
chromium-mobile project that meant the "chromium-mobile" tests launched
WebKit, whose 29 system libraries are not installed here and are NOT
installed by CI either (ci.yml runs playwright install --with-deps chromium only). The project failed at browserType.launch everywhere with a
misleading "missing dependencies" banner. Fix: pin
defaultBrowserType: "chromium" on the project (keeps the iPhone
viewport/UA/isMobile/hasTouch, which is what the mobile behavior under test
actually is). Pre-existing — the project had never worked.
D8 — the event stream is capped + flooded; a fixed tail window is
unreliable (fixed, e2e helper)
factory:events is capped at ~10 000 and dominated by page.updated. The
E2E's original reader scanned the last 2 000 entries once, so a form.submitted
appended post-commit could be outside the window (or evicted) before the read
→ 0 events. Fix: capture the stream tail id before the submit and poll
only entries added after it (xrange (tailId +) until the event lands.
Deterministic regardless of the flood. (The test-only debug endpoint
GET /api/v1/debug/events — NODE_ENV=test + X-Debug-Token — is the
alternative for integration tests; the E2E stack runs in production mode, so
it reads the stream directly.)
D9 — pre-existing broken helpers the spec had to route around (recorded)
tests/e2e/utils/api.tscreateDatabasecalls the nonexistentPOST /workspaces/:id/databases. The spec creates the database via the real path (POST /workspaces/:id/pages { type:"database" }+POST /databases/:id/properties).tests/integration/databases.integration.test.tsand the e2edatabasesspec are stale against the current record engine (see D4).apps/webtypecheck has 210 pre-existing errors;apps/apihas 84. Phase 10 adds zero new (verified per file).next lintis broken repo-wide (ESLint 9 vs Next 14 legacy config) — pre-existing.
D10 — TASK-0108 DoD items left open by design (recorded, rule 30)
- Fault-injection atomicity: the record + submission are written in ONE Prisma transaction (atomic by construction; a 422/failure persists nothing — proven by the "nothing persisted" tests), but there is no MID-transaction fault-injection test (the only test seam is the member-add one; no seam to abort the submit tx). Adding one needs a new test-only seam — out of scope for this phase, recorded so it isn't silently claimed.
- N+1 guard (≤ 4 queries) + p95 < 500 ms @ 50 rps: the submit read path is
batched by construction (1
findUniquefor form+questions, 1findManyfor all properties, a batchedfile.findMany, then the transactional writes — no per-question DB hit), but there is no query-COUNT assertion (the integration harness's client is separate from the service'sPrismaService) and no forms k6 run. Recorded.
D11 — docs/ui/forms.md notes the responses view is WINDOWED, not
virtualized (clarification) The TASK-0109 DoD says "virtualized table"; the implementation uses a deterministic-height windowed list (no virtualization dependency — the app has none). The E2E proves the acceptance intent (DOM rows stay < 30 across a 40-row dataset, before and after scrolling). The DoD box is marked with this clarification rather than pretending a virtualization lib was used.
TASK-0106
Title
Form builder: form CRUD bound to a database, question types, validation config, property mapping
Objective
Build the form creation/editing experience: a form is bound to exactly one database; each question has one of 10 types, optional required/validation config, and an optional mapping to a database property — with a builder UI that supports add/reorder/edit questions, a live preview toggle, and property-mapping selects filtered to compatible property types.
Background
Prisma models exist: Form (forms: workspaceId, databaseId, name,
slug @unique, description?, status FormStatus @default(DRAFT),
config Json (branding/intro/success message), createdById),
FormQuestion (form_questions: formId, label, description?,
type FormQuestionType, required @default(false), validation Json?
(min/max, pattern, options), propertyId?, placeholder?, position
fractional string default "a0"), FormSubmission (form_submissions).
Enums: FormStatus DRAFT/PUBLISHED/CLOSED; FormQuestionType: SHORT_TEXT,
LONG_TEXT, NUMBER, EMAIL, DATE, CHECKBOX, SINGLE_SELECT, MULTI_SELECT,
RATING, FILE. DatabaseProperty (database_properties) has the 22
PropertyTypes. The fractional-position engine from Phase 4
(packages/editor positions) is reused for question ordering. Forms are a
database feature: a FORM value already exists in ViewType (Phase-5 views)
— the form is attached to the database page (sidebar entry "Form" on the
database view toolbar), NOT a standalone block.
Requirements
- Compatibility matrix (question type → allowed
PropertyTypes;null= no mapping, stored only inFormSubmission.answers): SHORT_TEXT → TITLE, TEXT, RICH_TEXT, EMAIL, PHONE, URL; LONG_TEXT → RICH_TEXT, TEXT; NUMBER → NUMBER; EMAIL → EMAIL; DATE → DATE; CHECKBOX → CHECKBOX; SINGLE_SELECT → SELECT, STATUS; MULTI_SELECT → MULTI_SELECT; RATING → NUMBER (stored as int 1..max); FILE → FILES. The mapping select offers only compatible types for the chosen question type (server re-validates on every write — a mismatchedpropertyIdis 422PROPERTY_TYPE_MISMATCH). - Validation config per type (zod schema in
apps/api/src/modules/forms/dto/question.dto.ts): SHORT_TEXT/LONG_TEXT:{ minLength?, maxLength?, pattern? (regex string, compiled server-side, max 200 chars) }; NUMBER:{ min?, max?, integer? }; RATING:{ max: 5..10, default? }; SINGLE_SELECT/MULTI_SELECT:{ options: string[1..50] (non-empty, unique, ≤ 50 chars) }; EMAIL: built-in; DATE:{ minDate?, maxDate? }; CHECKBOX: none; FILE:{ maxFiles?: 1..5 (default 1), maxBytes? }(per-file size still bound by the workspace plan limit — Phase 9).required: booleanon every question. - Form CRUD API (workspace-scoped,
EDITon the target database required to create/edit;VIEWto read):POST /api/v1/databases/:databaseId/forms(auto-generates a uniqueslugfrom the name: kebab-case, ≤ 50 chars,-2/-3suffix on collision —LIMITS.MAX_SLUG),GET /api/v1/forms/:formId(detail incl. questions ordered byposition+ database property list for the mapping UI),PATCH /api/v1/forms/:formId(name, description, config, status transition DRAFT→PUBLISHED→CLOSED→ PUBLISHED allowed; ARCHIVED not a FormStatus — closing is the terminal visible state; DRAFT forms are not public),DELETE /api/v1/forms/:formId(soft: status → DRAFT +slugsuffixed-deleted-<id8>to free the slug; submissions retained), question ops:POST /api/v1/forms/:formId/questions,PATCH /api/v1/forms/:formId/questions/:qid,DELETE .../questions/:qid(orphaned answers remain inFormSubmission.answers— document),POST .../questions/reorder { fromId, toId }(fractional position, Phase-4 engine). - Publishing preconditions (enforced on DRAFT→PUBLISHED): database exists
and is not in trash; ≥ 1 question; every question with
propertyIdset maps to an existingDatabasePropertyof a compatible type; slug is unique platform-wide. 422 with a structuredissues[]list otherwise. - Builder UI (
apps/web/src/components/forms/FormBuilder.tsx, opened from the database page toolbar "Form" button + from the FORM view slot): left = question list (drag to reorder, click to edit, delete, add menu with the 10 types + icons); right = inspector for the selected question (label, description, placeholder, required toggle, type-specific validation fields incl. options editor + rating max slider, property mapping select filtered per the matrix with "Don't map" option); top bar: name, status pill (Draft/Published/Closed), preview toggle (switches the right pane to the exact public renderer from TASK-0107 in an iframe-like embedded component — same component, not a copy), "Copy link" (enabled only when PUBLISHED:https://<host>/f/<slug>), publish/close actions with confirm on close. - Validation errors in the UI: inline per-field on save attempts (from the
422
issues[]), toast on success; unsaved-changes guard on leaving. - Config (branding) editing: icon (emoji picker + file upload via Phase-9
files, stored as a file id in
config.icon), intro text (markdown, ≤ 2000 chars), theme accent (6 preset colors), success message (≤ 500 chars), closed message (≤ 500 chars) — all inForm.config(Json, zod-validated shape). - One active form per database is NOT required (multiple allowed, each its own slug) — document the choice; the FORM view slot lists all forms for the database.
Acceptance Criteria
- Integration: create a form on database D → 201 with generated unique slug;
creating a second form with the same name gets the
-2suffix slug. - Integration: PATCH a question mapping SHORT_TEXT → a NUMBER property → 422
PROPERTY_TYPE_MISMATCHlisting the allowed types; the same mapping via a compatible type (TEXT property) succeeds. - Integration: publish a DRAFT with 0 questions → 422
issues[]containing the "at least one question" issue; adding a question then publish → 200 and the public endpoint (TASK-0107) serves it. - Unit: zod validation matrix — every type's config schema accepts a valid config and rejects each documented invalid shape (min > max, empty option, duplicate option, rating max < 5, pattern > 200 chars, options > 50).
- Reorder: 5 questions reordered in 3 different sequences →
positionvalues strictly ordered, API returns them in the new order (integration). - Delete question with existing submissions (seeded) → submissions keep the
orphaned answer values in
answers(assert JSON intact), form still renders the remaining questions. - UI: builder drag-reorder persists across reload (Playwright), preview toggle shows the public renderer with the current unsaved state, mapping select for a SINGLE_SELECT question lists only SELECT/STATUS properties (count assertion against a fixture database with 8 mixed properties).
Backend Changes
apps/api/src/modules/forms/:forms.module.ts,forms.controller.ts(form CRUD + question ops),questions.controller.ts,forms.service.ts,questions.service.ts,dto/*.ts(zod: form, question, reorder, config),compatibility.ts(the type→PropertyType matrix, single source of truth, exported for the frontend via a shared const in@factory/shared/forms.ts).@factory/shared/forms.ts(new):QUESTION_PROPERTY_COMPATIBILITY,FORM_CONFIG_SCHEMA(zod),QuestionTypeconst enum — consumed by both API and web (no duplicated matrices).- Slug generator:
apps/api/src/modules/forms/slug.ts(kebab, ≤ 50, collision suffix, uniqueness check transactional with the Form insert).
Frontend Changes
apps/web/src/components/forms/:FormBuilder.tsx,QuestionList.tsx,QuestionInspector.tsx(per-type validation forms incl. options editor, rating slider, pattern input with live test),PropertyMappingSelect.tsx,FormConfigPanel.tsx(branding),FormPreviewPane.tsx(imports the TASK-0107 renderer).- Database page toolbar: "Form" button (opens builder; lists existing forms for the database).
apps/web/src/lib/api/forms.ts(typed client).
Database Changes
- No schema change (Form/FormQuestion exist;
positionfractional string already present).
API Changes
- New endpoints per Requirements §3 (all under
/api/v1/databases/:id/formsand/api/v1/forms/:id); contract indocs/api/forms.md(new file, shared with TASK-0107/0108/0109).
Tests
- Unit: compatibility matrix (every cell), slug generator (collision sequences, unicode names, length cap), validation config schemas (full matrix above), position reorder math.
- Integration: all Acceptance Criteria API scenarios in
tests/integration/forms-builder.spec.ts(fixture: database with all 22 property types represented where possible). - E2E (smoke, full E2E in TASK-0109): create form → add 3 questions → reorder → publish via UI.
Security
- Create/edit requires
EDITon the database (server-side; builder UI hides the button otherwise but the API is the gate). patternregexes are compiled with a timeout guard (ReDoS: test the regex against a 10k-char probe at compile time; reject pathological patterns > 100 ms with 422PATTERN_TOO_COMPLEX).- Slug collision check must be race-safe (unique index is the final arbiter; catch the constraint error → regenerate, max 3 attempts).
- Deleting a form must not delete submissions (data retention; document).
Documentation
docs/api/forms.md(new): builder endpoints, question schema per type, compatibility matrix table, error codes.docs/ui/forms.md(new, builder section): interactions, preview semantics, unsaved-changes guard.
Dependencies
- Phase 5 (databases, properties, views — the FORM view slot), Phase 4 (fractional positions), Phase 9 (icon file upload in config; FILE question upload path reuses Phase-9 sessions — FILE answers stored as file ids).
- TASK-0107 renders what this task builds.
Definition of Done
- [x] Form + question CRUD APIs with zod validation and the compatibility matrix enforced server-side.
- [x] 10 question types with full validation config (unit matrix green).
- [x] Builder UI: add/reorder/edit, filtered mapping select, live preview, branding config, publish/close with preconditions.
- [x] Slugs unique platform-wide, collision-safe, ≤ 50 chars.
- [x]
@factory/shared/forms.tsis the single source of truth (no duplicated matrix in API or web). - [x] Unit + integration green;
docs/api/forms.md+ builder UI docs merged.
TASK-0107
Title
Public form pages: /f/[slug] unauthenticated rendering, branding, states, abuse protection
Objective
Render published forms at https://<host>/f/[slug] with no authentication:
workspace branding, the questions in order, the three lifecycle states
(published / closed / draft-404), mobile-first layout, and abuse protection
(honeypot, per-IP rate limit 5/min, reCAPTCHA config stub) — while exposing
nothing beyond the form itself through the public endpoint.
Background
Form + FormQuestion data exist (TASK-0106). The public endpoint
GET /api/v1/public/forms/:slug is new and deliberately isolated from the
workspace auth middleware. The web app serves the form page as a standalone
route OUTSIDE the workspace app group (apps/web/src/app/f/[slug]/page.tsx
— no workspace layout, no sidebar, minimal shell: favicon + accent color
from Form.config). Rate limiting uses the shared Redis-backed limiter
(Phase 1 pattern: rate-limit:{key} counters). The reCAPTCHA integration
itself ships in Phase 13+ — this task adds the config stub
(config.recaptcha = { enabled: false, siteKey?: string }) and the
verification hook point.
Requirements
- Public endpoint:
GET /api/v1/public/forms/:slug(no auth) returns ONLY:{ form: { slug, name, description, status: "published"|"closed", config: { iconUrl, intro (rendered markdown, sanitized), accent, successMessage, closedMessage } }, questions: [{ id, label, description, type, required, placeholder, validation: <public-safe subset> }] }. The public-safe validation subset excludespatternsource (client only needs to know "text with rules" — patterns run server-side only to avoid leaking business rules; minLength/maxLength/max/min/options ARE exposed for client pre-validation). NEVER returned: workspace name/slug, database id or properties, property names/labels, questionpropertyId, other forms, any user data, submission data. A DRAFT or deleted form → 404 (no status leak); a CLOSED form → 200 withstatus: "closed"(the closed state is intentionally visible). - Public page:
apps/web/src/app/f/[slug]/page.tsx(SSR: fetch the public endpoint server-side for first paint + metadata (title = form name, icon = form icon)); renderer componentapps/web/src/components/forms/ PublicForm.tsx(shared with the builder preview, TASK-0106 §5). Per-type inputs: short/long text (textarea for long), number (step 1, min/max attrs), email (type=email), date (native date input), checkbox, single select (radio group or select), multi select (checkbox group), rating (1..max star buttons, keyboard selectable), file (file input, maxFiles from validation — uploads go through Phase-9 sessions attributed to the form, see TASK-0108). - States: PUBLISHED → interactive form; CLOSED → closed card (icon +
closedMessage+ "Check back later" — no inputs rendered at all); DRAFT/unknown → the page shows a generic 404 (same 404 as any missing page; do NOT reveal that a form existed). - Branding: form icon (emoji or image) + name as the page header; intro
rendered below (sanitized markdown: allow p/strong/em/ul/ol/a[href^=
http]/br — no scripts, no iframes); accent color drives the primary
button + focus rings (6 presets; CSS variables); page background subtle
neutral; footer "Powered by AI Software Factory" (configurable off via
config.poweredBy: boolean, default true). - Client pre-validation: on submit attempt, validate required +
minLength/maxLength/min/max/options using the exposed subset; show
inline field errors (red border + message,
aria-describedby); server remains authoritative (TASK-0108 re-validates everything). - Abuse protection (server-side, enforced at the submit endpoint but the
page must cooperate):
(a) Honeypot: a visually hidden field
website(CSSposition:absolute; left:-9999px,tabindex=-1,autocomplete="off", labelled for screen-reader invisibility) — any value present → 400 with a generic "Submission failed. Please try again." (do NOT reveal the reason; log internallyform.honeypot_triggered). (b) Rate limit: 5 submissions per minute per IP per form (Redisform:rl:{slug}:{ip}, 60 s window); 6th → 429 withRetry-After: <seconds>; the page shows "Too many submissions — please wait N seconds" (no reCAPTCHA wall). (c) reCAPTCHA stub: ifconfig.recaptcha.enabled(default false), the page renders arecaptchaplaceholder div where Phase 13 mounts the widget and the submit payload gainsrecaptchaToken; the server hookverifyRecaptcha(token)exists as a stub returning{ skipped: true }when the provider is unconfigured (documented). - Mobile-first: single column, inputs full-width, 16 px font on text inputs (no iOS zoom), star rating touch targets ≥ 44 px, submit button sticky on small screens, test at 390 px; desktop: centered 640 px card.
- No workspace data leakage, provable: the page's HTML + network calls
(asserted by E2E) contain only the public payload; no
workspacein meta tags, no API calls to/api/v1/*(authenticated routes), no database property names anywhere in the DOM.
Acceptance Criteria
- Integration:
GET /api/v1/public/forms/:slugfor a PUBLISHED form returns the exact allowed field set (assert the response keys; assertpropertyIdand pattern source are absent); DRAFT form → 404; CLOSED → 200 closed; random slug → 404. - Integration: property-name leak scan — build a form whose database
property names are unique sentinel strings (e.g.
SENTINEL_PROP_XYZ); assert the sentinel never appears in the public response JSON (byte scan). - E2E (
tests/e2e/forms.spec.ts, scenario "public page states"): published form in incognito → header/intro/inputs render with the accent color; closed form → closed card, zero input elements in DOM; draft form → generic 404 page. - E2E: mobile 390 px — all 10 question types render and are operable (type, select, star click, file pick); no horizontal overflow; 16 px input font (computed style assertion).
- Honeypot: E2E fills the hidden
websitefield viaevaluate→ submit → generic failure shown; server log containsform.honeypot_triggeredwith the IP (log assertion in integration). - Rate limit: 5 rapid anonymous submits (valid or not) → 6th is 429 with
Retry-After≤ 60; the wait message shows in the UI; a second IP (mocked X-Forwarded-For in integration) is unaffected. - reCAPTCHA stub: with
config.recaptcha.enabled = true, the placeholder div renders and the submit payload includes an emptyrecaptchaTokenslot; server logsrecaptcha verification skipped (stub)(integration). - SSR: the published page's initial HTML (no JS) contains the form title +
question labels (SEO/no-JS baseline, Playwright
waitForLoadStatedisabled assertion).
Backend Changes
apps/api/src/modules/forms/public.controller.ts+public.service.ts:GET /api/v1/public/forms/:slugwith the field whitelist + public-safe validation subset builder; mounted WITHOUT the auth guard (explicit@Public()decorator convention, documented).apps/api/src/modules/forms/rate-limit.ts: Redis sliding-window limiter (per slug + IP, 5/min), IP from the trusted proxy chain (Phase-1request.ip()helper).apps/api/src/modules/forms/recaptcha.ts:verifyRecaptchastub (interface + no-op implementation + envRECAPTCHA_SECRETvalidated but unused until Phase 13).- Markdown sanitizer:
apps/api/src/modules/forms/markdown.ts(reuse Phase-4's shared sanitizer if present inpackages/editor/@factory/ui— check first, reuse over duplicate).
Frontend Changes
apps/web/src/app/f/[slug]/page.tsx(SSR shell, outside workspace group)layout.tsx(minimal, no workspace chrome).
apps/web/src/components/forms/PublicForm.tsx(shared renderer, all 10 types, client pre-validation, states, honeypot field, sticky submit),RatingInput.tsx,MultiSelectInput.tsx(reused by TASK-0108 submit).- No new route inside the workspace group; workspace members reaching
/f/[slug]get the same public page (simple by design; the in-workspace experience is the database FORM view + responses, TASK-0109).
Database Changes
- None.
API Changes
- New public (unauthenticated) endpoint
GET /api/v1/public/forms/:slug— contract + field whitelist indocs/api/forms.md(marked PUBLIC, with the leakage guarantees).
Tests
- Unit: public payload builder (whitelist exactness, public-safe subset strips patterns), rate limiter (window math, per-key isolation), honeypot detection, markdown sanitizer (XSS vector list: script, onerror, javascript: url, iframe — all stripped).
- Integration: all Acceptance Criteria integration scenarios (states, leak
scan, rate limit, honeypot, recaptcha stub) in
tests/integration/forms-public.spec.ts. - E2E: all Acceptance Criteria E2E scenarios (states, mobile, honeypot UX, SSR baseline).
Security
- The public endpoint is the only unauthenticated surface this phase adds;
its whitelist is enforced by construction (explicit field pick, never
...formspread) + a response-schema test that fails on any new key. - No auth, no cookies set, no CSRF surface (GET only; submit is TASK-0108
and CSRF-safe via
X-Requested-With/custom header check orContent-Type: application/json— decide + document: JSON content-type requirement, which browsers send only via fetch/XHR). - Honeypot + rate limit protect the (expensive) submit path; 429 responses
are cached by proxies via
Cache-Control: no-store+Retry-After. - Markdown output is sanitized server-side AND re-sanitized client-side (defense in depth).
- Rate-limit key includes the slug (one abusive form can't starve another).
Documentation
docs/api/forms.mdaddendum: public endpoint contract + whitelist + abuse protection behavior + reCAPTCHA stub status.docs/security/forms.md(new): public form threat model (enumeration, spam, XSS via branding, rate abuse), mitigations, reCAPTCHA Phase-13 checklist.docs/ui/forms.mdaddendum: public page states + branding spec.
Dependencies
- TASK-0106 (data + config shape), Phase 1 (auth guard conventions, IP helper, Redis limiter pattern), Phase 4 (shared markdown sanitizer), Phase 9 (file question uploads).
- TASK-0108 consumes the page's submit flow.
Definition of Done
- [x]
GET /api/v1/public/forms/:slugwith exact whitelist; DRAFT → 404, CLOSED → visible closed state. - [x]
/f/[slug]SSR public page with branding, all 10 input types, 3 states, mobile-first (390 px verified). - [x] Honeypot + 5/min per-IP rate limit enforced (429 + Retry-After), recaptcha stub wired.
- [x] Leakage proven: sentinel property-name scan + response key test.
- [x] XSS vectors stripped (sanitizer test list).
- [x] Unit + integration + E2E green; docs merged.
TASK-0108
Title
Submission pipeline: server-side validation → database record + FormSubmission → form.submitted event
Objective
Process POST /f/[slug]/submit: validate the payload server-side from the
form's own config (zod), create the mapped database record, record the
FormSubmission (answers, IP, anonymous or attributed actor), fire the
form.submitted domain event so Phase-11 automations and the form-owner
notification trigger — and render the success state.
Background
The public page (TASK-0107) collects answers { questionId: value }.
FormSubmission (form_submissions): workspaceId, formId,
recordId? (the created DatabaseRecord), answers Json,
submittedBy? (null = anonymous, userId when a logged-in member submits —
the public page supports an optional signed-in state: if the browser holds
a valid session for that workspace, the submit endpoint attributes the
submission and the form header shows "Submitting as <name>" — otherwise
fully anonymous), ipAddress, createdAt. DatabaseRecord +
RecordProperty creation is Phase-5's record service
(apps/api/src/modules/database/records.service.ts — reuse its
createRecord with property-value coercion, never duplicate the
coercion logic). Domain event form.submitted is already in
@factory/shared/events. Automations (Phase 11) subscribe to
FORM_SUBMITTED triggers; the owner notification uses Phase-7's
notification service (in-app; email channel optional flag).
FILE answers: each file is uploaded via Phase-9 presigned sessions
attributed to the submission context (pageId = the record's page after
creation — two-phase: upload with pageId: null, then link on complete;
or upload to a staging prefix and move — decision: upload with
formId context param added to the session endpoint, link files to the
record page after creation; document the choice).
Requirements
- Endpoint:
POST /api/v1/public/forms/:slug/submit(unauthenticated;Content-Type: application/jsonrequired — 415 otherwise, CSRF mitigation per TASK-0107); body:{ answers: Record<questionId, value>, recaptchaToken?: string, website?: string (honeypot, must be absent/empty) }. Public form only (DRAFT → 404, CLOSED → 403FORM_CLOSEDwith the closed message). - Server-side validation (zod schema BUILT FROM THE STORED FORM CONFIG at
request time — the stored config is the schema source, not a static
DTO): per question, apply
required, type coercion (NUMBER → number, CHECKBOX → boolean, RATING → int ≤ max, SINGLE_SELECT → one of options, MULTI_SELECT → array of options, unique, ≤ options length, DATE → ISO date string within min/max, EMAIL → format, FILE → array of file ids ≤ maxFiles, all file ids must exist, belong to this workspace, status READY, and be linked to this form context — rejectFILE_NOT_READY/FILE_NOT_OWNED); min/max/length/pattern fromvalidation. All violations → 422 witherrors: [{ questionId, field?, code, message }](the UI maps these to field-level messages; unknown questionIds are ignored, extra top-level keys dropped). - Transaction (single Prisma
$transaction): (a) create theDatabaseRecordin the form's database: for each question with apropertyId, coerce the answer into the property's value shape via the Phase-5 record service (e.g. SELECT → option string, STATUS → option, NUMBER → number, DATE → ISO, PERSON/RELATION unmapped — the matrix in TASK-0106 makes these unreachable; RATING → NUMBER int); unmapped answers are stored ONLY inFormSubmission.answers. Record title: the TITLE-mapped question's value, or the form name + " — <first 8 chars of the first text answer>" fallback (deterministic). (b) create theFormSubmission(answers as submitted, pre-coercion values — raw answers are the audit trail;recordIdfrom (a);ipAddressfrom the trusted proxy chain;submittedBy= session user id if a valid session exists for this workspace, else null). (c) link FILE answers' files to the record's page (File.pageId/blockIdnull,metadata.formId). On any failure: nothing persisted (record + submission atomic). - Side effects (after commit, best-effort with their own error handling):
(a) emit domain event
form.submitted{ workspaceId, formId, slug, submissionId, recordId, databaseId, questionCount, anonymous: boolean, ip? (redacted to /24 for the event payload — full IP only in the DB) }→ Phase-11FORM_SUBMITTEDtrigger matcher consumes it. (b) create an in-app notification for the form owner (createdBy): type "form submission", link to the database form view, body "<form name> received a new response" (+ "from <name>" when attributed). Respect the owner's notification preferences (Phase 7). (c) audit eventform.submitted(actor: user or "anonymous" + IP; resource: the submission) — mandatory per AGENTS.md (automation-adjacent, data-ingestion op). - Response: 201
{ success: true, submissionId, message: form.config.successMessage }(the page replaces the form with the success card: checkmark icon, the success message, a "Submit another response" button that resets the form). 422 keeps the user's input (the UI re-renders fields with server errors, scrolls to the first error). 429/400 (honeypot) per TASK-0107 semantics. - Idempotency/abuse: no client token required in Phase 10 (rate limit is
the gate); response includes
X-Submission-Idheader (the submissionId) for client dedupe logging. A second submit with identical answers within the rate window is still rejected by the limiter (do not add content dedupe — documented). - Limits:
answerspayload ≤ 64 KB (413PAYLOAD_TOO_LARGE); file answers ≤ 5 ids; total form render + submit p95 < 500 ms (validation must not N+1: load form + questions + database + properties in ≤ 4 queries).
Acceptance Criteria
- Integration (ROADMAP exit criterion): anonymous POST (no session cookie)
with valid answers → 201; a
DatabaseRecordexists in the target database with the mapped property values correctly coerced (SELECT question → option string on a SELECT property; NUMBER → number; RATING → int); aFormSubmissionexists withrecordIdset,submittedBynull,ipAddresspresent, rawanswersintact; theform.submittedevent appears on the Redis stream within 2 s with the documented payload. - Integration: invalid input — required missing, NUMBER out of range,
SINGLE_SELECT value not in options, MULTI_SELECT duplicate, pattern
violation, FILE id from another workspace → 422 with ALL violations in
errors[](each withquestionId); nothing persisted (0 new records, 0 new submissions — assert counts). - Integration: CLOSED form submit → 403
FORM_CLOSED; DRAFT → 404. - Integration: attributed submit — a logged-in member of the workspace
submits →
submittedBy= their userId, notification "from <name>" created for the owner; anonymous → plain notification. - Integration: atomicity — force the record insert to fail (trigger a constraint via a fixture that makes the TITLE property non-nullable with no title answer) → submission row absent, no orphan files linked.
- Integration: honeypot value present → 400 generic, no submission, log
line present; 6th submit in 60 s → 429 +
Retry-After. - Performance: 50 concurrent valid submits on a 10-question form → p95
< 500 ms, zero N+1 (query-count assertion per request ≤ 4 via
Prisma
$extendsquery logging in test). - E2E (
tests/e2e/forms.spec.tsscenario "valid submission"): incognito submit of a valid form → success card with the configured success message → "Submit another" resets; the created record is visible in the database table view (logged-in session on the same origin, different context).
Backend Changes
apps/api/src/modules/forms/submit.controller.ts+submit.service.ts: payload validation from stored config (buildAnswerSchema(form, questions)insubmit-schema.ts— dynamic zod builder, unit-tested), transaction, side effects.apps/api/src/modules/forms/file-links.ts: file-answer verification (ownership, READY, workspace) + post-commit linking.- Reuse: Phase-5
records.service.createRecord(property coercion), Phase-7 notification service,@factory/shared/eventspublisher, audit service. - Session attribution: read the optional session (no auth required — a valid cookie attributes, an invalid/absent one means anonymous; never 401 on the public endpoint).
Frontend Changes
PublicForm.tsxsubmit flow (TASK-0107 shell): fetch submit, map 422errors[]to field errors (scroll + focus first), 429 wait message with countdown, 403FORM_CLOSED→ closed card, success state (card + reset button), "Submitting as" header when a session exists (fetched from the page's optionalGET /api/v1/meprobe — only if a cookie is present; 401 → anonymous silently).- FILE question: per-file upload progress (Phase-9
upload.ts), files disabled from submit until all READY.
Database Changes
- No schema change (FormSubmission/DatabaseRecord/RecordProperty exist).
API Changes
- New public endpoint
POST /api/v1/public/forms/:slug/submit— contract, error model (errors[]), event payload, and CSRF note documented indocs/api/forms.md. @factory/shared/events:form.submittedpayload type (already named; document the exact payload indocs/architecture/events.mdaddendum).
Tests
- Unit:
buildAnswerSchema(every question type × required × validation combo — the same matrix as TASK-0106 plus coercion), error mapping shape, success message resolution. - Integration: all Acceptance Criteria scenarios in
tests/integration/forms-submit.spec.ts(incl. atomicity fault injection, event stream assertion, N+1 query-count guard). - E2E: the valid-submission scenario (rest of the E2E file is TASK-0109's).
Security
- Server-side validation is authoritative: the client subset (TASK-0107) is advisory only; patterns are never exposed to the client.
- IP captured once from the trusted proxy chain; the event payload carries
a /24-redacted IP (full IP only in
form_submissions.ipAddress, access- controlled in TASK-0109). - File answers verified (ownership + workspace + READY) before linking —
a forged file id from another workspace is rejected and logged
(
form.file_forged_attemptaudit warn). - The dynamic zod schema is built from STORED config: config corruption
(bad JSON in
Form.config/validation) → fail closed with 500FORM_CONFIG_INVALID+ log (never a permissive fallback). - Audit on every submission (anonymous actor type supported by
AuditActorType— verify the enum covers "anonymous"/"system"; add a migration if missing — expected present, verify first).
Documentation
docs/api/forms.mdaddendum: submit contract + error codes + event payload + attribution semantics.docs/architecture/events.mdaddendum:form.submittedpayload + consumers (Phase-11 trigger matcher, owner notification).docs/security/forms.mdaddendum: submission threat model (forged file ids, config injection, N+1/DoS) + mitigations.
Dependencies
- TASK-0106 (form data, matrix), TASK-0107 (page + rate limit + honeypot),
Phase 5 (record creation + coercion), Phase 7 (notifications), Phase 9
(file sessions/linking),
@factory/shared/events. - Phase 11 consumes the
form.submittedevent (contract frozen here).
Definition of Done
- [x] Submit endpoint with dynamic server-side validation from stored config (all 10 types + all validation rules).
- [ ] Atomic record + submission creation (fault-injection test proves rollback). ⚠️ The record + submission are written in ONE Prisma transaction (atomic by construction — a 422/failure persists nothing, proven by the "nothing persisted" integration tests), but there is no MID-TRANSACTION fault-injection test (no test seam to abort the tx). Recorded, not silently shipped (Phase-9 precedent).
- [x]
form.submittedevent fired with the documented payload; owner notification created (attributed + anonymous variants). - [x] FILE answers verified + linked; forged ids rejected + audited.
- [x] Success/422/429/closed/404 states all proven (integration + E2E).
- [ ] N+1 guard (≤ 4 queries) + p95 < 500 ms under 50 rps. ⚠️ The read path
is batched by construction (1
findUniqueform+questions, 1findManyall properties, a batchedfile.findMany, then the transactional writes — no per-question DB hit), but there is no query-COUNT integration assertion (the harness client is separate from the service'sPrismaService) and no forms k6 run. Recorded. - [x] Unit + integration + E2E green; docs merged.
TASK-0109
Title
Form responses view (database page) + Phase-10 E2E suite
Objective
Give form owners the management surface: a responses view on the database page listing submissions (answers, submitter, IP, time) with a link to each created record, submission deletion that cascades the created record (with confirm), a CSV export stub wired for Phase 13 — and the complete Phase-10 E2E suite proving the phase exit criteria.
Background
The FORM view slot (ViewType.FORM, Phase 5) on the database page is where
lives: (a) the builder entry (TASK-0106), (b) the published-link chip, and
(c) this task's responses table. FormSubmission rows carry answers
(raw), submittedBy?, ipAddress, createdAt, recordId. Deleting a
submission deletes the record it created (the record is form-owned by
definition — but if an owner has manually re-parented/edited it, deletion
still proceeds: form submissions are the source of truth for their records;
document this). CSV export: Phase 13's export pipeline owns the full
feature; this task ships the endpoint stub GET /api/v1/forms/:id/export?format=csv
returning 501 NOT_IMPLEMENTED_YET with a roadmap note, and the UI button
disabled with a "Coming soon" tooltip (the schema for the future export is
documented now to prevent rework).
Requirements
- Responses view: inside the database page's FORM view (tab "Responses"
next to "Builder" when a form exists): a virtualized table (Phase-5
virtualization) of the form's submissions, newest first: columns =
(submission number), each question (up to 8 visible columns, "…" menu
for the rest — value rendered per type: text truncated 60 chars, select as pill, rating as stars, date formatted, checkbox ✓/—, file as count-chip, email as link), "Submitted by" (user avatar+name or "Anonymous"), "IP" (masked by default:1.2.3.***, full on hover for Admin/Owner — privacy-by-default), "Received" (relative time), row actions: "Open record" (→ record's page), "Delete" (trash icon → confirm dialog). - Record link:
recordIdpresent → row click on the # cell or the "Open record" icon navigates to the record page;recordIdnull (should not happen post-TASK-0108, but legacy rows possible) → shows "No record" muted, delete still works (orphan submission delete = row delete only). - Delete submission:
DELETE /api/v1/forms/:formId/submissions/:submissionId(permission:EDITon the form's database; Admin/Owner always): confirm dialog states exactly what happens ("This deletes the submission AND the database record it created. This cannot be undone."); on confirm: transaction — delete theDatabaseRecord(via Phase-5's record delete, which cascades its page per Phase-3/5 semantics), delete the submission row, delete files linked viametadata.formId= this submission (their objects via Phase-9 purge), emitrecord.deleted+ auditform.submission_deleted(metadata: submissionId, recordId, submitter). The view updates (row removed, optimistic update with rollback on failure). - Filters/sort: search box (matches answer text + submitter name + IP), date-range filter (from/to), "Anonymous only" toggle; sort by received date (default desc) or by any question column (text/number/date). Empty state: "No responses yet" + the published link (copy button). Error state: banner + retry.
- CSV export stub:
GET /api/v1/forms/:id/export?format=csv→ 501NOT_IMPLEMENTED_YET { implementedIn: "phase-13" }; UI: "Export CSV" button disabled, tooltip "Available in a later release"; the documented future contract (columns: submission number, received ISO, submitter email-or-id, one column per question (label — type), record link) is indocs/api/forms.mdso Phase 13 implements against it. - List endpoint:
GET /api/v1/forms/:formId/submissions?cursor=&limit=50& q=&from=&to=&anonymousOnly=→{ items: [FormSubmissionView], nextCursor };FormSubmissionView= submission + question labels (server-joins questions) + submitter display name/avatar (only for workspace members who may see members;submittedByof a deleted user → "Former member"). Permission:VIEWon the database (responses are workspace data — NEVER exposed on the public endpoint; IP masked unless Admin/Owner). - E2E suite (
tests/e2e/forms.spec.ts— completes the file started by TASK-0108): scenario "full form lifecycle": (a) logged-in owner creates a "Feedback" database (Title + Rating + SINGLE_SELECT "Category" + EMAIL) → creates a form mapping all 4 → publishes; (b) opens/f/<slug>in an incognito context → submits a valid form → success card; (c) back in the workspace: the record appears in the database table view with correct values; the responses view lists the submission with submitter "Anonymous", masked IP, all answer cells; (d)form.submittedverified via the automation-stub log (a Phase-11 stub automation created on the database with FORM_SUBMITTED trigger writes a marker — if Phase 11 not yet run, verify via the Redis stream through a test-only admin endpointGET /api/v1/debug/events?name= form.submittedavailable only inNODE_ENV=test): the event payload has the right recordId +anonymous: true; (e) invalid submit (incognito: rating above max + missing email) → field errors rendered, no new record; (f) owner closes the form → incognito sees the closed state; submit attempt → 403 closed message; (g) owner deletes the submission from the responses view (confirm) → record gone from the table view, submission row gone, file (if any) purged.
Acceptance Criteria
- Integration: list endpoint returns submissions with joined question
labels + submitter names; cursor pagination stable;
q=matches answer text + submitter + IP;anonymousOnlycorrect; a non-member gets 403/404 per workspace semantics; IP masked for Member, full for Admin (assert both responses). - Integration: delete submission cascades the record (record + its page
gone,
record.deletedevent observed), linked files purged (S3 key gone), audit row present; deleting an orphan submission (recordId null fixture) deletes only the row. - E2E full-lifecycle scenario (a–g above) passes end to end, including the incognito anonymous submit → record + response + event (the ROADMAP exit criterion, observed in the browser where possible and via the test-only events endpoint for the stream).
- E2E: invalid input shows per-field server errors (rating max, email format) and creates nothing; closed form shows closed state and rejects submits.
- Responses view: 100 seeded submissions render virtualized (assert row count in DOM < 30 while scrolling), filter + search + sort work, empty state shows the copy-link button.
- Export stub: button disabled with tooltip; API returns 501 with the
implementedInfield; future contract documented. - Mobile 390 px: responses table becomes cards (answer list per card), delete flow works.
Backend Changes
apps/api/src/modules/forms/submissions.controller.ts+ service: list (joined view model, filters, cursor) + delete (cascade transaction per Requirements §3) + export stub endpoint.- Permission checks via the Phase-5 database permission service (
VIEW/EDIT), Admin/Owner IP-unmask rule in the view model builder. @factory/debug/eventstest-only endpoint (gated byNODE_ENV=test+X-Debug-Tokenfrom envTEST_DEBUG_TOKEN) — used by the E2E suite to assert stream events; documented as test-only, excluded from production builds via guard.
Frontend Changes
apps/web/src/components/forms/ResponsesView.tsx(virtualized table, per-type cell renderers, # / submitter / masked-IP / time columns, actions),SubmissionDeleteDialog.tsx,SubmissionCard.tsx(mobile), filter bar (search, date range, anonymous toggle, sort), empty/error states, "Export CSV" stub button.- Database FORM view tab wiring (Builder / Responses / published-link chip).
Database Changes
- None (reads + deletes existing rows).
API Changes
- New:
GET /api/v1/forms/:id/submissions,DELETE /api/v1/forms/:id/submissions/:submissionId,GET /api/v1/forms/:id/export(501 stub). Contract + future CSV schema indocs/api/forms.md.
Tests
- Unit: view model builder (label joining, submitter resolution incl. deleted user, IP mask rules per role), delete cascade plan (which resources for a given submission), filter query builder.
- Integration: all Acceptance Criteria integration scenarios in
tests/integration/forms-responses.spec.ts. - E2E: the full-lifecycle scenario (a–g) + mobile cards scenario in
tests/e2e/forms.spec.ts.
Security
- Responses (incl. IPs and answers with potential PII) are workspace data:
VIEWminimum, IP masked by default, full IP Admin/Owner only, all access audited via the standard request audit (no extra endpoint — covered by workspace access logs). - Delete is transactional + audited + requires confirm in the UI; the API
itself requires the
confirm=truequery flag to make accidental programmatic deletes explicit (422CONFIRM_REQUIREDwithout it). - The test-only debug endpoint is compiled behind
NODE_ENV=test(not merely guarded at runtime) and requires the debug token.
Documentation
docs/api/forms.mdaddendum: submissions endpoints + delete semantics + future CSV contract (Phase 13 implements against it).docs/ui/forms.mdaddendum: responses view spec (columns, mask rules, mobile cards).docs/security/forms.mdaddendum: PII handling in responses (IP mask, answer data, deletion audit).
Dependencies
- TASK-0106/0107/0108 (data, page, pipeline), Phase 5 (virtualized table, record delete, FORM view slot), Phase 7 (notifications done), Phase 9 (file purge on cascade).
- Phase 13: real CSV/JSON export implements the documented contract.
Definition of Done
- [x] Responses view: windowed table (no virtualization dependency — fixed row height + bounded scroll window keeps the DOM to < 30 rows, proven by the E2E) + filters + newest-first order + mobile cards + empty/error states.
- [x] Record link works; delete cascades record + files transactionally,
with confirm +
confirm=trueAPI flag + audit. - [x] IP masked by default; Admin/Owner unmask; non-member denied.
- [x] Export stub (501 + documented future contract + disabled UI).
- [x] Full E2E lifecycle (a–g) green — the ROADMAP exit criterion proven:
public submission without auth creates a record and fires
form.submitted; validation rejects bad input server-side. - [x] Unit + integration + E2E green; docs merged.