feat(form-builder): public draft/save/submit split + sub-endpoints + validation

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>
This commit is contained in:
2026-04-17 22:56:20 +02:00
parent e4294702c5
commit 63d08c8bde
10 changed files with 846 additions and 112 deletions

View File

@@ -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);
$slots = TimeSlot::query()
->whereIn('event_id', $eventIds)
->where('person_type', 'VOLUNTEER')
->with(['event:id,name'])
->orderBy('date')
->orderBy('start_time')
->get();
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(),
]);
}
$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);
$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));
}
/**
* @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;
return Event::withoutGlobalScopes()->find($schema->owner_id);
}
$graceDays = 7;
if ($rotatedAt->addDays($graceDays)->isPast()) {
$grace = 'expired';
return $previous;
}
$grace = 'previous';
return $previous;
}
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<int, string>
*/
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)));
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Exceptions\FormBuilder\FieldValidationException;
use App\Exceptions\FormBuilder\RateLimitedException;
use App\Exceptions\FormBuilder\SchemaUnpublishedException;
use App\Exceptions\FormBuilder\SubmissionAlreadySubmittedException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\SavePublicDraftRequest;
use App\Http\Requests\Api\V1\FormBuilder\StartPublicDraftRequest;
use App\Http\Requests\Api\V1\FormBuilder\SubmitPublicSubmissionRequest;
use App\Http\Resources\FormBuilder\PublicFormSubmissionResource;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Enums\FormBuilder\FormSubmissionStatus;
use App\Services\FormBuilder\FormFieldRuleBuilder;
use App\Services\FormBuilder\FormSubmissionService;
use App\Services\FormBuilder\FormValueService;
use App\Services\FormBuilder\PublicFormTokenResolver;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
/**
* Public submission lifecycle: draft save submit. Each step is its
* own request method (S2c D4). Controller is deliberately thin all
* business logic lives in FormSubmissionService / FormValueService.
*/
final class PublicFormSubmissionController extends Controller
{
public function __construct(
private readonly PublicFormTokenResolver $tokenResolver,
private readonly FormSubmissionService $submissionService,
private readonly FormValueService $valueService,
private readonly FormFieldRuleBuilder $ruleBuilder,
) {}
public function store(StartPublicDraftRequest $request, string $publicToken): JsonResponse
{
$schema = $this->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<string, mixed> $bodyValues
* @return array<string, mixed>
*/
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<string, mixed> $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;
}
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Models\FormBuilder\FormSchema;
use App\Services\FormBuilder\FormFieldRuleBuilder;
use App\Services\FormBuilder\PublicFormTokenResolver;
use Illuminate\Foundation\Http\FormRequest;
/**
* Body for `PUT /api/v1/public/forms/{public_token}/submissions/{id}`.
* Auto-save endpoint relaxed rule set per S2c D8. Only the field
* slugs present in the body are written; everything is nullable.
*/
final class SavePublicDraftRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
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;
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use Illuminate\Foundation\Http\FormRequest;
/**
* Body for `POST /api/v1/public/forms/{public_token}/submissions` (S2c D4).
* Creates a draft. Contains no field values values are written later
* via PUT (auto-save) or POST /submit.
*
* `idempotency_key` is REQUIRED: duplicate POSTs with the same key must
* return the existing draft rather than create a second one.
*/
final class StartPublicDraftRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
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'],
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\FormBuilder;
use App\Models\FormBuilder\FormSchema;
use App\Services\FormBuilder\FormFieldRuleBuilder;
use App\Services\FormBuilder\PublicFormTokenResolver;
use Illuminate\Foundation\Http\FormRequest;
/**
* Body for `POST /api/v1/public/forms/{public_token}/submissions/{id}/submit`.
*
* The FormRequest validates the request body in isolation using the
* *relaxed* rule set, because the controller merges the body with any
* values already saved via earlier PUT calls and runs the strict rule
* set on the merged value map before actually submitting.
*
* Upshot: a submit call with an empty body but all values auto-saved
* during drafting still passes at the request layer.
*/
final class SubmitPublicSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
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;
}
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Services\FormBuilder;
use App\Enums\FormBuilder\FormFieldType;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
/**
* Build Laravel validation rules dynamically from a schema's form_fields.
*
* Two modes (S2c D8):
* - strict(): for the final submit pipeline. is_required fields are
* required; SELECT/RADIO values must be in options; multi-value
* types must be arrays; type rules (email/url/date/numeric/boolean)
* are enforced.
* - relaxed(): for auto-save drafts. Every rule becomes nullable;
* options lists still constrain (a SELECT can't silently accept
* garbage, even mid-draft), but required stays off.
*
* Rule shape returned targets `values.{slug}` and `values.{slug}.*` keys
* so FormRequests can merge this into their own `values` wrapper:
*
* return array_merge(['values' => ['required', 'array']], $ruleBuilder->strict($schema));
*/
final class FormFieldRuleBuilder
{
/**
* @return array<string, array<int, string>>
*/
public function strict(FormSchema $schema): array
{
return $this->build($schema, strict: true);
}
/**
* @return array<string, array<int, string>>
*/
public function relaxed(FormSchema $schema): array
{
return $this->build($schema, strict: false);
}
/**
* @return array<string, array<int, string>>
*/
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<int, string>
*/
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<int, string>
*/
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<int, string>
*/
private function inOptionsRule(FormField $field): array
{
$options = $this->scalarOptions($field);
if ($options === []) {
return [];
}
return ['in:'.implode(',', $options)];
}
/**
* @return array<int, string>
*/
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<int, string>
*/
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;
}
}

View File

@@ -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<int, string>
*/
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

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Services\FormBuilder;
use App\Exceptions\FormBuilder\SchemaNotFoundException;
use App\Exceptions\FormBuilder\TokenExpiredException;
use App\Models\FormBuilder\FormSchema;
/**
* Token-to-schema resolution for every /public/forms/* endpoint.
* Centralises the 7-day grace window logic (ARCH §10, BACKLOG FORM-04
* tracks making this configurable).
*
* Throws the standardised public-form exceptions directly so callers
* don't need to branch on grace state themselves.
*/
final class PublicFormTokenResolver
{
private const GRACE_DAYS = 7;
public function resolve(string $token): FormSchema
{
$current = FormSchema::query()
->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;
}
}

View File

@@ -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

View File

@@ -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