SCHEMA.md
- New §3.5.12 "Form Builder" with the legacy-tables-retained note
placed prominently directly under the section header (per S1 wrap-up
Path 3 decision: Phase 8 deferred to S2).
- Crosswalk: every legacy volunteer_profiles column → its new home
(user_profiles columns vs form_fields vs person_tags).
- Summary table for the 13 new tables with one-line purpose + ARCH §
pointer each.
- Activity log strategy and multi-tenancy discipline noted.
- §3.5.4 marked SUPERSEDED with a pointer to the new section.
/dev-docs/form-builder-migration-playbook.md (new)
- Operator runbook for forms:migrate-legacy-data on real legacy data.
- Pre-flight audit, dry-run, migrate, verify, spot-check, rollback
paths spelled out. Same legacy-tables-retained note prominently.
/dev-docs/form-builder-getting-started.md (new)
- Developer onboarding. Mental model, code samples for creating a
schema/field/submission/value, adding a new subject type, registering
a custom field type, suppressing activity log via
App\Support\ActivityLog::suppressed.
/dev-docs/COPY_CATALOGUE.md (new)
- Seeded verbatim from ARCH §30 (naming conventions, tooltip catalogue,
warning catalogue) with a header explaining purpose, growth strategy,
and the per-PR update workflow.
/docs/organizer/forms/concepts/wat-is-een-formulier.md (new VitePress)
- Dutch, informal je/jij. Follows /docs/.templates/concept-page.md.
- Three example use-cases: vrijwilligersregistratie, artist advance,
incidentrapportage. Light foundation; depth arrives in S2-S5.
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>
Add OrganisationMember (met avatar + joined_at), ActivityLogEntry en
OrganisationDashboardStats interfaces die door de pagina en composable
worden gebruikt. Deze hoorden in de dashboard-commit maar vielen uit
door een staging-split.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nieuwe VitePress pagina beschrijft het uitgebreide /organisation dashboard
(stat-tegels, organisatiegegevens bewerken, leden-top-5 en activity log)
en verduidelijkt dat "Organisatie verwijderen" nog via de platform
beheerder loopt en nu onder Instellingen > Gevaarlijke acties te vinden
is. Sidebar entry toegevoegd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vervang het naam-alleen dialoog door een volledig organisatiegegevens-
formulier: naam, slug (met copy-knop en tooltip), contactpersoon, contact
e-mail, telefoon en website. Slug krijgt een regex-validator; e-mail en
URL alleen gevalideerd wanneer ingevuld. Server-side validatiefouten per
veld getoond.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the minimal placeholder with a dashboard: header + edit action,
drie stat-tegels (Leden / Evenementen / Personen — de eerste twee
clickable), organisatiegegevens + leden-top-5 infokaarten en een recente-
activiteit lijst. Nieuwe TypeScript-types en useOrganisationDashboardStats
composable sluiten aan op de nieuwe backend-endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the Algemeen tab together with the Organisatie subheader — organisatie-
gegevens verhuizen naar /organisation. Voeg een GEVAARLIJK-subheader toe met
een Gevaarlijke acties tab, die de bestaande platform-beheerder-notitie bevat
(self-delete blijft buiten scope). Legacy ?tab=algemeen/general redirects
door naar /organisation; default tab valt terug op Crowd Types.
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>
- EventMetricCards: type navigateTo's routeName as the literal union
of the two routes it actually targets (events-id-persons,
events-id-sections) so the typed router accepts it.
- CreateTimeSlotDialog: type the form ref explicitly so person_type
is PersonType rather than being inferred as string.
- @layouts/types.ts: relax LayoutConfig.app.title from Lowercase<string>
to string. The lowercase constraint was a compile-time namespacing
convention in the Vuexy template with zero runtime effect;
relaxing it lets the branded "Crewli" title satisfy the type.
- useMembers.ts gains a scope param ('organisation' | 'platform') on list,
invite, update-role, and remove; endpoints branch accordingly.
- Platform Admin's [id].vue now consumes useMembers via scope='platform';
deleted the duplicated useInviteOrganisationMember / useRemoveOrganisationMember
/ useUpdateOrganisationMemberRole helpers from useAdmin.ts.
- Deduplicated InviteMemberPayload / UpdateMemberRolePayload / AdminOrganisationMember
from types/admin.ts; Member is now the canonical type.
- SettingsMembers.vue and EditMemberRoleDialog.vue removed (no remaining imports).
- InviteMemberDialog accepts an optional scope prop and is restricted to the
two organisation-level roles matching the /members UX.
Captures the approved UX specification for the festival hierarchy:
four dropdown scenarios (standard sub-event, cross_event section,
flat event, location-based), context-preservation on navigation,
and info-tooltip placement rules. This document has been referenced
in implementation work since April but was never committed.
- Wrap filter row so controls flow to a second line on narrow screens
- Search field now flex-fills available width instead of fixed 300px
- Type select: removed inline label, widened to 240px, prevented
shrink with flex-shrink-0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Login forms correctly only check for empty fields (no password
constraints needed). But password-reset, password-set, and
password-change forms now enforce constraints client-side:
- App reset-password: add PasswordRequirements component,
confirmation mismatch check, canSubmit guard, disabled button
- Portal wachtwoord-resetten: add canSubmit guard, confirmation
check, disabled button (PasswordRequirements was rendered but
not enforced)
- App SecurityTab (change password): replace static requirements
list with interactive PasswordRequirements, add canSubmit guard
Also created PasswordRequirements.vue component for the organizer
app (portal already had one).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The catch-all error handler (for anti-email-enumeration) was also
swallowing 422 validation errors, making it appear that a reset
email was sent even for empty or invalid input. Now 422 responses
are excluded from the catch — the user stays on the form so the
field-level validation messages remain visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both the organizer app and portal forgot-password pages now
validate the email field before submission: required + email
format check. Backend already validated this, but empty or
malformed emails were being sent to the API unnecessarily.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add requiredValidator and emailValidator rules to the portal login
form, matching the organizer app login. Empty fields and invalid
email format are now caught before the API call.
Co-Authored-By: Claude Opus 4.6 (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>
- Replace card-based multi-line options with compact single-line rows
(grip + label + description + delete all on one row)
- Standardize event registration appearance page on ImageUploadField
(was VFileInput + manual preview, now consistent with email branding)
- Fix EmailBrandingTab logoUrl ref to properly handle null from
ImageUploadField, ensuring existing image preview works on page load
Co-Authored-By: Claude Opus 4.6 (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>
Replace position:fixed VSystemBar + fragile :deep() CSS overrides
with a normal-flow div inside the Vuexy content area. The banner
renders in VerticalNavLayout's default slot (layout-page-content)
so it sits naturally below the navbar without fighting the layout
system. Sidebar and navbar are no longer affected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous :deep() overrides had equal specificity to Vuexy's
unscoped styles in VerticalNavLayout.vue. Since child component
styles are injected after parent styles, Vuexy's inset-block-start: 0
won by source order. Add !important and simplify the navbar selector
to target .layout-navbar directly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous paddingTop on a wrapper div didn't affect the Vuexy
layout's fixed-position sidebar or sticky navbar. Replace with
scoped :deep() CSS overrides that shift both elements down 48px
when impersonation is active.
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>
Replace horizontal tabs with VList-based vertical sidebar following the
Vuexy ecommerce settings pattern. Consolidate Tags, Crowd Types, Members,
and Registration Fields pages into the settings page as sidebar tabs.
Add SettingsGeneral panel with org details form and danger zone.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After successful MFA code verification, onMfaVerified() called
authStore.initialize() which returned immediately (isInitialized
was already true from the initial page load). The auth store was
never populated with user data, so the router guard saw
isAuthenticated === false and redirected back to /login — leaving
the user stuck on the MFA challenge screen with a consumed session.
Fix: use authStore.refreshUser() instead of initialize(). This
always calls GET /auth/me (using the new auth cookie from the MFA
verify response), populates the store, and then navigation to the
dashboard succeeds.
The portal login already uses authStore.fetchUser() which has no
isInitialized guard, so it was not affected.
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>