- Seed AVAILABILITY_PICKER and SECTION_PRIORITY demo fields in the
event_registration showcase, and augment seedEchtFeesten with a
parent-level VOLUNTEER time slot pair + a standard registration-
visible section whose name duplicates a child section so the
PublicFormController dedup path is exercised end-to-end.
- Validate SECTION_PRIORITY value shape in FormValueService: arrays of
{ section_id, priority } with unique section_ids + priorities in 1..5,
max 5 entries, and section_ids scoped to the schema's event tree
(parent + children). Error envelope is the standard VALIDATION_FAILED
FieldValidationException shape so the portal renders errors next to
the field.
- Enrich admin-facing FormSubmissionResource with a nested identity_match
block mirroring the PublicFormSubmissionResource contract (status only;
leaves room for future matched_user_id / confidence).
- Lock in the FORM-05 stub contract with 6 tests against the existing
TriggerPersonIdentityMatchOnFormSubmit listener (no new listener was
needed — the current one already writes 'pending' for public
event_registration submissions per ARCH §31.1).
- 24 new backend assertions across seeder, shape validation, listener
state matrix, and resource serialisation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five models that the public form endpoints touch carry a global
OrganisationScope: FormSchema, Event, TimeSlot, FestivalSection,
PersonTag. The initial S2c implementation relied on the scope no-opping
because /public/forms/* has no `{organisation}` route parameter and
OrganisationScope::resolveOrganisationId returns null in that case.
That's accidentally-correct. Any middleware that sets an implicit org
context later (route model binding for platform admin, impersonation,
default-org fallback on an authed Sanctum session) would start
filtering public schema resolution by the wrong org.
- PublicFormTokenResolver: both FormSchema::query() calls now pass
withoutGlobalScope(OrganisationScope::class). public_token is
globally unique so this is safe.
- PublicFormController::timeSlots() / sections() / festivalEventIds():
Event, TimeSlot, FestivalSection queries all explicit now, including
the eager-loaded event relation on time-slots.
- PublicFormController::ownerEvent(): narrowed from
Event::withoutGlobalScopes() to withoutGlobalScope(OrganisationScope)
so future scopes (soft-delete, archived) aren't accidentally
stripped.
- PublicFormSchemaResource::availableTagsByCategory: same narrowing on
the PersonTag query.
PublicFormCrossOrgScopeTest pins the expectation — 4 cases hit every
public endpoint under a stashed foreign-org route parameter and assert
the owner-org data still surfaces. Verified the tests fail when the
fix is reverted (all 4 return `SCHEMA_NOT_FOUND` with the bypass
absent).
Full suite 893 → 897 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
S2c D5 completion: schema_version_at_open column + drift semantics.
- Migration 2026_04_22_100002 adds unsignedInteger schema_version_at_open.
Recorded by FormSubmissionService::createDraft at the moment the
portal first renders the form.
- PublicFormSubmissionResource.schema_drift now compares
schema_version_at_open vs schema_version_at_submit (or
schema.version for active drafts) so organiser edits during an
open draft surface as drift on subsequent PUT/submit responses.
- PublicFormSubmissionController::update routes through
FormSubmissionService::saveDraft so auto_save_count increments
and the FormSubmissionDraftUpdated event fires per PUT.
- bootstrap/app.php: FormRequest ValidationException on
/api/v1/public/forms/* is now re-wrapped into the D6 envelope with
code=VALIDATION_FAILED, so public endpoints emit one consistent
error shape regardless of layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
- 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>
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>
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>
The approve() and bulkApprove() methods in ShiftAssignmentService
hard-blocked with a 422 when all slots were filled. This was incorrect
for organiser actions — only volunteer claims (portal self-service)
should enforce capacity limits. Organiser assign() already allowed
overbooking, making the approve block inconsistent.
Changes:
- Remove capacity hard-block from approve() and bulkApprove(), replace
with audit log entry (shift.overbooked_approval)
- Add overbook confirmation dialog in ShiftDetailPanel before approving
a full shift (single + bulk approve)
- Add onError handlers to all mutations in ShiftDetailPanel (approve,
reject, cancel, bulk-approve) so errors display in the snackbar
- Add global 422 validation error display in axios interceptor via
useNotificationStore as safety net for all components
- Add PHPUnit test for approve-when-full scenario
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Password reset: multi-app support with custom notification linking to correct
frontend (app/portal/admin). Email change: self-service with password
confirmation and admin-initiated, both sending verification to new address
with 24h expiry. Confirmation sent to old email on completion. Password
change: authenticated endpoint revoking other sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the full identity matching engine: email matching (HIGH confidence),
fuzzy name matching with Levenshtein distance (MEDIUM confidence, upgradable to
HIGH with DOB tiebreaker), manual link/unlink, revert confirmed matches, and
automatic detection via PersonObserver. Includes 33 comprehensive tests, frontend
integration with confirm/dismiss/unlink UI, and match indicators in the persons list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Token generation:
- Replace Str::ulid() with bin2hex(random_bytes(32)) for 256-bit entropy
- Store SHA-256 hash in database, never plaintext tokens
- Hash input before lookup on all token endpoints
Invitation tokens:
- InvitationService: generate crypto random, store hash, pass plain
token transiently for email URL via UserInvitation::$plainToken
- InvitationController show/accept: hash input before DB lookup
- AcceptInvitationRequest: hash token before invitation lookup
- Migration: widen user_invitations.token and artists.portal_token
from char(26) to char(64) for SHA-256 hex digests
Portal token auth:
- PortalTokenController: remove Schema::hasTable() runtime checks,
hash token before lookup, return shaped response via PortalEventResource
instead of raw model data
- Create PortalEventResource (name, dates, status only — no internals)
- Handle missing production_requests table gracefully via try/catch
Portal token middleware:
- Implement full token validation: extract from Bearer header or ?token=
query param, hash, look up in artists/production_requests, verify
event exists and is not draft/closed, set portal context on request
- Return generic 401 on any failure (no information leakage)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add throttle middleware to login (5/min), portal/token-auth (10/min),
volunteer-register (5/min), and invitation routes (10/min)
- Set Sanctum token expiration to 7 days
- Remove billing_status from UpdateOrganisationRequest (super_admin only)
- Revoke all Sanctum tokens on password reset
- Strengthen password rules: min 8 chars, mixed case, numbers
- Create SecurityHeaders middleware (X-Content-Type-Options, X-Frame-Options,
HSTS, Referrer-Policy, Permissions-Policy)
- Fix open redirect on all 3 login pages (validate ?to= starts with /)
- Set APP_DEBUG=false in .env.example
- Log failed login attempts with email, IP, user-agent
- Log authorization failures (403) with user, IP, path, method
- Harden mass assignment: remove user_id from Person, audit fields from
ShiftAssignment, system fields from UserInvitation $fillable
- Replace real DB records with factory make() in mail preview routes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add POST /public/check-email endpoint with rate limiting (10/min)
- Create user accounts during volunteer registration (new or returning)
- Returning volunteers authenticate with existing password
- Add password validation to VolunteerRegistrationRequest
- Normalize emails to lowercase throughout registration flow
- Handle race condition on duplicate accounts gracefully
- Create RegistrationConfirmationMail, RegistrationApprovedMail, RegistrationRejectedMail
- Wire approval/rejection emails into PersonController
- Add POST persons/{person}/reject endpoint
- Trigger TagSyncService on registration and approval
- Add CheckEmailTest, PersonApprovalEmailTest, extend VolunteerRegistrationTest
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- PublicRegistrationData now returns registration_fields (portal-visible only),
form toggles, and available_tags for tag_picker fields
- Volunteer registration accepts field_values and section_preferences with
festival_section_id, processed via existing services
- PortalMe eager-loads fieldValues and sectionPreferences
- Section preferences now use the proper relational table instead of custom_fields JSON
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move crowd types management to organisation settings as a new tab and
align all three settings tabs (Tags, Registration Field Templates, Crowd
Types) to the same layout pattern: header with title/subtitle, VDataTable
for active items, and a separate inactive section with VList. Also fix
the API to return inactive records for person tags and registration field
templates so the frontend can display them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement EAV system for dynamic event-specific registration fields
with organisation-level templates, person section preferences with
priority ranking, and TagSyncService for deferred tag_picker sync.
New tables: registration_field_templates, registration_form_fields,
person_field_values, person_section_preferences.
New columns: persons.remarks, events.registration_show_section_preferences,
events.registration_show_availability.
58 tests, 126 assertions — all 432 tests pass (zero regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cross-cutting migration affecting the entire stack:
- Database: 3 migrations splitting name columns with data migration
- Models: first_name/last_name on User, Person; contact_first_name/contact_last_name on Company; backward-compatible name accessors
- API: all resources return first_name, last_name, full_name; assignablePersons endpoint updated
- Requests: validation rules updated for all person/user/company forms
- Services: VolunteerRegistrationService, ShiftAssignmentService, InvitationService updated
- Frontend: TypeScript types, Zod schemas, all forms split into Voornaam/Achternaam fields
- Display: all person/user name references use full_name; initials use first_name[0]+last_name[0]
- Tests: all 371 tests passing
- Docs: SCHEMA.md and API.md updated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add cancelled_by, cancellation_source (organiser|volunteer|system), and
cancelled_at columns to shift_assignments. Cancel flow now records who
cancelled and why. Assign flow reactivates existing cancelled/rejected
records instead of creating duplicates, preventing UNIQUE constraint
violations. Assignable-persons endpoint returns previous_assignment data
for contextual UI indicators. Frontend shows cancellation source labels,
previous assignment history in assign dialog, and "Opnieuw toewijzen"
buttons with volunteer-cancelled confirmation dialogs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove capacity and status validation from organizer assign flow so
organizers can intentionally overbook shifts. Log overbooked assignments
for audit trail. Volunteer claims still enforce hard limits. Frontend
shows a warning banner when a shift is full and requires confirmation
before overbooking.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add GET /events/{event}/shifts/{shift}/assignable-persons endpoint that
returns approved persons with availability status, conflict details, and
already-assigned flags. Improve ShiftAssignmentService conflict errors to
include section name, time slot, and time range. Replace both assign
dialogs with a new AssignPersonDialog featuring search, crowd type
filtering, availability toggle, and inline conflict warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add show_in_registration and registration_description columns to festival_sections.
Registration form now shows deduplicated sections by name (across sub-events),
filtered by show_in_registration=true, grouped by category with card-based UI.
Section preferences use section_name instead of section_id.
Add GET/PUT registration-settings endpoints for festival-level bulk management.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the complete ShiftAssignment lifecycle:
- ShiftAssignmentStatus enum with allowed transitions
- ShiftAssignmentService with claim/assign/approve/reject/cancel/bulkApprove
- ShiftAssignmentController with event-scoped endpoints
- ShiftAssignmentPolicy (organizer + volunteer self-cancel)
- VolunteerAvailability model, controller, and sync endpoint
- Refactored ShiftController to delegate to service layer
- 31 workflow tests covering all paths and multi-tenancy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Audit and complete the Crowd Lists module:
- Add CrowdListType enum (internal/external) with proper casts
- Create CrowdListService for business logic (add/remove person,
max_persons enforcement, auto_approve, activity logging)
- Create CrowdListFactory with Dutch names and states
- Create AddPersonToCrowdListRequest form request
- Fix FormRequests to use Rule::enum instead of hardcoded strings
- Fix CrowdListResource to use enum->value and add is_full field
- Refactor controller to be thin (delegates to service)
- Add eager loading for crowdType and recipientCompany
- Write 18 comprehensive tests (CRUD, auth, edge cases)
- Update API.md with request/response documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements enterprise-grade identity resolution (detect → suggest → confirm)
for Person ↔ User linking. Matches are detected automatically on person
creation and user account creation, then surfaced to organisers for explicit
confirmation or dismissal. No silent auto-linking.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>