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

@@ -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']);
});
});
});