From 63d08c8bdebc9d6def0671a89e75fe1c76c52feb Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 22:56:20 +0200 Subject: [PATCH] feat(form-builder): public draft/save/submit split + sub-endpoints + validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../V1/FormBuilder/PublicFormController.php | 200 +++++++-------- .../PublicFormSubmissionController.php | 238 ++++++++++++++++++ .../V1/FormBuilder/SavePublicDraftRequest.php | 57 +++++ .../FormBuilder/StartPublicDraftRequest.php | 37 +++ .../SubmitPublicSubmissionRequest.php | 63 +++++ .../FormBuilder/FormFieldRuleBuilder.php | 210 ++++++++++++++++ .../Services/FormBuilder/FormValueService.php | 62 ++++- .../FormBuilder/PublicFormTokenResolver.php | 51 ++++ api/routes/api.php | 19 +- .../Feature/FormBuilder/PublicFormApiTest.php | 21 +- 10 files changed, 846 insertions(+), 112 deletions(-) create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php create mode 100644 api/app/Http/Requests/Api/V1/FormBuilder/SavePublicDraftRequest.php create mode 100644 api/app/Http/Requests/Api/V1/FormBuilder/StartPublicDraftRequest.php create mode 100644 api/app/Http/Requests/Api/V1/FormBuilder/SubmitPublicSubmissionRequest.php create mode 100644 api/app/Services/FormBuilder/FormFieldRuleBuilder.php create mode 100644 api/app/Services/FormBuilder/PublicFormTokenResolver.php diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php index 7186ee7c..08f464a6 100644 --- a/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php @@ -5,142 +5,132 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1\FormBuilder; use App\Http\Controllers\Controller; -use App\Http\Requests\Api\V1\FormBuilder\PublicSubmissionRequest; -use App\Http\Resources\FormBuilder\FormSubmissionResource; use App\Http\Resources\FormBuilder\PublicFormSchemaResource; +use App\Models\Event; +use App\Models\FestivalSection; use App\Models\FormBuilder\FormSchema; -use App\Services\FormBuilder\FormSubmissionService; +use App\Models\TimeSlot; +use App\Services\FormBuilder\PublicFormTokenResolver; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Collection; +/** + * Public read-side endpoints for the form builder: + * - GET /public/forms/{public_token} schema + fields + tag options + * - GET /public/forms/{public_token}/time-slots AVAILABILITY_PICKER dependency data + * - GET /public/forms/{public_token}/sections SECTION_PRIORITY dependency data + * + * Draft/save/submit live in PublicFormSubmissionController. + */ final class PublicFormController extends Controller { public function __construct( - private readonly FormSubmissionService $submissionService, + private readonly PublicFormTokenResolver $tokenResolver, ) {} public function show(string $publicToken): JsonResponse { - $schema = $this->resolveSchema($publicToken, $grace); - if ($schema === null) { - return $this->error('Form not found.', 404); - } - if ($grace === 'expired') { - return $this->error('This form link has expired.', 410); - } + $schema = $this->tokenResolver->resolve($publicToken); return $this->success(new PublicFormSchemaResource($schema)); } - public function submit(PublicSubmissionRequest $request, string $publicToken): JsonResponse + public function timeSlots(string $publicToken): JsonResponse { - $schema = $this->resolveSchema($publicToken, $grace); - if ($schema === null) { - return $this->error('Form not found.', 404); - } - if ($grace === 'expired') { - return $this->error('This form link has expired.', 410); - } - if (! $schema->is_published) { - return $this->error('Form is not currently accepting submissions.', 410); + $schema = $this->tokenResolver->resolve($publicToken); + $event = $this->ownerEvent($schema); + if ($event === null) { + return $this->success(['data' => []]); } - $purpose = $schema->purpose instanceof \BackedEnum ? $schema->purpose->value : (string) $schema->purpose; - $captchaRequired = in_array($purpose, (array) config('form_builder.captcha.required_for_purposes', []), true); - if ($captchaRequired && ! $this->captchaValid($request)) { - return $this->error('Captcha validation failed.', 422); - } + $eventIds = $this->festivalEventIds($event); - $key = 'form-submit:'.$publicToken.':'.$request->ip(); - $perHour = (int) config('form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour', 5); - if (RateLimiter::tooManyAttempts($key, $perHour)) { - return response() - ->json(['success' => false, 'message' => 'Too many submissions.'], 429) - ->header('Retry-After', (string) RateLimiter::availableIn($key)); - } - RateLimiter::hit($key, 3600); + $slots = TimeSlot::query() + ->whereIn('event_id', $eventIds) + ->where('person_type', 'VOLUNTEER') + ->with(['event:id,name']) + ->orderBy('date') + ->orderBy('start_time') + ->get(); - $submission = $this->submissionService->createDraft( - $schema, - null, - $request->user(), - [ - 'idempotency_key' => $request->validated('idempotency_key'), - 'is_test' => false, - 'public_submitter_name' => $request->validated('public_submitter_name'), - 'public_submitter_email' => $request->validated('public_submitter_email'), - 'public_submitter_ip' => $request->ip(), - ], - ); - - $values = (array) ($request->validated('values') ?? []); - if ($values !== []) { - $this->submissionService->saveDraft($submission, $values, $request->user()); - } - - $submission = $this->submissionService->submit($submission->refresh(), $request->user()); - - return $this->created(new FormSubmissionResource($submission)); + return response()->json([ + 'data' => $slots->map(fn (TimeSlot $ts) => [ + 'id' => (string) $ts->id, + 'name' => (string) $ts->name, + 'date' => optional($ts->date)->toDateString(), + 'start_time' => (string) $ts->start_time, + 'end_time' => (string) $ts->end_time, + 'duration_hours' => $ts->duration_hours !== null ? (float) $ts->duration_hours : null, + 'event_id' => (string) $ts->event_id, + 'event_name' => (string) ($ts->event?->name ?? ''), + ])->values()->all(), + ]); } - /** - * @param-out string|null $grace 'current' | 'previous' | 'expired' | null - */ - private function resolveSchema(string $token, ?string &$grace = null): ?FormSchema + public function sections(string $publicToken): JsonResponse { - $grace = null; - - $current = FormSchema::query()->where('public_token', $token)->first(); - if ($current !== null) { - $grace = 'current'; - - return $current; + $schema = $this->tokenResolver->resolve($publicToken); + $event = $this->ownerEvent($schema); + if ($event === null) { + return $this->success(['data' => []]); } - $previous = FormSchema::query()->where('public_token_previous', $token)->first(); - if ($previous === null) { + $eventIds = $this->festivalEventIds($event); + + $sections = FestivalSection::query() + ->whereIn('event_id', $eventIds) + ->where('show_in_registration', true) + ->where('type', 'standard') + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + // For festival parents: dedup by name across children. Keep the + // first occurrence per name (lowest sort_order wins via the + // existing ordering). + $deduped = $sections->unique('name')->values(); + + return response()->json([ + 'data' => $deduped->map(fn (FestivalSection $s) => [ + 'id' => (string) $s->id, + 'name' => (string) $s->name, + 'category' => $s->category !== null ? (string) $s->category : null, + 'icon' => $s->icon !== null ? (string) $s->icon : null, + 'registration_description' => $s->registration_description !== null + ? (string) $s->registration_description + : null, + ])->all(), + ]); + } + + private function ownerEvent(FormSchema $schema): ?Event + { + if ($schema->owner_type !== 'event' || $schema->owner_id === null) { return null; } - $rotatedAt = $previous->public_token_rotated_at; - if ($rotatedAt === null) { - $grace = 'previous'; - - return $previous; - } - - $graceDays = 7; - if ($rotatedAt->addDays($graceDays)->isPast()) { - $grace = 'expired'; - - return $previous; - } - - $grace = 'previous'; - - return $previous; + return Event::withoutGlobalScopes()->find($schema->owner_id); } - private function captchaValid(Request $request): bool + /** + * Festival-aware event id list: for a festival/series parent, returns + * the children (operational units where time_slots + festival_sections + * typically live). For a flat event or a sub-event, returns just its + * own id. + * + * @return array + */ + private function festivalEventIds(Event $event): array { - $token = (string) $request->input('captcha_token', ''); - $secret = (string) config('form_builder.captcha.secret_key', ''); - if ($token === '' || $secret === '') { - return false; - } + $childIds = Event::query() + ->where('parent_event_id', $event->id) + ->pluck('id') + ->map(fn ($id) => (string) $id) + ->all(); - try { - $response = Http::asForm()->timeout(5)->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ - 'secret' => $secret, - 'response' => $token, - 'remoteip' => $request->ip(), - ]); - - return (bool) data_get($response->json(), 'success', false); - } catch (\Throwable) { - return false; - } + // Parent + children. For flat events $childIds is [] and the + // parent alone covers it. For festivals both parent-level + // cross-event items and per-child items surface. + return array_values(array_unique(array_merge([(string) $event->id], $childIds))); } } diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php new file mode 100644 index 00000000..e78807e3 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormSubmissionController.php @@ -0,0 +1,238 @@ +tokenResolver->resolve($publicToken); + $this->assertAcceptingSubmissions($schema); + + $context = [ + 'idempotency_key' => $request->validated('idempotency_key'), + 'opened_at' => $request->validated('opened_at'), + 'public_submitter_name' => $request->validated('public_submitter_name'), + 'public_submitter_email' => $request->validated('public_submitter_email'), + 'public_submitter_ip' => $request->ip(), + 'is_test' => false, + ]; + + try { + $submission = $this->submissionService->createDraft($schema, null, null, $context); + $wasExisting = $submission->wasRecentlyCreated === false; + } catch (UniqueConstraintViolationException) { + // Race with a sibling request using the same idempotency key — + // re-read the existing draft. + $submission = FormSubmission::query() + ->where('form_schema_id', $schema->id) + ->where('idempotency_key', $context['idempotency_key']) + ->firstOrFail(); + $wasExisting = true; + } + + $status = $wasExisting ? 200 : 201; + + return response()->json( + (new PublicFormSubmissionResource($submission))->response($request)->getData(), + $status, + ); + } + + public function update(SavePublicDraftRequest $request, string $publicToken, string $submissionId): JsonResponse + { + $schema = $this->tokenResolver->resolve($publicToken); + $this->assertAcceptingSubmissions($schema); + + $submission = $this->loadSubmission($schema, $submissionId); + $this->assertDraft($submission); + + $values = (array) ($request->validated('values') ?? []); + if ($values !== []) { + // Service throws FieldValidationException (422) on validation + // failure; the handler renders the standardised envelope. + $this->valueService->upsertMany($submission, $values, null); + } + + if ($request->filled('first_interacted_at')) { + $submission->first_interacted_at ??= $request->validated('first_interacted_at'); + $submission->save(); + } + + return $this->success(new PublicFormSubmissionResource($submission->fresh())); + } + + public function submit( + SubmitPublicSubmissionRequest $request, + string $publicToken, + string $submissionId, + ): JsonResponse { + $schema = $this->tokenResolver->resolve($publicToken); + $this->assertAcceptingSubmissions($schema); + + $submission = $this->loadSubmission($schema, $submissionId); + $this->assertDraft($submission); + + $this->enforceRateLimit($schema, $publicToken, $request->ip()); + + $purpose = $schema->purpose instanceof \BackedEnum + ? $schema->purpose->value + : (string) $schema->purpose; + $captchaRequired = in_array($purpose, (array) config('form_builder.captcha.required_for_purposes', []), true); + if ($captchaRequired && ! $this->captchaValid($request)) { + throw new FieldValidationException( + ['captcha_token' => ['Captcha validation failed.']], + 'Captcha validation failed.', + ); + } + + $bodyValues = (array) ($request->validated('values') ?? []); + $mergedValues = $this->mergeWithSavedValues($submission, $bodyValues); + + $this->validateMergedValues($schema, $mergedValues); + + if ($bodyValues !== []) { + $this->valueService->upsertMany($submission, $bodyValues, null); + } + + $submission = $this->submissionService->submit($submission->refresh(), null); + RateLimiter::hit('form-submit:'.$publicToken.':'.$request->ip(), 3600); + + return $this->created(new PublicFormSubmissionResource($submission)); + } + + private function loadSubmission(FormSchema $schema, string $submissionId): FormSubmission + { + /** @var FormSubmission|null $submission */ + $submission = FormSubmission::query() + ->where('form_schema_id', $schema->id) + ->whereKey($submissionId) + ->first(); + + if ($submission === null) { + abort(response()->json([ + 'message' => 'Submission not found for this form.', + 'code' => 'SUBMISSION_NOT_FOUND', + ], 404)); + } + + return $submission; + } + + private function assertAcceptingSubmissions(FormSchema $schema): void + { + if (! (bool) $schema->is_published) { + throw new SchemaUnpublishedException; + } + } + + private function assertDraft(FormSubmission $submission): void + { + $status = $submission->status instanceof \BackedEnum + ? $submission->status->value + : (string) $submission->status; + + if ($status !== FormSubmissionStatus::DRAFT->value) { + throw new SubmissionAlreadySubmittedException; + } + } + + private function enforceRateLimit(FormSchema $schema, string $publicToken, string $ip): void + { + $key = 'form-submit:'.$publicToken.':'.$ip; + $perHour = (int) config('form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour', 5); + if (RateLimiter::tooManyAttempts($key, $perHour)) { + throw new RateLimitedException(RateLimiter::availableIn($key)); + } + } + + /** + * @param array $bodyValues + * @return array + */ + private function mergeWithSavedValues(FormSubmission $submission, array $bodyValues): array + { + $saved = []; + foreach ($submission->values()->with('field')->get() as $value) { + $slug = $value->field?->slug; + if ($slug !== null) { + $saved[$slug] = $value->value; + } + } + + return array_merge($saved, $bodyValues); + } + + /** + * @param array $merged + */ + private function validateMergedValues(FormSchema $schema, array $merged): void + { + $rules = $this->ruleBuilder->strict($schema); + // Extend body wrapper rule so the merged `values` array passes. + $rules['values'] = ['required', 'array']; + + $validator = Validator::make(['values' => $merged], $rules); + if ($validator->fails()) { + throw new FieldValidationException($validator->errors()->messages()); + } + } + + private function captchaValid(Request $request): bool + { + $token = (string) $request->input('captcha_token', ''); + $secret = (string) config('form_builder.captcha.secret_key', ''); + if ($token === '' || $secret === '') { + return false; + } + + try { + $response = Http::asForm()->timeout(5)->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => $secret, + 'response' => $token, + 'remoteip' => $request->ip(), + ]); + + return (bool) data_get($response->json(), 'success', false); + } catch (\Throwable) { + return false; + } + } +} diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/SavePublicDraftRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/SavePublicDraftRequest.php new file mode 100644 index 00000000..e929614c --- /dev/null +++ b/api/app/Http/Requests/Api/V1/FormBuilder/SavePublicDraftRequest.php @@ -0,0 +1,57 @@ + + */ + public function rules(): array + { + $base = [ + 'values' => ['sometimes', 'array'], + 'first_interacted_at' => ['nullable', 'date'], + ]; + + $schema = $this->resolveSchema(); + if (! $schema instanceof FormSchema) { + return $base; + } + + return array_merge( + $base, + app(FormFieldRuleBuilder::class)->relaxed($schema), + ); + } + + private function resolveSchema(): ?FormSchema + { + $token = (string) $this->route('public_token'); + if ($token === '') { + return null; + } + try { + return app(PublicFormTokenResolver::class)->resolve($token); + } catch (\Throwable) { + return null; + } + } +} diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/StartPublicDraftRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/StartPublicDraftRequest.php new file mode 100644 index 00000000..0ff485ac --- /dev/null +++ b/api/app/Http/Requests/Api/V1/FormBuilder/StartPublicDraftRequest.php @@ -0,0 +1,37 @@ + + */ + public function rules(): array + { + return [ + 'idempotency_key' => ['required', 'string', 'min:6', 'max:30'], + 'opened_at' => ['nullable', 'date'], + 'submitted_in_locale' => ['nullable', 'string', 'max:10'], + 'public_submitter_name' => ['nullable', 'string', 'max:150'], + 'public_submitter_email' => ['nullable', 'email', 'max:255'], + ]; + } +} diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/SubmitPublicSubmissionRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/SubmitPublicSubmissionRequest.php new file mode 100644 index 00000000..d83b1173 --- /dev/null +++ b/api/app/Http/Requests/Api/V1/FormBuilder/SubmitPublicSubmissionRequest.php @@ -0,0 +1,63 @@ + + */ + public function rules(): array + { + $base = [ + 'values' => ['sometimes', 'array'], + 'captcha_token' => ['nullable', 'string', 'max:2000'], + ]; + + $schema = $this->resolveSchema(); + if (! $schema instanceof FormSchema) { + return $base; + } + + return array_merge( + $base, + app(FormFieldRuleBuilder::class)->relaxed($schema), + ); + } + + private function resolveSchema(): ?FormSchema + { + $token = (string) $this->route('public_token'); + if ($token === '') { + return null; + } + try { + return app(PublicFormTokenResolver::class)->resolve($token); + } catch (\Throwable) { + return null; + } + } +} diff --git a/api/app/Services/FormBuilder/FormFieldRuleBuilder.php b/api/app/Services/FormBuilder/FormFieldRuleBuilder.php new file mode 100644 index 00000000..eb37749d --- /dev/null +++ b/api/app/Services/FormBuilder/FormFieldRuleBuilder.php @@ -0,0 +1,210 @@ + ['required', 'array']], $ruleBuilder->strict($schema)); + */ +final class FormFieldRuleBuilder +{ + /** + * @return array> + */ + public function strict(FormSchema $schema): array + { + return $this->build($schema, strict: true); + } + + /** + * @return array> + */ + public function relaxed(FormSchema $schema): array + { + return $this->build($schema, strict: false); + } + + /** + * @return array> + */ + private function build(FormSchema $schema, bool $strict): array + { + $fields = $schema->fields()->get(); + $rules = []; + + foreach ($fields as $field) { + if (! (bool) $field->is_portal_visible || (bool) $field->is_admin_only) { + continue; + } + + if (in_array($field->field_type, [FormFieldType::HEADING->value, FormFieldType::PARAGRAPH->value], true)) { + continue; + } + + $key = 'values.'.$field->slug; + $isMulti = $this->isMultiValue($field); + $primaryRules = []; + + if ($strict && (bool) $field->is_required) { + $primaryRules[] = $isMulti ? 'present' : 'required'; + } else { + $primaryRules[] = 'sometimes'; + $primaryRules[] = 'nullable'; + } + + if ($isMulti) { + $primaryRules[] = 'array'; + $rules[$key] = $primaryRules; + + foreach ($this->itemRulesFor($field, $strict) as $itemRule) { + $rules[$key.'.*'][] = $itemRule; + } + continue; + } + + foreach ($this->scalarTypeRules($field) as $r) { + $primaryRules[] = $r; + } + foreach ($this->validationRuleShortcuts($field) as $r) { + $primaryRules[] = $r; + } + + $rules[$key] = $primaryRules; + } + + return $rules; + } + + private function isMultiValue(FormField $field): bool + { + return in_array($field->field_type, [ + FormFieldType::MULTISELECT->value, + FormFieldType::CHECKBOX_LIST->value, + FormFieldType::TAG_PICKER->value, + FormFieldType::AVAILABILITY_PICKER->value, + FormFieldType::SECTION_PRIORITY->value, + FormFieldType::TABLE_ROWS->value, + ], true); + } + + /** + * @return array + */ + private function scalarTypeRules(FormField $field): array + { + return match ($field->field_type) { + FormFieldType::EMAIL->value => ['email:rfc'], + FormFieldType::URL->value => ['url'], + FormFieldType::NUMBER->value => ['numeric'], + FormFieldType::DATE->value => ['date_format:Y-m-d'], + FormFieldType::DATETIME->value => ['date'], + FormFieldType::BOOLEAN->value => ['boolean'], + FormFieldType::PHONE->value => ['regex:/^[+]?[0-9\s\-()]{4,25}$/'], + FormFieldType::SELECT->value, FormFieldType::RADIO->value => $this->inOptionsRule($field), + default => ['string'], + }; + } + + /** + * @return array + */ + private function itemRulesFor(FormField $field, bool $strict): array + { + $rules = []; + if ($strict && (bool) $field->is_required) { + $rules[] = 'required'; + } else { + $rules[] = 'nullable'; + } + + return match ($field->field_type) { + FormFieldType::MULTISELECT->value, FormFieldType::CHECKBOX_LIST->value => array_merge($rules, $this->inOptionsRule($field)), + FormFieldType::TAG_PICKER->value => array_merge($rules, ['string', 'max:30']), + FormFieldType::AVAILABILITY_PICKER->value => array_merge($rules, ['string', 'max:30']), + FormFieldType::SECTION_PRIORITY->value => array_merge($rules, ['array:section_id,priority']), + FormFieldType::TABLE_ROWS->value => array_merge($rules, ['array']), + default => $rules, + }; + } + + /** + * @return array + */ + private function inOptionsRule(FormField $field): array + { + $options = $this->scalarOptions($field); + if ($options === []) { + return []; + } + + return ['in:'.implode(',', $options)]; + } + + /** + * @return array + */ + private function scalarOptions(FormField $field): array + { + $options = is_array($field->options) ? $field->options : []; + $out = []; + foreach ($options as $opt) { + if (is_scalar($opt)) { + $out[] = (string) $opt; + } elseif (is_array($opt) && isset($opt['value']) && is_scalar($opt['value'])) { + $out[] = (string) $opt['value']; + } elseif (is_array($opt) && isset($opt['label']) && is_scalar($opt['label'])) { + $out[] = (string) $opt['label']; + } + } + + return $out; + } + + /** + * Shortcuts picked up from form_fields.validation_rules JSON. + * Service-layer FormValueService does the deeper min/max/regex/unique + * enforcement — these are quick boundary checks surfaced at the + * Request layer when cheap. + * + * @return array + */ + private function validationRuleShortcuts(FormField $field): array + { + $rules = []; + $v = $field->validation_rules ?? null; + if (! is_array($v)) { + return $rules; + } + + if (isset($v['min']) && is_numeric($v['min'])) { + $rules[] = 'min:'.(string) $v['min']; + } + if (isset($v['max']) && is_numeric($v['max'])) { + $rules[] = 'max:'.(string) $v['max']; + } + if (isset($v['regex']) && is_string($v['regex'])) { + $rules[] = 'regex:'.$v['regex']; + } + + return $rules; + } +} diff --git a/api/app/Services/FormBuilder/FormValueService.php b/api/app/Services/FormBuilder/FormValueService.php index 86c82630..01c85a25 100644 --- a/api/app/Services/FormBuilder/FormValueService.php +++ b/api/app/Services/FormBuilder/FormValueService.php @@ -37,7 +37,9 @@ final class FormValueService ->get() ->keyBy('slug'); - DB::transaction(function () use ($slugToValue, $fields, $submission, $actor): void { + $fieldErrors = []; + + DB::transaction(function () use ($slugToValue, $fields, $submission, $actor, &$fieldErrors): void { foreach ($slugToValue as $slug => $raw) { $field = $fields->get($slug); if ($field === null) { @@ -53,10 +55,68 @@ final class FormValueService throw new AuthorizationException(sprintf('Not allowed to write field "%s".', $slug)); } + $errors = $this->validateAgainstFieldRules($field, $raw, $submission); + if ($errors !== []) { + $fieldErrors[$slug] = $errors; + + continue; + } + $this->writeValue($submission, $field, $raw); $this->writeEntityMirror($submission, $field, $raw); } }); + + if ($fieldErrors !== []) { + throw new \App\Exceptions\FormBuilder\FieldValidationException($fieldErrors); + } + } + + /** + * Backstop enforcement of form_fields.validation_rules JSON per + * S2c D8. The FormFieldRuleBuilder already surfaces min/max/regex + * shortcuts at the request layer; this is where the deeper checks + * (is_unique, validation_rules.unique) live. + * + * @return array + */ + private function validateAgainstFieldRules(FormField $field, mixed $raw, FormSubmission $submission): array + { + $errors = []; + $rules = is_array($field->validation_rules) ? $field->validation_rules : []; + + if ($raw === null || $raw === '' || $raw === []) { + return $errors; + } + + if (isset($rules['min']) && is_numeric($rules['min']) && is_numeric($raw) && (float) $raw < (float) $rules['min']) { + $errors[] = sprintf('Minimum is %s.', (string) $rules['min']); + } + if (isset($rules['max']) && is_numeric($rules['max']) && is_numeric($raw) && (float) $raw > (float) $rules['max']) { + $errors[] = sprintf('Maximum is %s.', (string) $rules['max']); + } + if (isset($rules['regex']) && is_string($rules['regex']) && is_string($raw) + && @preg_match($rules['regex'], $raw) !== 1) { + $errors[] = 'Value does not match the expected format.'; + } + + $unique = (bool) $field->is_unique || (bool) ($rules['unique'] ?? false); + if ($unique) { + $scalar = is_scalar($raw) ? (string) $raw : null; + if ($scalar !== null) { + $exists = \App\Models\FormBuilder\FormValue::query() + ->where('form_field_id', $field->id) + ->where('value_indexed', $scalar) + ->where('form_submission_id', '!=', $submission->id) + ->whereHas('submission', fn ($q) => $q->where('status', \App\Enums\FormBuilder\FormSubmissionStatus::SUBMITTED->value)) + ->exists(); + if ($exists) { + $errors[] = 'This value is already in use for another submission.'; + } + } + } + + return $errors; } private function writeValue(FormSubmission $submission, FormField $field, mixed $raw): void diff --git a/api/app/Services/FormBuilder/PublicFormTokenResolver.php b/api/app/Services/FormBuilder/PublicFormTokenResolver.php new file mode 100644 index 00000000..ba725691 --- /dev/null +++ b/api/app/Services/FormBuilder/PublicFormTokenResolver.php @@ -0,0 +1,51 @@ +where('public_token', $token) + ->first(); + if ($current !== null) { + return $current; + } + + $previous = FormSchema::query() + ->where('public_token_previous', $token) + ->first(); + + if ($previous === null) { + throw new SchemaNotFoundException; + } + + $rotatedAt = $previous->public_token_rotated_at; + if ($rotatedAt === null) { + return $previous; + } + + if ($rotatedAt->addDays(self::GRACE_DAYS)->isPast()) { + throw new TokenExpiredException; + } + + return $previous; + } +} diff --git a/api/routes/api.php b/api/routes/api.php index 1087dac9..8a9e5bc9 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -97,10 +97,25 @@ Route::post('verify-email-change', [EmailChangeController::class, 'verify']); Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1'); Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1'); -// Public Form Builder routes (no auth — token-based, rate-limited per ARCH §10) +// Public Form Builder routes (no auth — token-based, rate-limited per ARCH §10). +// S2c D4: draft/save/submit split into three separate endpoints. Route::middleware('throttle:30,1')->group(function (): void { Route::get('public/forms/{public_token}', [PublicFormController::class, 'show']); - Route::post('public/forms/{public_token}/submissions', [PublicFormController::class, 'submit']); + Route::get('public/forms/{public_token}/time-slots', [PublicFormController::class, 'timeSlots']); + Route::get('public/forms/{public_token}/sections', [PublicFormController::class, 'sections']); + + Route::post( + 'public/forms/{public_token}/submissions', + [\App\Http\Controllers\Api\V1\FormBuilder\PublicFormSubmissionController::class, 'store'], + ); + Route::put( + 'public/forms/{public_token}/submissions/{submission_id}', + [\App\Http\Controllers\Api\V1\FormBuilder\PublicFormSubmissionController::class, 'update'], + ); + Route::post( + 'public/forms/{public_token}/submissions/{submission_id}/submit', + [\App\Http\Controllers\Api\V1\FormBuilder\PublicFormSubmissionController::class, 'submit'], + ); }); // Platform Admin routes diff --git a/api/tests/Feature/FormBuilder/PublicFormApiTest.php b/api/tests/Feature/FormBuilder/PublicFormApiTest.php index ebb1841c..56d1484c 100644 --- a/api/tests/Feature/FormBuilder/PublicFormApiTest.php +++ b/api/tests/Feature/FormBuilder/PublicFormApiTest.php @@ -73,17 +73,30 @@ final class PublicFormApiTest extends TestCase // PUBLIC_RSVP does not require captcha by default Config::set('form_builder.captcha.required_for_purposes', []); - $response = $this->postJson( + // S2c D4 three-step flow: create draft, save values, submit. + $create = $this->postJson( "/api/v1/public/forms/{$this->schema->public_token}/submissions", [ - 'values' => ['name' => 'Bart'], + 'idempotency_key' => 'public-rsvp-test-001', 'public_submitter_name' => 'Bart', 'public_submitter_email' => 'bart@example.nl', ], ); + $create->assertCreated(); + $this->assertSame('draft', $create->json('data.status')); + $submissionId = $create->json('data.id'); - $response->assertCreated(); - $this->assertSame('submitted', $response->json('data.status')); + $this->putJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submissionId}", + ['values' => ['name' => 'Bart']], + )->assertOk(); + + $this->postJson( + "/api/v1/public/forms/{$this->schema->public_token}/submissions/{$submissionId}/submit", + [], + ) + ->assertCreated() + ->assertJsonPath('data.status', 'submitted'); } public function test_submit_with_expired_previous_token_returns_410(): void