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);
$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<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;
}
}
}