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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user