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;
|
namespace App\Http\Controllers\Api\V1\FormBuilder;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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\Http\Resources\FormBuilder\PublicFormSchemaResource;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\FestivalSection;
|
||||||
use App\Models\FormBuilder\FormSchema;
|
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\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
final class PublicFormController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FormSubmissionService $submissionService,
|
private readonly PublicFormTokenResolver $tokenResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function show(string $publicToken): JsonResponse
|
public function show(string $publicToken): JsonResponse
|
||||||
{
|
{
|
||||||
$schema = $this->resolveSchema($publicToken, $grace);
|
$schema = $this->tokenResolver->resolve($publicToken);
|
||||||
if ($schema === null) {
|
|
||||||
return $this->error('Form not found.', 404);
|
|
||||||
}
|
|
||||||
if ($grace === 'expired') {
|
|
||||||
return $this->error('This form link has expired.', 410);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->success(new PublicFormSchemaResource($schema));
|
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);
|
$schema = $this->tokenResolver->resolve($publicToken);
|
||||||
if ($schema === null) {
|
$event = $this->ownerEvent($schema);
|
||||||
return $this->error('Form not found.', 404);
|
if ($event === null) {
|
||||||
}
|
return $this->success(['data' => []]);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$purpose = $schema->purpose instanceof \BackedEnum ? $schema->purpose->value : (string) $schema->purpose;
|
$eventIds = $this->festivalEventIds($event);
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = 'form-submit:'.$publicToken.':'.$request->ip();
|
$slots = TimeSlot::query()
|
||||||
$perHour = (int) config('form_builder.limits.max_submissions_per_public_schema_per_ip_per_hour', 5);
|
->whereIn('event_id', $eventIds)
|
||||||
if (RateLimiter::tooManyAttempts($key, $perHour)) {
|
->where('person_type', 'VOLUNTEER')
|
||||||
return response()
|
->with(['event:id,name'])
|
||||||
->json(['success' => false, 'message' => 'Too many submissions.'], 429)
|
->orderBy('date')
|
||||||
->header('Retry-After', (string) RateLimiter::availableIn($key));
|
->orderBy('start_time')
|
||||||
}
|
->get();
|
||||||
RateLimiter::hit($key, 3600);
|
|
||||||
|
|
||||||
$submission = $this->submissionService->createDraft(
|
return response()->json([
|
||||||
$schema,
|
'data' => $slots->map(fn (TimeSlot $ts) => [
|
||||||
null,
|
'id' => (string) $ts->id,
|
||||||
$request->user(),
|
'name' => (string) $ts->name,
|
||||||
[
|
'date' => optional($ts->date)->toDateString(),
|
||||||
'idempotency_key' => $request->validated('idempotency_key'),
|
'start_time' => (string) $ts->start_time,
|
||||||
'is_test' => false,
|
'end_time' => (string) $ts->end_time,
|
||||||
'public_submitter_name' => $request->validated('public_submitter_name'),
|
'duration_hours' => $ts->duration_hours !== null ? (float) $ts->duration_hours : null,
|
||||||
'public_submitter_email' => $request->validated('public_submitter_email'),
|
'event_id' => (string) $ts->event_id,
|
||||||
'public_submitter_ip' => $request->ip(),
|
'event_name' => (string) ($ts->event?->name ?? ''),
|
||||||
],
|
])->values()->all(),
|
||||||
);
|
]);
|
||||||
|
|
||||||
$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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function sections(string $publicToken): JsonResponse
|
||||||
* @param-out string|null $grace 'current' | 'previous' | 'expired' | null
|
|
||||||
*/
|
|
||||||
private function resolveSchema(string $token, ?string &$grace = null): ?FormSchema
|
|
||||||
{
|
{
|
||||||
$grace = null;
|
$schema = $this->tokenResolver->resolve($publicToken);
|
||||||
|
$event = $this->ownerEvent($schema);
|
||||||
$current = FormSchema::query()->where('public_token', $token)->first();
|
if ($event === null) {
|
||||||
if ($current !== null) {
|
return $this->success(['data' => []]);
|
||||||
$grace = 'current';
|
|
||||||
|
|
||||||
return $current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$previous = FormSchema::query()->where('public_token_previous', $token)->first();
|
$eventIds = $this->festivalEventIds($event);
|
||||||
if ($previous === null) {
|
|
||||||
|
$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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rotatedAt = $previous->public_token_rotated_at;
|
return Event::withoutGlobalScopes()->find($schema->owner_id);
|
||||||
if ($rotatedAt === null) {
|
|
||||||
$grace = 'previous';
|
|
||||||
|
|
||||||
return $previous;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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', '');
|
$childIds = Event::query()
|
||||||
$secret = (string) config('form_builder.captcha.secret_key', '');
|
->where('parent_event_id', $event->id)
|
||||||
if ($token === '' || $secret === '') {
|
->pluck('id')
|
||||||
return false;
|
->map(fn ($id) => (string) $id)
|
||||||
}
|
->all();
|
||||||
|
|
||||||
try {
|
// Parent + children. For flat events $childIds is [] and the
|
||||||
$response = Http::asForm()->timeout(5)->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
|
// parent alone covers it. For festivals both parent-level
|
||||||
'secret' => $secret,
|
// cross-event items and per-child items surface.
|
||||||
'response' => $token,
|
return array_values(array_unique(array_merge([(string) $event->id], $childIds)));
|
||||||
'remoteip' => $request->ip(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (bool) data_get($response->json(), 'success', false);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
api/app/Services/FormBuilder/FormFieldRuleBuilder.php
Normal file
210
api/app/Services/FormBuilder/FormFieldRuleBuilder.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,9 @@ final class FormValueService
|
|||||||
->get()
|
->get()
|
||||||
->keyBy('slug');
|
->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) {
|
foreach ($slugToValue as $slug => $raw) {
|
||||||
$field = $fields->get($slug);
|
$field = $fields->get($slug);
|
||||||
if ($field === null) {
|
if ($field === null) {
|
||||||
@@ -53,10 +55,68 @@ final class FormValueService
|
|||||||
throw new AuthorizationException(sprintf('Not allowed to write field "%s".', $slug));
|
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->writeValue($submission, $field, $raw);
|
||||||
$this->writeEntityMirror($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
|
private function writeValue(FormSubmission $submission, FormField $field, mixed $raw): void
|
||||||
|
|||||||
51
api/app/Services/FormBuilder/PublicFormTokenResolver.php
Normal file
51
api/app/Services/FormBuilder/PublicFormTokenResolver.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('public/check-email', CheckEmailController::class)->middleware('throttle:10,1');
|
||||||
Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->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::middleware('throttle:30,1')->group(function (): void {
|
||||||
Route::get('public/forms/{public_token}', [PublicFormController::class, 'show']);
|
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
|
// Platform Admin routes
|
||||||
|
|||||||
@@ -73,17 +73,30 @@ final class PublicFormApiTest extends TestCase
|
|||||||
// PUBLIC_RSVP does not require captcha by default
|
// PUBLIC_RSVP does not require captcha by default
|
||||||
Config::set('form_builder.captcha.required_for_purposes', []);
|
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",
|
"/api/v1/public/forms/{$this->schema->public_token}/submissions",
|
||||||
[
|
[
|
||||||
'values' => ['name' => 'Bart'],
|
'idempotency_key' => 'public-rsvp-test-001',
|
||||||
'public_submitter_name' => 'Bart',
|
'public_submitter_name' => 'Bart',
|
||||||
'public_submitter_email' => 'bart@example.nl',
|
'public_submitter_email' => 'bart@example.nl',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
$create->assertCreated();
|
||||||
|
$this->assertSame('draft', $create->json('data.status'));
|
||||||
|
$submissionId = $create->json('data.id');
|
||||||
|
|
||||||
$response->assertCreated();
|
$this->putJson(
|
||||||
$this->assertSame('submitted', $response->json('data.status'));
|
"/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
|
public function test_submit_with_expired_previous_token_returns_410(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user