feat(form-builder): controllers and routes (auth + public token)

Phase 5 of S2b. Ten thin controllers plus route registration under the
existing organisations/{organisation} prefix and two unauthenticated
public endpoints.

Controllers (api/app/Http/Controllers/Api/V1/FormBuilder/):
- FormSchemaController: CRUD + duplicate/publish/unpublish/rotate-token/
  edit-lock. Returns 410 via PublicFormController when a rotated token is
  past its 7-day grace window.
- FormFieldController: CRUD + reorder + insert-from-library. 422 on
  binding-change / frozen / cyclic conditional_logic.
- FormSubmissionController: index/store/show/submit/destroy.
- FormValueController: bulk upsert draft values; 403 when
  FieldAccessService rejects a write.
- FormSubmissionReviewController, FormSubmissionDelegationController.
- FormTemplateController, FormFieldLibraryController (deactivate on
  DELETE for is_active records).
- FormSchemaWebhookController (url/secret never leak — only url_host +
  has_secret in responses).
- FilterRegistryController: cached entity_column + tags + form_field
  source list for Personen-module (ARCH §7.3–§7.5).
- PublicFormController: GET schema + POST submission. Turnstile captcha
  for public_complaint/public_press_request. Rate-limited per
  IP+public_token. 410 when token expired.

Routes: grouped under organisations/{organisation}/forms/ for auth'd
routes and public/forms/{public_token}/... with throttle:30,1 for the
public pair. Policies auto-discovered from the namespaced location.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 21:18:06 +02:00
parent 4b7e66b83f
commit 65070faf47
12 changed files with 1128 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
final class FilterRegistryController extends Controller
{
public function show(Organisation $organisation): JsonResponse
{
Gate::authorize('view', $organisation);
$eventId = request()->query('event_id');
$cacheKey = sprintf('form-builder.filter-registry.%s.%s', $organisation->id, (string) $eventId);
$data = Cache::remember($cacheKey, 60, function () use ($organisation, $eventId): array {
$entityColumns = $this->entityColumnSources();
$tagsSource = [[
'source' => 'tags',
'key' => 'tags',
'label' => 'Vaardigheden',
'field_type' => 'TAG_PICKER',
]];
$formFieldSources = FormField::query()
->whereHas('schema', function ($q) use ($organisation, $eventId): void {
$q->where('organisation_id', $organisation->id);
if ($eventId !== null && $eventId !== '') {
$q->where(function ($inner) use ($eventId): void {
$inner->where('owner_type', 'event')->where('owner_id', $eventId)
->orWhereNull('owner_type');
});
}
})
->where('is_filterable', true)
->get()
->map(fn (FormField $f) => [
'source' => 'form_field',
'key' => 'form_field:'.$f->id,
'form_field_id' => $f->id,
'schema_slug' => $f->schema?->slug,
'label' => $f->label,
'field_type' => $f->field_type,
'options' => is_array($f->options) ? array_values($f->options) : null,
])->values()->all();
return [
'data' => array_merge($entityColumns, $tagsSource, $formFieldSources),
];
});
return response()->json($data);
}
/**
* @return array<int, array<string, mixed>>
*/
private function entityColumnSources(): array
{
$config = (array) config('form_filter_registry.persons', []);
$out = [];
foreach ($config as $key => $def) {
$options = null;
if (! empty($def['options_enum']) && enum_exists((string) $def['options_enum'])) {
$options = array_map(fn ($c) => $c->value, ((string) $def['options_enum'])::cases());
}
$out[] = [
'source' => 'entity_column',
'key' => (string) $key,
'label' => (string) ($def['label'] ?? $key),
'field_type' => (string) ($def['field_type'] ?? 'TEXT'),
'options' => $options,
];
}
return $out;
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Exceptions\FormBuilder\BindingChangeBlockedException;
use App\Exceptions\FormBuilder\CyclicDependencyException;
use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException;
use App\Exceptions\FormBuilder\FrozenSchemaException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\InsertLibraryFieldRequest;
use App\Http\Requests\Api\V1\FormBuilder\ReorderFormFieldsRequest;
use App\Http\Requests\Api\V1\FormBuilder\StoreFormFieldRequest;
use App\Http\Requests\Api\V1\FormBuilder\UpdateFormFieldRequest;
use App\Http\Resources\FormBuilder\FormFieldResource;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Services\FormBuilder\FormFieldService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class FormFieldController extends Controller
{
public function __construct(
private readonly FormFieldService $service,
) {}
public function index(Organisation $organisation, FormSchema $formSchema): AnonymousResourceCollection
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('view', $formSchema);
$fields = $formSchema->fields()->orderBy('sort_order')->get();
return FormFieldResource::collection($fields);
}
public function store(StoreFormFieldRequest $request, Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('create', [FormField::class, $formSchema]);
try {
$field = $this->service->create($formSchema, $request->validated());
} catch (FrozenSchemaException|CyclicDependencyException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->created(new FormFieldResource($field));
}
public function update(UpdateFormFieldRequest $request, Organisation $organisation, FormSchema $formSchema, FormField $formField): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
$this->assertFieldBelongsToSchema($formSchema, $formField);
Gate::authorize('update', $formField);
$data = $request->validated();
$force = (bool) ($data['force_binding_change'] ?? false);
unset($data['force_binding_change']);
try {
$field = $this->service->update($formField, $data, $force);
} catch (BindingChangeBlockedException $e) {
return $this->error($e->getMessage(), 422);
} catch (FrozenSchemaException|CyclicDependencyException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->success(new FormFieldResource($field));
}
public function destroy(Organisation $organisation, FormSchema $formSchema, FormField $formField): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
$this->assertFieldBelongsToSchema($formSchema, $formField);
Gate::authorize('delete', $formField);
try {
$this->service->delete($formField, request()->query('confirmed_name'));
} catch (DestructiveConfirmationRequiredException|FrozenSchemaException $e) {
return $this->error($e->getMessage(), 422);
}
return response()->json(null, 204);
}
public function reorder(ReorderFormFieldsRequest $request, Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('reorder', [FormField::class, $formSchema]);
$this->service->reorder($formSchema, $request->validated('field_ids'));
return $this->success(['reordered' => true]);
}
public function insertFromLibrary(InsertLibraryFieldRequest $request, Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('insertFromLibrary', [FormField::class, $formSchema]);
/** @var FormFieldLibrary $library */
$library = FormFieldLibrary::query()->findOrFail($request->validated('library_field_id'));
if ($library->organisation_id !== $organisation->id) {
abort(404);
}
try {
$field = $this->service->insertFromLibrary($formSchema, $library, (array) ($request->validated('overrides') ?? []));
} catch (FrozenSchemaException $e) {
return $this->error($e->getMessage(), 422);
}
return $this->created(new FormFieldResource($field));
}
private function assertSameOrg(Organisation $organisation, FormSchema $schema): void
{
if ($schema->organisation_id !== $organisation->id) {
abort(404);
}
}
private function assertFieldBelongsToSchema(FormSchema $schema, FormField $field): void
{
if ($field->form_schema_id !== $schema->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\StoreFormFieldLibraryRequest;
use App\Http\Requests\Api\V1\FormBuilder\UpdateFormFieldLibraryRequest;
use App\Http\Resources\FormBuilder\FormFieldLibraryResource;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\Organisation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
final class FormFieldLibraryController extends Controller
{
public function index(Organisation $organisation): AnonymousResourceCollection
{
Gate::authorize('viewAny', [FormFieldLibrary::class, $organisation]);
$library = FormFieldLibrary::query()
->orderByDesc('is_system')
->orderBy('name')
->paginate(25);
return FormFieldLibraryResource::collection($library);
}
public function show(Organisation $organisation, FormFieldLibrary $fieldLibrary): JsonResponse
{
$this->assertSameOrg($organisation, $fieldLibrary);
Gate::authorize('view', $fieldLibrary);
return $this->success(new FormFieldLibraryResource($fieldLibrary));
}
public function store(StoreFormFieldLibraryRequest $request, Organisation $organisation): JsonResponse
{
Gate::authorize('create', [FormFieldLibrary::class, $organisation]);
$data = $request->validated();
$data['organisation_id'] = $organisation->id;
$data['is_system'] = false;
$data['is_active'] ??= true;
$data['slug'] ??= Str::slug($data['name']);
/** @var FormFieldLibrary $library */
$library = FormFieldLibrary::create($data);
return $this->created(new FormFieldLibraryResource($library));
}
public function update(UpdateFormFieldLibraryRequest $request, Organisation $organisation, FormFieldLibrary $fieldLibrary): JsonResponse
{
$this->assertSameOrg($organisation, $fieldLibrary);
Gate::authorize('update', $fieldLibrary);
$fieldLibrary->fill($request->validated());
$fieldLibrary->save();
return $this->success(new FormFieldLibraryResource($fieldLibrary));
}
public function destroy(Organisation $organisation, FormFieldLibrary $fieldLibrary): JsonResponse
{
$this->assertSameOrg($organisation, $fieldLibrary);
Gate::authorize('deactivate', $fieldLibrary);
$fieldLibrary->is_active = false;
$fieldLibrary->save();
return response()->json(null, 204);
}
private function assertSameOrg(Organisation $organisation, FormFieldLibrary $library): void
{
if ($library->organisation_id !== $organisation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\RotatePublicTokenRequest;
use App\Http\Requests\Api\V1\FormBuilder\StoreFormSchemaRequest;
use App\Http\Requests\Api\V1\FormBuilder\UpdateFormSchemaRequest;
use App\Http\Resources\FormBuilder\FormSchemaResource;
use App\Http\Resources\FormBuilder\FormSchemaSummaryResource;
use App\Models\FormBuilder\FormSchema;
use App\Models\Organisation;
use App\Services\FormBuilder\FormSchemaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Facades\Gate;
final class FormSchemaController extends Controller
{
public function __construct(
private readonly FormSchemaService $service,
) {}
public function index(Organisation $organisation): ResourceCollection
{
Gate::authorize('viewAny', [FormSchema::class, $organisation]);
$schemas = FormSchema::query()
->with(['fields', 'sections'])
->withCount(['fields', 'submissions'])
->orderByDesc('updated_at')
->paginate(25);
return FormSchemaSummaryResource::collection($schemas);
}
public function store(StoreFormSchemaRequest $request, Organisation $organisation): JsonResponse
{
Gate::authorize('create', [FormSchema::class, $organisation]);
$schema = $this->service->create($organisation, $request->validated(), $request->user());
return $this->created(new FormSchemaResource($schema));
}
public function show(Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('view', $formSchema);
$formSchema->load(['fields', 'sections']);
$formSchema->loadCount(['fields', 'submissions']);
return $this->success(new FormSchemaResource($formSchema));
}
public function update(UpdateFormSchemaRequest $request, Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('update', $formSchema);
$schema = $this->service->update($formSchema, $request->validated(), $request->user());
return $this->success(new FormSchemaResource($schema));
}
public function destroy(Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('delete', $formSchema);
$this->service->delete($formSchema, request()->user(), request()->query('confirmed_name'));
return response()->json(null, 204);
}
public function duplicate(Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('duplicate', $formSchema);
$copy = $this->service->duplicate($formSchema, request()->user());
return $this->created(new FormSchemaResource($copy));
}
public function publish(Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('publish', $formSchema);
return $this->success(new FormSchemaResource($this->service->publish($formSchema, request()->user())));
}
public function unpublish(Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('publish', $formSchema);
return $this->success(new FormSchemaResource($this->service->unpublish($formSchema, request()->user())));
}
public function rotatePublicToken(RotatePublicTokenRequest $request, Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('rotatePublicToken', $formSchema);
$schema = $this->service->rotatePublicToken(
$formSchema,
$request->user(),
(int) ($request->validated('grace_days') ?? 7),
);
return $this->success(new FormSchemaResource($schema));
}
public function acquireEditLock(Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('acquireEditLock', $formSchema);
$schema = $this->service->acquireEditLock($formSchema, request()->user());
return $this->success(new FormSchemaResource($schema));
}
public function releaseEditLock(Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('acquireEditLock', $formSchema);
$schema = $this->service->releaseEditLock($formSchema, request()->user());
return $this->success(new FormSchemaResource($schema));
}
private function assertSameOrg(Organisation $organisation, FormSchema $schema): void
{
if ($schema->organisation_id !== $organisation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\StoreFormSchemaWebhookRequest;
use App\Http\Requests\Api\V1\FormBuilder\UpdateFormSchemaWebhookRequest;
use App\Http\Resources\FormBuilder\FormSchemaWebhookResource;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaWebhook;
use App\Models\Organisation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class FormSchemaWebhookController extends Controller
{
public function index(Organisation $organisation, FormSchema $formSchema): AnonymousResourceCollection
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('view', $formSchema);
$webhooks = FormSchemaWebhook::query()
->where('form_schema_id', $formSchema->id)
->orderByDesc('is_active')
->orderBy('name')
->paginate(25);
return FormSchemaWebhookResource::collection($webhooks);
}
public function store(StoreFormSchemaWebhookRequest $request, Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('create', [FormSchemaWebhook::class, $formSchema]);
$data = $request->validated();
$data['form_schema_id'] = $formSchema->id;
$data['is_active'] ??= true;
/** @var FormSchemaWebhook $webhook */
$webhook = FormSchemaWebhook::create($data);
return $this->created(new FormSchemaWebhookResource($webhook));
}
public function update(UpdateFormSchemaWebhookRequest $request, Organisation $organisation, FormSchema $formSchema, FormSchemaWebhook $webhook): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
$this->assertBelongsToSchema($formSchema, $webhook);
Gate::authorize('update', $webhook);
$webhook->fill($request->validated());
$webhook->save();
return $this->success(new FormSchemaWebhookResource($webhook));
}
public function destroy(Organisation $organisation, FormSchema $formSchema, FormSchemaWebhook $webhook): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
$this->assertBelongsToSchema($formSchema, $webhook);
Gate::authorize('delete', $webhook);
$webhook->delete();
return response()->json(null, 204);
}
private function assertSameOrg(Organisation $organisation, FormSchema $schema): void
{
if ($schema->organisation_id !== $organisation->id) {
abort(404);
}
}
private function assertBelongsToSchema(FormSchema $schema, FormSchemaWebhook $webhook): void
{
if ($webhook->form_schema_id !== $schema->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\CreateFormSubmissionRequest;
use App\Http\Requests\Api\V1\FormBuilder\SubmitFormSubmissionRequest;
use App\Http\Resources\FormBuilder\FormSubmissionResource;
use App\Http\Resources\FormBuilder\FormSubmissionSummaryResource;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Services\FormBuilder\FormSubmissionService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
final class FormSubmissionController extends Controller
{
public function __construct(
private readonly FormSubmissionService $service,
) {}
public function index(Organisation $organisation, FormSchema $formSchema): AnonymousResourceCollection
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('viewAny', [FormSubmission::class, $formSchema]);
$submissions = FormSubmission::query()
->where('form_schema_id', $formSchema->id)
->orderByDesc('created_at')
->paginate(25);
return FormSubmissionSummaryResource::collection($submissions);
}
public function store(CreateFormSubmissionRequest $request, Organisation $organisation, FormSchema $formSchema): JsonResponse
{
$this->assertSameOrg($organisation, $formSchema);
Gate::authorize('create', [FormSubmission::class, $formSchema]);
$subject = $this->resolveSubject(
(string) ($request->validated('subject_type') ?? ''),
(string) ($request->validated('subject_id') ?? ''),
);
$submission = $this->service->createDraft(
$formSchema,
$subject,
$request->user(),
[
'idempotency_key' => $request->validated('idempotency_key'),
'is_test' => (bool) ($request->validated('is_test') ?? false),
'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(),
],
);
return $this->created(new FormSubmissionResource($submission));
}
public function show(Organisation $organisation, FormSubmission $formSubmission): JsonResponse
{
$this->assertSubmissionOrg($organisation, $formSubmission);
Gate::authorize('view', $formSubmission);
return $this->success(new FormSubmissionResource($formSubmission));
}
public function submit(SubmitFormSubmissionRequest $request, Organisation $organisation, FormSubmission $formSubmission): JsonResponse
{
$this->assertSubmissionOrg($organisation, $formSubmission);
Gate::authorize('submit', $formSubmission);
$values = (array) ($request->validated('values') ?? []);
if ($values !== []) {
$this->service->saveDraft($formSubmission, $values, $request->user());
$formSubmission->refresh();
}
$submission = $this->service->submit($formSubmission, $request->user());
return $this->success(new FormSubmissionResource($submission));
}
public function destroy(Organisation $organisation, FormSubmission $formSubmission): JsonResponse
{
$this->assertSubmissionOrg($organisation, $formSubmission);
Gate::authorize('delete', $formSubmission);
$this->service->delete($formSubmission, request()->user());
return response()->json(null, 204);
}
private function resolveSubject(string $type, string $id): ?Model
{
if ($type === '' || $id === '') {
return null;
}
$map = (array) config('form_subjects');
$class = $map[$type]['model'] ?? null;
if ($class === null) {
return null;
}
return $class::withoutGlobalScopes()->find($id);
}
private function assertSameOrg(Organisation $organisation, FormSchema $schema): void
{
if ($schema->organisation_id !== $organisation->id) {
abort(404);
}
}
private function assertSubmissionOrg(Organisation $organisation, FormSubmission $submission): void
{
if ($submission->schema === null || $submission->schema->organisation_id !== $organisation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\DelegateFormSubmissionRequest;
use App\Models\FormBuilder\FormSubmission;
use App\Models\FormBuilder\FormSubmissionDelegation;
use App\Models\Organisation;
use App\Models\User;
use App\Services\FormBuilder\FormSubmissionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class FormSubmissionDelegationController extends Controller
{
public function __construct(
private readonly FormSubmissionService $service,
) {}
public function delegate(DelegateFormSubmissionRequest $request, Organisation $organisation, FormSubmission $formSubmission): JsonResponse
{
$this->assertSubmissionOrg($organisation, $formSubmission);
Gate::authorize('delegate', $formSubmission);
/** @var User $delegatee */
$delegatee = User::query()->findOrFail($request->validated('delegated_to_user_id'));
$delegation = $this->service->delegate(
$formSubmission,
$delegatee,
$request->user(),
$request->validated('message'),
);
return $this->created([
'id' => $delegation->id,
'delegated_to_user_id' => $delegation->delegated_to_user_id,
'granted_at' => optional($delegation->granted_at)->toIso8601String(),
'message' => $delegation->message,
]);
}
public function revoke(Organisation $organisation, FormSubmission $formSubmission, FormSubmissionDelegation $delegation): JsonResponse
{
$this->assertSubmissionOrg($organisation, $formSubmission);
if ($delegation->form_submission_id !== $formSubmission->id) {
abort(404);
}
Gate::authorize('revokeDelegation', $delegation);
$this->service->revokeDelegation($delegation, request()->user());
return response()->json(null, 204);
}
private function assertSubmissionOrg(Organisation $organisation, FormSubmission $submission): void
{
if ($submission->schema === null || $submission->schema->organisation_id !== $organisation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Enums\FormBuilder\FormSubmissionReviewStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\ReviewFormSubmissionRequest;
use App\Http\Resources\FormBuilder\FormSubmissionResource;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Services\FormBuilder\FormSubmissionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class FormSubmissionReviewController extends Controller
{
public function __construct(
private readonly FormSubmissionService $service,
) {}
public function review(ReviewFormSubmissionRequest $request, Organisation $organisation, FormSubmission $formSubmission): JsonResponse
{
$this->assertSubmissionOrg($organisation, $formSubmission);
Gate::authorize('review', $formSubmission);
$submission = $this->service->review(
$formSubmission,
FormSubmissionReviewStatus::from((string) $request->validated('status')),
$request->validated('review_notes'),
$request->user(),
);
return $this->success(new FormSubmissionResource($submission));
}
private function assertSubmissionOrg(Organisation $organisation, FormSubmission $submission): void
{
if ($submission->schema === null || $submission->schema->organisation_id !== $organisation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\StoreFormTemplateRequest;
use App\Http\Requests\Api\V1\FormBuilder\UpdateFormTemplateRequest;
use App\Http\Resources\FormBuilder\FormTemplateResource;
use App\Models\FormBuilder\FormTemplate;
use App\Models\Organisation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
final class FormTemplateController extends Controller
{
public function index(Organisation $organisation): AnonymousResourceCollection
{
Gate::authorize('viewAny', [FormTemplate::class, $organisation]);
$templates = FormTemplate::query()
->orderByDesc('is_system')
->orderBy('name')
->paginate(25);
return FormTemplateResource::collection($templates);
}
public function show(Organisation $organisation, FormTemplate $formTemplate): JsonResponse
{
$this->assertSameOrg($organisation, $formTemplate);
Gate::authorize('view', $formTemplate);
return $this->success(new FormTemplateResource($formTemplate));
}
public function store(StoreFormTemplateRequest $request, Organisation $organisation): JsonResponse
{
Gate::authorize('create', [FormTemplate::class, $organisation]);
$data = $request->validated();
$data['organisation_id'] = $organisation->id;
$data['is_system'] = false;
$data['slug'] ??= Str::slug($data['name']);
$data['is_active'] ??= true;
/** @var FormTemplate $template */
$template = FormTemplate::create($data);
return $this->created(new FormTemplateResource($template));
}
public function update(UpdateFormTemplateRequest $request, Organisation $organisation, FormTemplate $formTemplate): JsonResponse
{
$this->assertSameOrg($organisation, $formTemplate);
Gate::authorize('update', $formTemplate);
$formTemplate->fill($request->validated());
$formTemplate->save();
return $this->success(new FormTemplateResource($formTemplate));
}
public function destroy(Organisation $organisation, FormTemplate $formTemplate): JsonResponse
{
$this->assertSameOrg($organisation, $formTemplate);
Gate::authorize('deactivate', $formTemplate);
$formTemplate->is_active = false;
$formTemplate->save();
return response()->json(null, 204);
}
private function assertSameOrg(Organisation $organisation, FormTemplate $template): void
{
if ($template->organisation_id !== $organisation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\FormBuilder;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\FormBuilder\UpsertFormValuesRequest;
use App\Http\Resources\FormBuilder\FormSubmissionResource;
use App\Models\FormBuilder\FormSubmission;
use App\Models\Organisation;
use App\Services\FormBuilder\FormSubmissionService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class FormValueController extends Controller
{
public function __construct(
private readonly FormSubmissionService $service,
) {}
public function upsert(UpsertFormValuesRequest $request, Organisation $organisation, FormSubmission $formSubmission): JsonResponse
{
$this->assertSubmissionOrg($organisation, $formSubmission);
Gate::authorize('update', $formSubmission);
try {
$submission = $this->service->saveDraft(
$formSubmission,
(array) $request->validated('values'),
$request->user(),
);
} catch (AuthorizationException $e) {
return $this->error($e->getMessage(), 403);
}
return $this->success(new FormSubmissionResource($submission));
}
private function assertSubmissionOrg(Organisation $organisation, FormSubmission $submission): void
{
if ($submission->schema === null || $submission->schema->organisation_id !== $organisation->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,146 @@
<?php
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\FormBuilder\FormSchema;
use App\Services\FormBuilder\FormSubmissionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\RateLimiter;
final class PublicFormController extends Controller
{
public function __construct(
private readonly FormSubmissionService $submissionService,
) {}
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);
}
return $this->success(new PublicFormSchemaResource($schema));
}
public function submit(PublicSubmissionRequest $request, 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);
}
$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);
}
$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
{
$grace = null;
$current = FormSchema::query()->where('public_token', $token)->first();
if ($current !== null) {
$grace = 'current';
return $current;
}
$previous = FormSchema::query()->where('public_token_previous', $token)->first();
if ($previous === 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;
}
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;
}
}
}