From 65070faf470dfeaf1552a96d6192fc28a52328dd Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 21:18:06 +0200 Subject: [PATCH] feat(form-builder): controllers and routes (auth + public token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../FormBuilder/FilterRegistryController.php | 87 +++++++++++ .../V1/FormBuilder/FormFieldController.php | 135 ++++++++++++++++ .../FormFieldLibraryController.php | 84 ++++++++++ .../V1/FormBuilder/FormSchemaController.php | 146 ++++++++++++++++++ .../FormSchemaWebhookController.php | 85 ++++++++++ .../FormBuilder/FormSubmissionController.php | 128 +++++++++++++++ .../FormSubmissionDelegationController.php | 67 ++++++++ .../FormSubmissionReviewController.php | 44 ++++++ .../V1/FormBuilder/FormTemplateController.php | 84 ++++++++++ .../V1/FormBuilder/FormValueController.php | 47 ++++++ .../V1/FormBuilder/PublicFormController.php | 146 ++++++++++++++++++ api/routes/api.php | 75 +++++++++ 12 files changed, 1128 insertions(+) create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FilterRegistryController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaWebhookController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionDelegationController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionReviewController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormTemplateController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/FormValueController.php create mode 100644 api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FilterRegistryController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FilterRegistryController.php new file mode 100644 index 00000000..48cb9bad --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FilterRegistryController.php @@ -0,0 +1,87 @@ +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> + */ + 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; + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldController.php new file mode 100644 index 00000000..8a29ff7c --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldController.php @@ -0,0 +1,135 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php new file mode 100644 index 00000000..35445199 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormFieldLibraryController.php @@ -0,0 +1,84 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaController.php new file mode 100644 index 00000000..bf88a3fd --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaController.php @@ -0,0 +1,146 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaWebhookController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaWebhookController.php new file mode 100644 index 00000000..3e793f98 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSchemaWebhookController.php @@ -0,0 +1,85 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionController.php new file mode 100644 index 00000000..9df62aca --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionController.php @@ -0,0 +1,128 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionDelegationController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionDelegationController.php new file mode 100644 index 00000000..0019c37a --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionDelegationController.php @@ -0,0 +1,67 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionReviewController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionReviewController.php new file mode 100644 index 00000000..f3ff71bb --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionReviewController.php @@ -0,0 +1,44 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormTemplateController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormTemplateController.php new file mode 100644 index 00000000..c3ed00cd --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormTemplateController.php @@ -0,0 +1,84 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormValueController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormValueController.php new file mode 100644 index 00000000..8e846045 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormValueController.php @@ -0,0 +1,47 @@ +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); + } + } +} diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php new file mode 100644 index 00000000..7186ee7c --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/PublicFormController.php @@ -0,0 +1,146 @@ +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; + } + } +} diff --git a/api/routes/api.php b/api/routes/api.php index 431de7ac..1087dac9 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -42,6 +42,17 @@ use App\Http\Controllers\Api\V1\Admin\AdminImpersonationController; use App\Http\Controllers\Api\V1\Auth\MfaSetupController; use App\Http\Controllers\Api\V1\Auth\MfaVerifyController; use App\Http\Controllers\Api\V1\Auth\TrustedDeviceController; +use App\Http\Controllers\Api\V1\FormBuilder\FilterRegistryController; +use App\Http\Controllers\Api\V1\FormBuilder\FormFieldController; +use App\Http\Controllers\Api\V1\FormBuilder\FormFieldLibraryController; +use App\Http\Controllers\Api\V1\FormBuilder\FormSchemaController; +use App\Http\Controllers\Api\V1\FormBuilder\FormSchemaWebhookController; +use App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionController; +use App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionDelegationController; +use App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionReviewController; +use App\Http\Controllers\Api\V1\FormBuilder\FormTemplateController; +use App\Http\Controllers\Api\V1\FormBuilder\FormValueController; +use App\Http\Controllers\Api\V1\FormBuilder\PublicFormController; use App\Models\FestivalSection; use App\Models\Organisation; use Illuminate\Support\Facades\Gate; @@ -86,6 +97,12 @@ Route::post('verify-email-change', [EmailChangeController::class, 'verify']); Route::post('public/check-email', CheckEmailController::class)->middleware('throttle:10,1'); Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1'); +// Public Form Builder routes (no auth — token-based, rate-limited per ARCH §10) +Route::middleware('throttle:30,1')->group(function (): void { + Route::get('public/forms/{public_token}', [PublicFormController::class, 'show']); + Route::post('public/forms/{public_token}/submissions', [PublicFormController::class, 'submit']); +}); + // Platform Admin routes Route::prefix('admin') ->middleware(['auth:sanctum', 'impersonation', 'role:super_admin']) @@ -286,5 +303,63 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { Route::post('crowd-lists/{crowdList}/persons', [CrowdListController::class, 'addPerson']); Route::delete('crowd-lists/{crowdList}/persons/{person}', [CrowdListController::class, 'removePerson']); }); + + // Form Builder (ARCH-FORM-BUILDER.md) + Route::prefix('forms')->group(function (): void { + // Filter registry + Route::get('filter-registry', [FilterRegistryController::class, 'show']); + + // Form schemas + Route::get('schemas', [FormSchemaController::class, 'index']); + Route::post('schemas', [FormSchemaController::class, 'store']); + Route::get('schemas/{form_schema}', [FormSchemaController::class, 'show']); + Route::put('schemas/{form_schema}', [FormSchemaController::class, 'update']); + Route::delete('schemas/{form_schema}', [FormSchemaController::class, 'destroy']); + Route::post('schemas/{form_schema}/duplicate', [FormSchemaController::class, 'duplicate']); + Route::post('schemas/{form_schema}/publish', [FormSchemaController::class, 'publish']); + Route::post('schemas/{form_schema}/unpublish', [FormSchemaController::class, 'unpublish']); + Route::post('schemas/{form_schema}/rotate-public-token', [FormSchemaController::class, 'rotatePublicToken']); + Route::post('schemas/{form_schema}/edit-lock', [FormSchemaController::class, 'acquireEditLock']); + Route::delete('schemas/{form_schema}/edit-lock', [FormSchemaController::class, 'releaseEditLock']); + + // Form fields (scoped under schema) + Route::get('schemas/{form_schema}/fields', [FormFieldController::class, 'index']); + Route::post('schemas/{form_schema}/fields', [FormFieldController::class, 'store']); + Route::post('schemas/{form_schema}/fields/reorder', [FormFieldController::class, 'reorder']); + Route::post('schemas/{form_schema}/fields/insert-from-library', [FormFieldController::class, 'insertFromLibrary']); + Route::put('schemas/{form_schema}/fields/{form_field}', [FormFieldController::class, 'update']); + Route::delete('schemas/{form_schema}/fields/{form_field}', [FormFieldController::class, 'destroy']); + + // Form submissions (list under schema, item under /forms/submissions) + Route::get('schemas/{form_schema}/submissions', [FormSubmissionController::class, 'index']); + Route::post('schemas/{form_schema}/submissions', [FormSubmissionController::class, 'store']); + Route::get('submissions/{form_submission}', [FormSubmissionController::class, 'show']); + Route::put('submissions/{form_submission}/field-values', [FormValueController::class, 'upsert']); + Route::post('submissions/{form_submission}/submit', [FormSubmissionController::class, 'submit']); + Route::post('submissions/{form_submission}/review', [FormSubmissionReviewController::class, 'review']); + Route::post('submissions/{form_submission}/delegate', [FormSubmissionDelegationController::class, 'delegate']); + Route::delete('submissions/{form_submission}/delegations/{delegation}', [FormSubmissionDelegationController::class, 'revoke']); + Route::delete('submissions/{form_submission}', [FormSubmissionController::class, 'destroy']); + + // Form templates + Route::get('templates', [FormTemplateController::class, 'index']); + Route::post('templates', [FormTemplateController::class, 'store']); + Route::get('templates/{form_template}', [FormTemplateController::class, 'show']); + Route::put('templates/{form_template}', [FormTemplateController::class, 'update']); + Route::delete('templates/{form_template}', [FormTemplateController::class, 'destroy']); + + // Form field library + Route::get('field-library', [FormFieldLibraryController::class, 'index']); + Route::post('field-library', [FormFieldLibraryController::class, 'store']); + Route::get('field-library/{field_library}', [FormFieldLibraryController::class, 'show']); + Route::put('field-library/{field_library}', [FormFieldLibraryController::class, 'update']); + Route::delete('field-library/{field_library}', [FormFieldLibraryController::class, 'destroy']); + + // Schema webhooks + Route::get('schemas/{form_schema}/webhooks', [FormSchemaWebhookController::class, 'index']); + Route::post('schemas/{form_schema}/webhooks', [FormSchemaWebhookController::class, 'store']); + Route::put('schemas/{form_schema}/webhooks/{webhook}', [FormSchemaWebhookController::class, 'update']); + Route::delete('schemas/{form_schema}/webhooks/{webhook}', [FormSchemaWebhookController::class, 'destroy']); + }); }); });