S2c D2, D3, D4, D8 — the meat of the public API rewrite.
Draft / save / submit split (D4):
- POST /public/forms/{public_token}/submissions
Creates a draft. idempotency_key is now REQUIRED; second POST with
the same key returns the existing draft (HTTP 200 vs 201 for fresh).
UniqueConstraintViolationException caught for race-safe replay.
- PUT /public/forms/{public_token}/submissions/{submission_id}
Auto-save. Partial updates only — each PUT writes just the
slugs in the body. Status stays 'draft'; auto_save_count++.
- POST /public/forms/{public_token}/submissions/{submission_id}/submit
Final submission. Merges body values with already-saved values,
runs strict rule set against the merged map, then calls
FormSubmissionService::submit which fires the lifecycle events
(tag sync, identity match). Rate-limited per IP per token per hour.
Access rules: submission must belong to the resolved schema; status
must be 'draft' (409 SUBMISSION_ALREADY_SUBMITTED otherwise); schema
still accepting submissions.
Sub-endpoints (D2, D3):
- GET /public/forms/{public_token}/time-slots
Volunteer-only, festival-aware (parent + children). Reads straight
from TimeSlot model — no org-coupled service to extract from. Out:
{id, name, date, start_time, end_time, duration_hours, event_id,
event_name}.
- GET /public/forms/{public_token}/sections
show_in_registration=true, type=standard, deduplicated by name
across festival children.
Dynamic per-field validation (D8):
- FormFieldRuleBuilder builds Laravel rule arrays from form_fields.
strict() enforces is_required + in:options + type rules (email,
url, numeric, date, boolean, phone regex); relaxed() is the
auto-save variant that drops required-ness.
- StartPublicDraftRequest (required idempotency_key),
SavePublicDraftRequest (relaxed rules, values optional),
SubmitPublicSubmissionRequest (relaxed rules at body level — the
controller merges the body with saved values and runs the strict
validator on the full map so submit with an empty body still
passes when everything was auto-saved).
- FormValueService backs the request layer up with deeper enforcement
of validation_rules JSON (min/max/regex) + is_unique. Throws
FieldValidationException (422) which renders via the D6 envelope.
PublicFormTokenResolver centralises the grace-window logic; every
public endpoint resolves through it so the standardised exceptions
bubble uniformly.
Routes: 6 total under /public/forms/ (up from 2). Tests:
PublicFormApiTest's existing submit test retrofitted to the three-step
flow; 857 tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D1, D5, D7.
PublicFormSchemaResource:
- Carries available_tags on every TAG_PICKER field, respecting
validation_rules.tag_categories when set. Tags prefetched once per
org to avoid N+1.
- Surfaces the schema version and an opened_at timestamp so the portal
can detect drift between GET-time and submit-time.
PublicFormSubmissionResource (new): the submission envelope the portal
sees on every lifecycle endpoint. No PII echo — public_submitter_name,
public_submitter_email, public_submitter_ip never appear. Admin
metadata (review_status, reviewed_by, schema_snapshot, submitter user
id) is filtered out. Includes schema_drift computed from
schema.version vs schema_version_at_submit, and identity_match read
from the §31.1 listener's column with a Dutch message per state
(pending|matched|none).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D6. Seven concrete exceptions over a shared PublicFormApiException
base + a single renderer in bootstrap/app.php produce the contract:
{ "message": "...", "code": "...", "errors"?: {...} }
Codes: SCHEMA_NOT_FOUND (404), TOKEN_EXPIRED (410), TOKEN_REVOKED (410),
SCHEMA_UNPUBLISHED (410), SUBMISSION_ALREADY_SUBMITTED (409),
RATE_LIMITED (429 with Retry-After header), VALIDATION_FAILED (422
with per-field errors).
Used by PublicFormController (resolve) and PublicFormSubmissionController
(load/submit lifecycle). Every public-form endpoint now emits the same
envelope regardless of which branch failed; the renderer only fires on
PublicFormApiException so the authenticated API still uses its default
Laravel shapes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D9. Implements ARCH §31.1 — identity matching triggered on
FormSubmissionSubmitted for event_registration schemas.
- Migration 2026_04_22_100000: add form_submissions.identity_match_status
(nullable string(20), pending|matched|none) + index
(form_schema_id, identity_match_status).
- Migration 2026_04_22_100001: replace the composite index on
(form_schema_id, idempotency_key) with a UNIQUE constraint so the DB
itself is the race-safe backstop behind the application-level
idempotency replay.
- Listener TriggerPersonIdentityMatchOnFormSubmit: runs only when
form_schema.purpose === event_registration. For person-subject
submissions it calls PersonIdentityService::detectMatches and writes
matched/pending/none; for public (subject=null) it records 'pending'
so the portal can message the submitter that matching will complete
when the organiser attaches a person. Failures log at error level
and never rethrow — sibling listeners on the same event (§31.10
TAG_PICKER sync) still run.
- AppServiceProvider wires the listener alongside
SyncTagPickerSelectionsOnSubmit.
- FormSubmission.$fillable gains identity_match_status.
Rationale for a dedicated column (over JSON on submission.metadata):
the matrix is a hard-typed 3-state enum that the public API surfaces
directly, and we want to index it to show organiser dashboards "how
many submissions are pending identity-confirmation".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprint 0.5. Extends FormBuilderDevSeeder (additive) so that after
`migrate:fresh --seed` the dev org has:
- one published public-token-enabled event_registration schema anchored
to the primary festival (Echt Feesten 2026) with a curated 5-field
set (HEADING / SELECT / CHECKBOX_LIST / TAG_PICKER / TEXTAREA) —
mirrors the subset Bert needs to eyeball via the portal and verify
§31.10 sync with;
- one draft submission (partial fill: shirtmaat + dieetwensen) for the
first approved person with user_id — the TAG_PICKER is deliberately
absent so this submission does NOT fire the listener;
- one submitted submission for the next approved person, with
TAG_PICKER values = the first 3 active person_tags by sort_order.
The submission is pushed through FormSubmissionService::submit so
FormSubmissionSubmitted fires, SyncTagPickerSelectionsOnSubmit runs,
and user_organisation_tags receives 3 self_reported rows.
Queue-connection contract: production runs QUEUE_CONNECTION=redis, so
the listener would queue and not execute before the seeder returns.
The seeder temporarily flips queue.default to sync for the submit()
call so Bert sees the synced tags immediately after `--seed`.
Console output matches the Sprint 0.5 spec: public URL for GET-testing
+ a line naming the submitter and the sync result count.
Wired from DevSeeder::seedEchtFeesten() behind an
app()->environment('local', 'testing', 'development') guard (belt-and-
suspenders on top of DatabaseSeeder's existing local gate).
Collateral fix: FormSubmissionService::submit() stored signed fractional
seconds into the unsigned `submission_duration_seconds` column. Carbon
3's diffInSeconds returns signed floats when `opened_at` is earlier than
now, which MySQL rejects. Wrapped with abs() + int cast. No test
expectations relied on the sign so 857 tests remain green.
Verified via tinker after `migrate:fresh --seed`:
fields_count = 5, submissions_count = 2 (1 draft + 1 submitted),
values on submitted = 4, self_reported tags for submitter = 3,
PublicFormSchemaResource returns all 5 fields on the public token.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of S2b. Ten thin controllers plus route registration under the
existing organisations/{organisation} prefix and two unauthenticated
public endpoints.
Controllers (api/app/Http/Controllers/Api/V1/FormBuilder/):
- FormSchemaController: CRUD + duplicate/publish/unpublish/rotate-token/
edit-lock. Returns 410 via PublicFormController when a rotated token is
past its 7-day grace window.
- FormFieldController: CRUD + reorder + insert-from-library. 422 on
binding-change / frozen / cyclic conditional_logic.
- FormSubmissionController: index/store/show/submit/destroy.
- FormValueController: bulk upsert draft values; 403 when
FieldAccessService rejects a write.
- FormSubmissionReviewController, FormSubmissionDelegationController.
- FormTemplateController, FormFieldLibraryController (deactivate on
DELETE for is_active records).
- FormSchemaWebhookController (url/secret never leak — only url_host +
has_secret in responses).
- FilterRegistryController: cached entity_column + tags + form_field
source list for Personen-module (ARCH §7.3–§7.5).
- PublicFormController: GET schema + POST submission. Turnstile captcha
for public_complaint/public_press_request. Rate-limited per
IP+public_token. 410 when token expired.
Routes: grouped under organisations/{organisation}/forms/ for auth'd
routes and public/forms/{public_token}/... with throttle:30,1 for the
public pair. Policies auto-discovered from the namespaced location.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of S2b. Nine resources that shape the universal form builder
responses. FieldAccessService::filterVisibleFields gates every field
array — the primary defence tested by FormResourceSecurityTest (§22.9).
- FormSchemaResource: includes fields_count, submissions_count,
has_submissions, is_locked (derived from edit_lock_*), public_form_url
when public_token is set, and filtered fields collection.
- FormSchemaSummaryResource: lean list-endpoint variant.
- FormFieldResource: effective_label / help_text / options resolved via
FormLocaleResolver + translations JSON, plus TAG_PICKER available_tags
filtered by validation_rules.tag_categories.
- FormSubmissionResource: values keyed by field slug with FieldAccessService
filtering, section_statuses, active delegations, review_info,
submitted_in_locale, submission_duration_seconds.
- FormSubmissionSummaryResource: lean list variant.
- FormTemplateResource, FormFieldLibraryResource.
- PublicFormSchemaResource: strictly limited per §10 — only
is_portal_visible=true AND is_admin_only=false fields, no PII hints,
no role_restrictions, no submissions_count.
- FormSchemaWebhookResource: url/secret never returned; only url_host +
has_secret boolean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of S2b. Six policies and fifteen form requests for the universal
form builder. Every exists: rule is scoped to the route's organisation
or form_schema to close the A01-5..18 findings from SECURITY_AUDIT.md.
Policies (api/app/Policies/FormBuilder/):
- FormSchemaPolicy, FormFieldPolicy, FormFieldLibraryPolicy,
FormTemplatePolicy, FormSubmissionPolicy, FormSchemaWebhookPolicy.
- FormSubmissionPolicy honours subject-self (user / person.user_id
match / submitted_by_user_id) and active delegations, per §18.3.
- No `return true` placeholders — each method checks org membership and
role via Spatie's hasRole().
Form Requests (api/app/Http/Requests/Api/V1/FormBuilder/):
- Schema: Store/UpdateFormSchemaRequest, RotatePublicTokenRequest.
- Fields: Store/UpdateFormFieldRequest, ReorderFormFieldsRequest (field
ids scoped to the route schema), InsertLibraryFieldRequest (library
scoped to the route organisation).
- Templates: Store/UpdateFormTemplateRequest.
- Field library: Store/UpdateFormFieldLibraryRequest.
- Submissions: CreateFormSubmissionRequest, UpsertFormValuesRequest
(slug allow-list derived from schema), SubmitFormSubmissionRequest,
ReviewFormSubmissionRequest, DelegateFormSubmissionRequest (delegatee
scoped to organisation pivot).
- Webhooks: Store/UpdateFormSchemaWebhookRequest.
- Public: PublicSubmissionRequest (captcha_token collected here,
enforcement in controller per config('form_builder.captcha')).
All enum validation routes through the existing PHP enums from S1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebuilds the tag-sync flow purged in S2a, now listener-driven against the
universal FormBuilder (ARCH §31.10).
- SyncTagPickerSelectionsOnSubmit listener: ShouldQueue on connection=redis
queue=default. Filters to event_registration + person subjects with at
least one TAG_PICKER form_value. Logs on failure, never rethrows so
sibling listeners keep running.
- AppServiceProvider registers the listener via Event::listen alongside
the existing S1 observers.
- PersonIdentityService::confirmMatch now calls
FormTagSyncService::rebuildForPerson after setting person.user_id — the
deferred-sync path for persons who filled in TAG_PICKER fields before
their account was linked.
- ARCH-FORM-BUILDER.md §31.10 rewritten with the authoritative contract
block from this session. Header bumped to v1.2.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UserProfileTest: belongs-to user, fillable/non-fillable boundaries,
settings cast, lastSubmittedAt accessor (null + max from user-subject
submissions only, ignoring drafts and is_test rows).
FormSchemaTest: ULID PK, OrganisationScope filtering, polymorphic owner
resolution to Event, purpose enum cast, hasMany fields/submissions, and
logSchemaChange() actually creates an activity-log entry.
FormFieldTest: belongs-to schema, field_type stored as string (not DB
enum), binding/translations array casts, hasMany values, soft-delete
preserves historical values, logFieldChange() creates an entry.
FormSubmissionTest: belongs-to schema, polymorphic subject resolution,
status enum cast, schema_snapshot array cast, hasMany values.
FormValueTest: belongs-to submission/field, value array cast, hasMany
options pivot rebuilt by observer, unique-pair DB constraint enforced.
MultiTenancyTest: OrganisationScope correctly filters FormSchema /
FormTemplate / FormFieldLibrary by route-resolved organisation. Pins
the FormSchemaWebhook un-scoped behaviour explicitly so a future scope
addition is an intentional decision, not an accident.
MigrationRollbackTest (group 'slow'): full migrate:fresh → rollback 14
S1 steps → assert all 13 form-builder tables dropped + legacy tables
intentionally retained → re-migrate and assert table list matches
snapshot. Plus a separate test exercising the populate-user-profiles
migration's down().
Supporting tweaks:
- UserProfile::lastSubmittedAt accessor now returns Carbon|null instead
of a raw timestamp string — testable, and matches Eloquent convention.
- UserProfileFactory cooperates with UserObserver via newModel override
(updates the auto-created row instead of inserting a duplicate).
- AppServiceProvider morph map extended with all 12 form-builder model
keys so logSchemaChange/logFieldChange resolve under enforceMorphMap.
Suite: 945 passed (was 911), 2671 assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FormValueObserver: value_indexed is filter-driven per ARCH §4.4, not
hint-driven. Populating it for every string-hint field produced dead
weight in the partial index and made FilterQueryBuilder logic murkier.
Behaviour after fix:
hint=string, is_filterable=true → populate value_indexed
hint=string, is_filterable=false → leave null
hint=number/date/bool, any filterable → populate typed column (unchanged)
hint=json, any filterable → leave typed columns null (unchanged)
value_number / value_date / value_bool remain hint-driven — they serve
display and sorting beyond filtering. Only value_indexed is gated.
VerifyFormsDataIntegrity: "value_indexed set on non-filterable field"
is now a FAIL (was WARN) — it means the observer didn't run correctly,
which is a real integrity issue.
Observer tests: split the old "string hint populates value_indexed"
case into filterable/non-filterable pair. Full suite 911/911.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds UserObserver::created() that firstOrCreate's a user_profiles row
for every User. Registered in AppServiceProvider alongside PersonObserver.
Covers DevSeeder (3 scattered User::create sites: DatabaseSeeder super admin,
DevSeeder org staff, DevSeeder volunteer users) and all future creation
paths (invite/register/import) with zero per-caller boilerplate.
New FormBuilderDevSeeder seeder class holds canonical 16-field registration
template (borrowed from the legacy RegistrationFieldTemplateService list so
test data stays recognisable). Produces per-org:
- 16 form_templates (system, schema_snapshot per ARCH §4.6.1)
- 1 FormSchema per event (event_registration, owner=event, draft_single
mode, is_published mirrors event.status lifecycle)
- 16 FormFields per schema
- 1 FormSubmission per person whose status ∈ applied/approved/no_show
(same rule as MigrateLegacyFormsData), with 6 realistic FormValues each
DevSeeder::run() now wraps the whole seed body in
ActivityLog::suppressed(...) so the ~80 field creates + ~277 submission
lifecycle triggers don't flood activity_log. Also removes the legacy
RegistrationFieldTemplateService::seedSystemTemplates call — the 16
system templates now land directly in form_templates.
Post-seed totals (dev DB):
5 form_schemas, 80 form_fields, 277 form_submissions, 1662 form_values,
16 form_templates, 270 user_profiles (1:1 with users).
forms:verify-data-integrity on freshly seeded DB: exit 0.
php artisan test: 910/910.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
forms:migrate-legacy-data {--dry-run} {--verify-only}
Per-org transaction (outer loop); inside each org, one form_schema per
distinct event_id in registration_form_fields, one form_field per legacy
field (with lowercase→uppercase field_type mapping and PII heuristic),
one form_submission per distinct person_field_values author, one form_value
per legacy row. form_templates derive schema_snapshot in ARCH §4.6.1 shape.
Idempotent via existence checks; skips if registration_form_fields absent.
Wrapped in App\Support\ActivityLog::suppressed() so --dry-run and re-runs
don't storm the activity log.
forms:verify-data-integrity {--strict}
Nine coherence checks: schemas/fields/submissions/values/user_profiles
structure, data migration counts (skipped when legacy tables absent),
orphans, section/schema relation consistency, and strict reachability
(opt-in). Runs all checks to completion; exit 1 on any failure.
Validates binding JSON against config/form_binding.php registry and
field_type against FormFieldType::values() ∪ custom_field_types config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of S1.
Models (app/Models/FormBuilder/): FormSchema, FormSchemaSection, FormField,
FormSubmission, FormValue, FormValueOption, FormTemplate, FormFieldLibrary,
FormSchemaWebhook, FormWebhookDelivery, FormSubmissionSectionStatus,
FormSubmissionDelegation. Plus UserProfile at app/Models/ (user-universal).
OrganisationScope applied on: FormSchema, FormTemplate, FormFieldLibrary.
FormSchemaWebhook documents inherited-scope discipline (OrganisationScope's
strategies — organisation_id/event_id/festival_section_id — don't cover
form_schema_id; direct queries would leak across orgs, so must go via
$schema->webhooks()).
User::profile()/getOrCreateProfile(), Event::formSchemas() (morphMany),
Person::formSubmissions() (morphMany).
Morph map enforced in AppServiceProvider with 28 keys covering every model
that appears as activitylog subject/causer. Also updated
OrganisationDashboardService (and its test) to query activitylog via
getMorphClass() instead of FQCN.
Activity log strategy: nuanced explicit calls (logSchemaChange on FormSchema,
logFieldChange on FormField) — no LogsActivity trait. Suppression for bulk
fixtures via App\Support\ActivityLog::suppressed(fn() => ...) which flips
config('activitylog.enabled') around a callback. Both our explicit calls
and spatie's trait on Organisation respect the flag via ActivityLogger::log().
FormValueObserver (app/Observers/FormBuilder/) populates value_indexed/
value_number/value_date/value_bool on save per field.value_storage_hint,
rebuilds form_value_options pivot on multi-value filterable fields, cleans
up on delete. Memoised field cache avoids N+1. Registered in AppServiceProvider.
9 lightweight event classes (app/Events/FormBuilder/) as SerializesModels
containers — submission lifecycle signatures lock in for S2 services, no
listeners yet.
Factories for all models with Dutch fake data (fake('nl_NL')). FormSchema
factory uses defaultSubmissionMode(); FormField factory uses
recommendedValueStorageHint().
Tests: 9 new observer tests (all pass); full suite 910/910 (up from 901).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 fresh tables per ARCH §4 (v1.2): user_profiles + 13 form_* tables.
ULID PKs on domain rows, integer AI on heavy-join EAV tables (form_values,
form_value_options). All FKs indexed, every constraint named explicitly.
FULLTEXT on form_submissions.search_index is best-effort (wrapped try/catch
so SQLite test runs still apply).
Notes:
- Partial unique indexes on public_token/custom_purpose_slug traded for
regular indexes + application-level uniqueness (MySQL limitation).
- (form_schema_id, slug) on form_fields is a regular index to avoid
soft-delete + re-create collisions; uniqueness enforced in service.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9 backed string enums covering purpose, field type, submission status/mode/review,
field width, value storage hint, snapshot mode, webhook delivery status.
FormPurpose/FormFieldType include helper methods per ARCH §3/§5. All with
declare(strict_types=1) and values() helpers for validation rules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /organisations/{organisation}/dashboard-stats returns members,
events (with status breakdown + active count), persons, the first five
members sorted by join date, and the five most recent activity log
entries. Business logic lives in OrganisationDashboardService; access
follows OrganisationPolicy@view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add spatie/laravel-activitylog LogsActivity trait tracking per-field
dirty changes on name, slug, contact_name, contact_email, phone, and
website. Log name "organisation", skip empty logs. Used by the dashboard
recent-activity feed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add contact_name, contact_email, phone, website columns. Wire the new
fields through the Organisation model, update request validation,
response resource, and the TypeScript Organisation interface. Needed by
the upcoming dashboard + form-builder binding registry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DevSeeder was creating approved persons without linked User
accounts, which can't happen in production (approval flow always
creates accounts). Added linkUsersToApprovedPersons() helper that
runs after person creation in each event seeder, creating User
accounts via firstOrCreate for approved and no_show persons that
lack user_id.
Also added safeguard tests verifying the approval flow creates
user accounts and reuses existing ones.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Missing from UpdateEventRequest rules, so the fields were stripped from
validated() and the uploaded URLs never persisted — the preview showed
briefly in the upload component but disappeared on reload because the
event record still had null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restructure field editor dialog: move Options section to bottom with
divider and subheader, fix delete button with flex layout
- Change tag_category (single string) to tag_categories (JSON array)
supporting multiple category selection in tag picker fields
- Portal tag picker now groups tags by category with subheaders
- Add generic file upload endpoint (FileUploadService + UploadController)
- Replace email branding logo URL text field with ImageUploadField
- Update Partner crowd type default icon to tabler-affiliate
- Apply changes consistently to both field and template dialogs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the per-field `section` text property with a dedicated HEADING field type that
organizers add as a separate block for visual grouping. Also fixes duplicate heading bug
on portal radio fields, replaces cramped VBtnToggle with VSelect for field width, and
adds grouped field type dropdown with structure/input categories.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add configurable column widths (full/half) and optional descriptions
for radio/select/checkbox options on registration form fields.
- Migration adds display_width column to both tables
- FieldDisplayWidth enum with smart defaults per field type
- normalized_options accessor for backwards-compatible option format
- Portal form renderer uses display_width for VRow/VCol grid layout
- Radio/select/checkbox options render with descriptions
- Admin field editor supports display_width toggle and description input
- System templates updated with appropriate widths and descriptions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removes password from the volunteer registration form. Account
creation is now deferred to the approval step:
Backend:
- Registration creates Person without User (user_id=null)
- On approval, system finds or creates User by person.email
- New accounts get a "set password" email with activation link
- Existing accounts get a portal link email
- Added registration_source column to persons (self/organizer)
- Fuzzy name matching skipped for self-registered persons
- person.email is always source of truth for account linking
Frontend:
- Registration form no longer collects password
- Email check shows info alert with login suggestion
- New wachtwoord-instellen.vue page for account activation
- PasswordRequirements.vue component (reused on reset page)
- Success page updated with activation messaging
Tests: 837 passed (all updated for new flow)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The portal store merged events from the API with localStorage events
without ever pruning stale entries. When /auth/me returned empty
portal_events (e.g. after a person's user_id was cleared), localStorage
events persisted, causing "registratie niet ophalen" when /portal/me
correctly returned 404.
Now when /auth/me succeeds, API data is the source of truth — stored
events not confirmed by the API are dropped. localStorage fallback is
only used when the API call fails (network error).
Also adds an end-to-end test covering the full register → approve →
portal/me flow including festival hierarchy.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Banner: white elevated button for contrast, fixed 48px height,
layout top padding offset so content isn't obscured
- Middleware: allow GET me/profile (viewing), block mutations only;
add auth/refresh to blocked routes
- Navigation: hide Platform section during impersonation; hide
org-dependent items when impersonated user has no organisation
- Test: add read-only routes allowed test, auth/refresh blocked test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SECURITY: A user with MFA enabled could bypass the MFA challenge by
using a pre-existing auth cookie from a previous session.
Vulnerability chain:
1. Auth::attempt() in LoginController created a Laravel session
(unnecessary side effect — only credential validation was needed)
2. When MFA was required, the response did NOT revoke existing
Sanctum tokens or expire the auth cookie
3. If the MFA session expired, the user could navigate directly to
any page and the old auth cookie would authenticate them
Fixes:
- Replace Auth::attempt() with Hash::check() — no session created
- Revoke ALL existing Sanctum tokens when MFA is required, so old
sessions cannot bypass the challenge
- Expire the auth cookie in the MFA-required response via
forgetAuthCookie(), ensuring the browser discards stale tokens
- Auth is now ONLY issued after successful MFA verification in
MfaVerifyController
New security tests (11 added):
- MFA login returns no auth token or user data
- MFA login expires the auth cookie
- MFA login revokes all existing tokens
- Old token returns 401 after MFA login
- MFA session token cannot be used as Bearer token
- MFA session consumed after successful verify (no replay)
- MFA session survives failed verify (user can retry)
- Auth cookie only issued on successful MFA verify
- MFA session expires after TTL (10 minutes)
- Email codes consumed after use (no replay)
- Trusted device expires after 30 days
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When MFA was already enabled and the user clicked "Opnieuw instellen"
on the TOTP card, setupTotp() unconditionally set mfa_confirmed_at to
null. If the user then cancelled the dialog without confirming, the
login controller's check `mfa_enabled && mfa_confirmed_at` evaluated
to false (true && null), silently skipping the MFA challenge.
Fix: only set mfa_method and mfa_confirmed_at when MFA is not yet
enabled (first-time setup). For re-setup or adding TOTP as a second
method, only rotate the mfa_secret — matching the guard already
applied to setupEmail().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the ability for users to change their preferred/primary MFA method
when both TOTP and email are available.
Backend:
- Add PUT /auth/mfa/preferred-method endpoint with validation
(method must be totp/email, MFA must be enabled, TOTP must be
configured if selecting totp)
- Add totp_configured and email_configured fields to MFA status
endpoint (totp = has secret + enabled, email = always when enabled)
- Fix setupEmail() to preserve mfa_secret so TOTP config survives
when email is set up as a second method
Frontend (organizer + portal):
- Add useSetPreferredMethod() composable to useMfa.ts
- Add totp_configured/email_configured to MfaStatus type
- SecurityTab method cards now show "Primaire methode" chip on the
preferred method and "Als primair instellen" button on the other
- Portal security section shows per-method rows with status chips
and primary switching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: the MFA status endpoint returned `mfa_enabled` as the JSON
key but the TypeScript MfaStatus interface expected `enabled`. At
runtime, `mfaStatus.value?.enabled` was always `undefined`, so
`isEnabled` was always false — the banner never hid and the method
cards never showed "Geconfigureerd".
Additionally, the auth store had no way to re-fetch /auth/me after
initialization, so `mfaSetupRequired` was never properly refreshed
from the backend after MFA setup.
Fixes:
- Rename `mfa_enabled` → `enabled` in the MFA status endpoint response
to match the TypeScript type (and the /auth/me MeResource which
already used `enabled`)
- Add `refreshUser()` to the auth store for post-initialization
re-fetching of /auth/me
- Call `refreshUser()` in onSetupCompleted so the store reflects the
backend state without a full page reload
- Update backend tests to match the renamed response key
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three verification methods (TOTP authenticator, email code, backup codes),
trusted device management with 30-day expiry, role-based enforcement for
super_admin and org_admin, admin reset capability, and full test coverage
(46 tests). Modifies login flow to support MFA challenge/response with
temporary session tokens stored in cache.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the full transactional email system:
- Redis queue (QUEUE_CONNECTION=redis), SES config in .env.example
- 3 migrations: organisation_email_settings, organisation_email_templates, email_logs
- EmailTemplateType and EmailLogStatus enums with Dutch defaults
- EmailService as central entry point for all email sending
- SendTransactionalEmail queued job with retries and idempotency
- TransactionalMail mailable with responsive HTML + plain text templates
- Organisation-level branding (colors, logo, footer, reply-to)
- Per-type template overrides with {variable} substitution
- Email log with filtering by status, type, date range, recipient
- Preview and send-test endpoints for template management
- API endpoints: email-settings, email-templates (CRUD), email-logs (read-only)
- Integrated into existing flows: invitations, password reset, email
verification, registration approval/rejection
- 37 new tests across 4 test files, all existing tests updated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add member management to the platform admin organisation detail page:
- Backend: invite (creates invitation or directly adds existing user),
remove member, update member role endpoints on AdminOrganisationController
- Backend: show endpoint now returns members alongside organisation data
- Frontend: members table with inline role editing, invite dialog,
remove confirmation dialog on /platform/organisations/[id]
- Tests: 7 new tests covering happy paths and edge cases (self-removal,
existing member, non-super_admin denied)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LoginController used UserResource (returns `roles`) but the frontend
authStore.setUser() expects MeResponse format with `app_roles`. After
login, appRoles was set to undefined, making isSuperAdmin always false.
Combined with isInitialized staying true after the initial failed
/auth/me call, the correct /auth/me was never re-fetched after login.
Fix: use MeResource in LoginController (same as MeController) so the
login response includes app_roles, permissions, and portal_events.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Restructure the festival hierarchy end-to-end:
Seeder: Remove duplicate festival-level VOLUNTEER time slots, keep only CREW
operational slots. Rename sub-events to "Dag 1/2/3 — ..." pattern. Change
Nachtsecurity to Security (cross_event). EHBO/Security shifts now use sub-event
time slots via cross_event exception. Add flat event "Braderie Dorpstown 2026".
API: Add ?include_children=true to TimeSlotController for festivals, returning
all sub-event time slots with source and event_name fields. Update
StoreShiftRequest and UpdateShiftRequest to accept child time slots for
cross_event sections.
Frontend: Create useTimeSlotDropdown composable with 4-scenario dropdown logic.
Replace AppSelect with VAutocomplete in CreateShiftDialog with grouped items,
dimmed festival slots, and info tooltips. Add InfoTooltip reusable component.
Show festival context labels on cross_event sections in sub-event section lists.
Add read-only festival time slots on sub-event time-slots page. Add cross_event
context banner with "Bekijk alle diensten" link.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EHBO and Accreditatiebalie shifts referenced sub-event time slots
(Vrijdag Early, Zaterdag Dag, etc.) but these cross_event sections
belong to the parent festival. The dropdown fetches time slots for
the festival event, so the sub-event time slot IDs had no matching
items — causing raw ULIDs in the dropdown and validation failures.
Added festival-level VOLUNTEER time slots mirroring the sub-event
schedule, and pointed cross_event section shifts at those instead.
Verified: all 81 shifts now reference time slots from the same event
as their section (zero mismatches).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The time slot dropdown in the shift edit dialog could flash the
"create a time slot first" alert during loading, and show raw ULIDs
when time slot data hadn't loaded yet. Fixed by:
- Adding loading state indicator to the time slot dropdown
- Using the shift's existing time_slot object as a fallback item
while the full list is fetching
- Showing the dropdown (with loading spinner) instead of the
misleading "no time slots" alert during fetch
Added test coverage for time_slot_id validation on shift updates:
- Update with valid same-event time slot (200)
- Update with cross-org time slot (422)
- Update on sub-event with parent festival time slot (200)
- Store/update responses include nested time_slot object
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>