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