diff --git a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionActionFailureController.php b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionActionFailureController.php index 8ebb8a1e..8bc12770 100644 --- a/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionActionFailureController.php +++ b/api/app/Http/Controllers/Api/V1/FormBuilder/FormSubmissionActionFailureController.php @@ -9,11 +9,13 @@ use App\Exceptions\FormBuilder\FailureNotRetriableException; use App\Exceptions\FormBuilder\ParentSubmissionGoneException; use App\Http\Controllers\Controller; use App\Http\Requests\FormBuilder\DismissFailureRequest; +use App\Http\Requests\FormBuilder\IndexFailuresRequest; use App\Http\Requests\FormBuilder\ResolveFailureRequest; use App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource; use App\Models\FormBuilder\FormSubmissionActionFailure; use App\Models\Organisation; use App\Services\FormBuilder\FormFailureRetryService; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -28,7 +30,7 @@ use Illuminate\Support\Facades\Gate; */ final class FormSubmissionActionFailureController extends Controller { - public function orgIndex(Organisation $organisation): AnonymousResourceCollection + public function orgIndex(Organisation $organisation, IndexFailuresRequest $request): AnonymousResourceCollection { // RFC V3 IDOR-class — the user must be super_admin OR an // org_admin on THIS specific organisation. Viewing any org's @@ -38,28 +40,64 @@ final class FormSubmissionActionFailureController extends Controller // resolve/dismiss. $this->authorizeViewAnyInOrgOrNotFound($organisation); - $failures = FormSubmissionActionFailure::query() + $query = FormSubmissionActionFailure::query() + ->with($this->indexEagerLoads()) ->whereHas('submission', function ($q) use ($organisation): void { /** @var \Illuminate\Database\Eloquent\Builder<\App\Models\FormBuilder\FormSubmission> $q */ $q->where('organisation_id', $organisation->id); - }) - ->latest('failed_at') - ->paginate(50); + }); + + $this->applyIndexFilters($query, $request); + + $failures = $query->latest('failed_at')->paginate(50)->withQueryString(); return FormSubmissionActionFailureResource::collection($failures); } - public function platformIndex(): AnonymousResourceCollection + public function platformIndex(IndexFailuresRequest $request): AnonymousResourceCollection { Gate::authorize('viewAny', FormSubmissionActionFailure::class); - $failures = FormSubmissionActionFailure::query() - ->latest('failed_at') - ->paginate(50); + $query = FormSubmissionActionFailure::query()->with($this->indexEagerLoads()); + $this->applyIndexFilters($query, $request); + + $failures = $query->latest('failed_at')->paginate(50)->withQueryString(); return FormSubmissionActionFailureResource::collection($failures); } + /** + * Sessie 3c — admin-UI dashboard counters (org-scoped). Same + * tenant gate as orgIndex (RFC V3): denied → 404. + */ + public function orgKpis(Organisation $organisation): JsonResponse + { + $this->authorizeViewAnyInOrgOrNotFound($organisation); + + return response()->json([ + 'data' => $this->buildKpis( + FormSubmissionActionFailure::query() + ->whereHas('submission', function ($q) use ($organisation): void { + /** @var \Illuminate\Database\Eloquent\Builder<\App\Models\FormBuilder\FormSubmission> $q */ + $q->where('organisation_id', $organisation->id); + }), + $organisation, + ), + ]); + } + + /** + * Sessie 3c — platform-wide dashboard counters (super_admin only). + */ + public function platformKpis(): JsonResponse + { + Gate::authorize('viewAny', FormSubmissionActionFailure::class); + + return response()->json([ + 'data' => $this->buildKpis(FormSubmissionActionFailure::query(), null), + ]); + } + public function show(?Organisation $organisation, FormSubmissionActionFailure $formSubmissionActionFailure): FormSubmissionActionFailureResource { // $organisation is bound only on the org-scoped route; null on the @@ -70,6 +108,8 @@ final class FormSubmissionActionFailureController extends Controller unset($organisation); $this->authorizeOrNotFound('view', $formSubmissionActionFailure); + $formSubmissionActionFailure->load($this->detailEagerLoads()); + return new FormSubmissionActionFailureResource($formSubmissionActionFailure); } @@ -97,7 +137,7 @@ final class FormSubmissionActionFailureController extends Controller ], 410); } - return new FormSubmissionActionFailureResource($failure->refresh()); + return new FormSubmissionActionFailureResource($failure->refresh()->load($this->detailEagerLoads())); } public function resolve( @@ -126,7 +166,7 @@ final class FormSubmissionActionFailureController extends Controller $failure->resolved_by_user_id = $request->user()?->id; $failure->save(); - return new FormSubmissionActionFailureResource($failure->refresh()); + return new FormSubmissionActionFailureResource($failure->refresh()->load($this->detailEagerLoads())); } public function dismiss( @@ -156,7 +196,118 @@ final class FormSubmissionActionFailureController extends Controller $failure->dismissed_by_user_id = $request->user()?->id; $failure->save(); - return new FormSubmissionActionFailureResource($failure->refresh()); + return new FormSubmissionActionFailureResource($failure->refresh()->load($this->detailEagerLoads())); + } + + /** + * Sessie 3c — relations needed by the index Resource. + * Tight set: org+schema labels and the two action-actor user names. + * Skip retryAttempts on the index for payload size; show endpoint + * lazy-loads it via {@see detailEagerLoads()}. + * + * @return array + */ + private function indexEagerLoads(): array + { + return [ + 'submission:id,form_schema_id,organisation_id', + 'submission.organisation:id,name', + 'submission.schema:id,name', + 'resolvedBy:id,first_name,last_name', + 'dismissedBy:id,first_name,last_name', + ]; + } + + /** + * Sessie 3c — relations for the detail Resource. Adds retryAttempts + * with attempted-by user for the timeline. + * + * @return array + */ + private function detailEagerLoads(): array + { + return [ + 'submission:id,form_schema_id,organisation_id', + 'submission.organisation:id,name', + 'submission.schema:id,name', + 'resolvedBy:id,first_name,last_name', + 'dismissedBy:id,first_name,last_name', + 'retryAttempts', + 'retryAttempts.attemptedBy:id,first_name,last_name', + ]; + } + + /** + * Sessie 3c — KPI aggregate (open / resolved 30d / dismissed 30d / + * total submissions). The 30d windows are computed via aggregate + * SQL (single COUNT per metric) so the cost stays O(1) on indexed + * columns regardless of org volume. + * + * `total_submissions` is org-scoped when an organisation is given; + * platform-wide otherwise. We re-query FormSubmission rather than + * derive it from the failures query because total submissions is + * unrelated to the failure rowset. + * + * @param Builder $query + * @return array{open:int, resolved_30d:int, dismissed_30d:int, total_submissions:int} + */ + private function buildKpis(Builder $query, ?Organisation $organisation): array + { + $thirtyDaysAgo = now()->subDays(30); + + $open = (clone $query)->whereNull('resolved_at')->whereNull('dismissed_at')->count(); + $resolved30d = (clone $query)->where('resolved_at', '>=', $thirtyDaysAgo)->count(); + $dismissed30d = (clone $query)->where('dismissed_at', '>=', $thirtyDaysAgo)->count(); + + $submissionsQuery = \App\Models\FormBuilder\FormSubmission::query(); + if ($organisation instanceof Organisation) { + $submissionsQuery->where('organisation_id', $organisation->id); + } + + return [ + 'open' => $open, + 'resolved_30d' => $resolved30d, + 'dismissed_30d' => $dismissed30d, + 'total_submissions' => $submissionsQuery->count(), + ]; + } + + /** + * Sessie 3c — apply admin-UI filters to the failure query. + * + * Default state = `open`. Search runs case-insensitive substring + * match on `exception_message`. Date range is half-open by default + * (Laravel `whereBetween` is inclusive on both ends — sufficient + * for ISO date inputs from the date-picker UI). + * + * @param Builder $query + */ + private function applyIndexFilters(Builder $query, IndexFailuresRequest $request): void + { + $state = (string) ($request->validated('state') ?? 'open'); + match ($state) { + 'open' => $query->whereNull('resolved_at')->whereNull('dismissed_at'), + 'resolved' => $query->whereNotNull('resolved_at'), + 'dismissed' => $query->whereNotNull('dismissed_at'), + 'all' => null, + default => null, + }; + + if (($search = $request->validated('search')) !== null && $search !== '') { + $like = '%'.str_replace(['%', '_'], ['\\%', '\\_'], (string) $search).'%'; + $query->where('exception_message', 'like', $like); + } + + if (($from = $request->validated('failed_at_from')) !== null) { + $query->where('failed_at', '>=', $from); + } + if (($to = $request->validated('failed_at_to')) !== null) { + $query->where('failed_at', '<=', $to); + } + + if (($listener = $request->validated('listener_class')) !== null && $listener !== '') { + $query->where('listener_class', $listener); + } } /** diff --git a/api/app/Http/Requests/FormBuilder/IndexFailuresRequest.php b/api/app/Http/Requests/FormBuilder/IndexFailuresRequest.php new file mode 100644 index 00000000..1f7e85d8 --- /dev/null +++ b/api/app/Http/Requests/FormBuilder/IndexFailuresRequest.php @@ -0,0 +1,34 @@ + + */ + public function rules(): array + { + return [ + 'state' => ['nullable', 'string', 'in:open,resolved,dismissed,all'], + 'search' => ['nullable', 'string', 'max:255'], + 'failed_at_from' => ['nullable', 'date'], + 'failed_at_to' => ['nullable', 'date', 'after_or_equal:failed_at_from'], + 'listener_class' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php b/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php index afad008e..e7e6f091 100644 --- a/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php +++ b/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php @@ -4,11 +4,19 @@ declare(strict_types=1); namespace App\Http\Resources\FormBuilder; +use App\Models\FormBuilder\FormSchema; +use App\Models\FormBuilder\FormSubmission; use App\Models\FormBuilder\FormSubmissionActionFailure; +use App\Models\Organisation; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** + * Sessie 3c (WS-6) — admin UI consumes denormalized labels + * (`organisation_name`, `form_schema_label`, user names) plus a full + * stack trace and per-attempt retry history. The relations are + * eager-loaded by the controller via {@see FormSubmissionActionFailure::loadAdminListContext()}. + * * @mixin FormSubmissionActionFailure */ final class FormSubmissionActionFailureResource extends JsonResource @@ -18,6 +26,13 @@ final class FormSubmissionActionFailureResource extends JsonResource */ public function toArray(Request $request): array { + /** @var FormSubmission|null $submission */ + $submission = $this->submission; + /** @var Organisation|null $organisation */ + $organisation = $submission?->organisation; + /** @var FormSchema|null $schema */ + $schema = $submission?->schema; + return [ 'id' => $this->id, 'form_submission_id' => $this->form_submission_id, @@ -26,11 +41,16 @@ final class FormSubmissionActionFailureResource extends JsonResource 'failed_at' => $this->failed_at->toIso8601String(), 'exception_class' => $this->exception_class, 'exception_message' => $this->exception_message, + 'exception_trace' => $this->exception_trace, 'context' => $this->context, 'retry_count' => $this->retry_count, 'resolved_at' => $this->resolved_at?->toIso8601String(), + 'resolved_by_user_id' => $this->resolved_by_user_id, + 'resolved_by_user_name' => $this->resolvedBy?->name, 'resolved_note' => $this->resolved_note, 'dismissed_at' => $this->dismissed_at?->toIso8601String(), + 'dismissed_by_user_id' => $this->dismissed_by_user_id, + 'dismissed_by_user_name' => $this->dismissedBy?->name, 'dismissed_reason_type' => $this->dismissed_reason_type?->value, 'dismissed_reason_note' => $this->dismissed_reason_note, 'state' => match (true) { @@ -38,6 +58,13 @@ final class FormSubmissionActionFailureResource extends JsonResource $this->dismissed_at !== null => 'dismissed', default => 'open', }, + 'organisation_id' => $organisation?->id, + 'organisation_name' => $organisation?->name, + 'form_schema_id' => $schema?->id, + 'form_schema_label' => $schema?->name, + 'retry_history' => FormSubmissionActionFailureRetryAttemptResource::collection( + $this->whenLoaded('retryAttempts'), + ), ]; } } diff --git a/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureRetryAttemptResource.php b/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureRetryAttemptResource.php new file mode 100644 index 00000000..0c1bbf06 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureRetryAttemptResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'attempted_at' => $this->attempted_at->toIso8601String(), + 'attempted_by_user_id' => $this->attempted_by_user_id, + 'attempted_by_user_name' => $this->attemptedBy?->name, + 'outcome' => $this->outcome, + 'exception_class' => $this->exception_class, + 'exception_message' => $this->exception_message, + ]; + } +} diff --git a/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php index aba9952f..e6a8fd12 100644 --- a/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php +++ b/api/app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php @@ -71,6 +71,7 @@ final readonly class ApplyBindingsOnFormSubmit 'failed_at' => now(), 'exception_class' => $e::class, 'exception_message' => $e->getMessage(), + 'exception_trace' => $e->getTraceAsString(), 'context' => [ 'purpose' => $purposeValue, ], diff --git a/api/app/Models/FormBuilder/FormSubmissionActionFailure.php b/api/app/Models/FormBuilder/FormSubmissionActionFailure.php index 9febdc57..415e3572 100644 --- a/api/app/Models/FormBuilder/FormSubmissionActionFailure.php +++ b/api/app/Models/FormBuilder/FormSubmissionActionFailure.php @@ -42,6 +42,7 @@ final class FormSubmissionActionFailure extends Model 'failed_at', 'exception_class', 'exception_message', + 'exception_trace', 'context', 'retry_count', 'resolved_at', diff --git a/api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php b/api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php index bedd9439..93f7d0d5 100644 --- a/api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php +++ b/api/database/factories/FormBuilder/FormSubmissionActionFailureFactory.php @@ -23,6 +23,7 @@ final class FormSubmissionActionFailureFactory extends Factory 'failed_at' => now(), 'exception_class' => \RuntimeException::class, 'exception_message' => 'Simulated apply failure', + 'exception_trace' => "#0 /app/Listeners/FormBuilder/ApplyBindingsOnFormSubmit.php(63): SimulatedException::throw()\n#1 [internal function]: Listener->handle()", 'context' => [ 'target_entity' => 'person', 'target_attribute' => 'email', diff --git a/api/database/migrations/2026_04_28_181000_add_exception_trace_to_form_submission_action_failures.php b/api/database/migrations/2026_04_28_181000_add_exception_trace_to_form_submission_action_failures.php new file mode 100644 index 00000000..8fc3666a --- /dev/null +++ b/api/database/migrations/2026_04_28_181000_add_exception_trace_to_form_submission_action_failures.php @@ -0,0 +1,31 @@ +longText('exception_trace')->nullable()->after('exception_message'); + }); + } + + public function down(): void + { + Schema::table('form_submission_action_failures', function (Blueprint $table): void { + $table->dropColumn('exception_trace'); + }); + } +}; diff --git a/api/database/schema/mysql-schema.sql b/api/database/schema/mysql-schema.sql index 4efae0f6..190c5959 100644 --- a/api/database/schema/mysql-schema.sql +++ b/api/database/schema/mysql-schema.sql @@ -628,13 +628,13 @@ DROP TABLE IF EXISTS `form_submission_action_failure_retry_attempts`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `form_submission_action_failure_retry_attempts` ( - `id` char(26) COLLATE utf8mb4_unicode_ci NOT NULL, - `form_submission_action_failure_id` char(26) COLLATE utf8mb4_unicode_ci NOT NULL, + `id` char(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `form_submission_action_failure_id` char(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `attempted_at` timestamp NOT NULL, - `attempted_by_user_id` char(26) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `outcome` enum('succeeded','failed') COLLATE utf8mb4_unicode_ci NOT NULL, - `exception_class` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, - `exception_message` text COLLATE utf8mb4_unicode_ci, + `attempted_by_user_id` char(26) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outcome` enum('succeeded','failed') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `exception_class` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `exception_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, `created_at` timestamp NULL DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), @@ -655,6 +655,7 @@ CREATE TABLE `form_submission_action_failures` ( `failed_at` timestamp NOT NULL, `exception_class` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `exception_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `exception_trace` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, `context` json NOT NULL, `retry_count` tinyint unsigned NOT NULL DEFAULT '0', `resolved_at` timestamp NULL DEFAULT NULL, @@ -1753,21 +1754,22 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (104,'2026_04_24_20 INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (105,'2026_04_25_015838_create_telescope_entries_table',1); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (106,'2026_04_25_100000_create_form_field_bindings_table',1); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (107,'2026_04_25_100001_drop_binding_json_columns',1); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (140,'2026_04_25_110000_create_form_field_validation_rules_table',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (141,'2026_04_25_110001_backfill_form_field_validation_rules',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (142,'2026_04_25_120000_create_form_field_configs_table',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (143,'2026_04_25_120001_backfill_form_field_configs',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (144,'2026_04_25_120002_drop_validation_rules_json_columns',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (145,'2026_04_25_140000_extend_form_submissions_with_apply_status',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (146,'2026_04_25_140100_create_form_submission_action_failures',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (147,'2026_04_26_100000_create_form_field_conditional_logic_groups_table',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (148,'2026_04_26_100001_create_form_field_conditional_logic_conditions_table',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (149,'2026_04_26_100002_backfill_form_field_conditional_logic',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (150,'2026_04_26_100003_drop_conditional_logic_json_column',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (151,'2026_04_26_120000_add_default_crowd_type_id_to_form_schemas',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (152,'2026_04_27_100000_create_form_field_options_table',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (153,'2026_04_27_100001_backfill_form_field_options',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (154,'2026_04_27_100002_drop_form_field_options_json_columns',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (155,'2026_04_28_100000_restore_default_crowd_type_id_foreign_key',2); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (156,'2026_04_28_140000_add_kvk_number_to_companies_table',3); -INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (157,'2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table',4); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (108,'2026_04_25_110000_create_form_field_validation_rules_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (109,'2026_04_25_110001_backfill_form_field_validation_rules',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (110,'2026_04_25_120000_create_form_field_configs_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (111,'2026_04_25_120001_backfill_form_field_configs',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (112,'2026_04_25_120002_drop_validation_rules_json_columns',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (113,'2026_04_25_140000_extend_form_submissions_with_apply_status',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (114,'2026_04_25_140100_create_form_submission_action_failures',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (115,'2026_04_26_100000_create_form_field_conditional_logic_groups_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (116,'2026_04_26_100001_create_form_field_conditional_logic_conditions_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (117,'2026_04_26_100002_backfill_form_field_conditional_logic',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (118,'2026_04_26_100003_drop_conditional_logic_json_column',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (119,'2026_04_26_120000_add_default_crowd_type_id_to_form_schemas',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (120,'2026_04_27_100000_create_form_field_options_table',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (121,'2026_04_27_100001_backfill_form_field_options',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (122,'2026_04_27_100002_drop_form_field_options_json_columns',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (123,'2026_04_28_100000_restore_default_crowd_type_id_foreign_key',1); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (124,'2026_04_28_140000_add_kvk_number_to_companies_table',2); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (125,'2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table',2); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (126,'2026_04_28_181000_add_exception_trace_to_form_submission_action_failures',2); diff --git a/api/routes/api.php b/api/routes/api.php index 4864a992..412c70d1 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -147,6 +147,7 @@ Route::prefix('admin') // RFC-WS-6 §3 (Q5) — platform-wide form-failure admin endpoints. Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'platformIndex']); + Route::get('form-failures/kpis', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'platformKpis']); Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show']); Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry']); Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve']); @@ -248,6 +249,7 @@ Route::middleware(['auth:sanctum', 'impersonation'])->group(function () { // Organisation has no formSubmissionActionFailures() relation; // the policy's FK-chain check is the tenant gate. Route::get('form-failures', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'orgIndex']); + Route::get('form-failures/kpis', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'orgKpis']); Route::get('form-failures/{formSubmissionActionFailure}', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'show'])->withoutScopedBindings(); Route::post('form-failures/{formSubmissionActionFailure}/retry', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'retry'])->withoutScopedBindings(); Route::post('form-failures/{formSubmissionActionFailure}/resolve', [\App\Http\Controllers\Api\V1\FormBuilder\FormSubmissionActionFailureController::class, 'resolve'])->withoutScopedBindings(); diff --git a/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureFilterTest.php b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureFilterTest.php new file mode 100644 index 00000000..cb777187 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureFilterTest.php @@ -0,0 +1,247 @@ +seed(RoleSeeder::class); + + $this->orgA = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $this->orgA->id]); + $this->submission = FormSubmission::factory()->create([ + 'form_schema_id' => $schema->id, + 'organisation_id' => $this->orgA->id, + ]); + + $this->orgAdminA = User::factory()->create(); + $this->orgA->users()->attach($this->orgAdminA, ['role' => 'org_admin']); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + } + + /** + * @param array $attrs + */ + private function makeFailure(array $attrs = []): FormSubmissionActionFailure + { + return FormSubmissionActionFailure::factory() + ->for($this->submission, 'submission') + ->create($attrs); + } + + private function orgUrl(string $query = ''): string + { + return "/api/v1/organisations/{$this->orgA->id}/form-failures".($query !== '' ? "?{$query}" : ''); + } + + public function test_default_state_filter_returns_only_open(): void + { + $open = $this->makeFailure(); + $this->makeFailure(['resolved_at' => now()]); + $this->makeFailure(['dismissed_at' => now(), 'dismissed_reason_type' => 'schema_deleted']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson($this->orgUrl())->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $open->id], $ids); + } + + public function test_state_resolved_filter(): void + { + $this->makeFailure(); + $resolved = $this->makeFailure(['resolved_at' => now()]); + $this->makeFailure(['dismissed_at' => now(), 'dismissed_reason_type' => 'schema_deleted']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson($this->orgUrl('state=resolved'))->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $resolved->id], $ids); + } + + public function test_state_dismissed_filter(): void + { + $this->makeFailure(); + $this->makeFailure(['resolved_at' => now()]); + $dismissed = $this->makeFailure(['dismissed_at' => now(), 'dismissed_reason_type' => 'schema_deleted']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson($this->orgUrl('state=dismissed'))->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $dismissed->id], $ids); + } + + public function test_state_all_returns_every_row(): void + { + $this->makeFailure(); + $this->makeFailure(['resolved_at' => now()]); + $this->makeFailure(['dismissed_at' => now(), 'dismissed_reason_type' => 'schema_deleted']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson($this->orgUrl('state=all'))->assertOk(); + + $this->assertCount(3, $response->json('data')); + } + + public function test_search_matches_exception_message_substring_case_insensitive(): void + { + $hit = $this->makeFailure(['exception_message' => 'Person provisioning failed: NO_DEFAULT_CROWD_TYPE']); + $this->makeFailure(['exception_message' => 'Some other unrelated error']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson($this->orgUrl('search=crowd_type'))->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $hit->id], $ids); + } + + public function test_search_with_sql_wildcards_is_escaped(): void + { + $this->makeFailure(['exception_message' => 'literal % percent in message']); + $this->makeFailure(['exception_message' => 'no wildcard here']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson($this->orgUrl('search=%25'))->assertOk(); + + $messages = array_column((array) $response->json('data'), 'exception_message'); + $this->assertCount(1, $messages); + $this->assertStringContainsString('%', (string) $messages[0]); + } + + public function test_failed_at_from_and_to_inclusive(): void + { + $old = $this->makeFailure(['failed_at' => '2026-01-01 12:00:00']); + $mid = $this->makeFailure(['failed_at' => '2026-02-15 12:00:00']); + $new = $this->makeFailure(['failed_at' => '2026-03-30 12:00:00']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this + ->getJson($this->orgUrl('failed_at_from=2026-02-01&failed_at_to=2026-03-01')) + ->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $mid->id], $ids); + $this->assertNotContains((string) $old->id, $ids); + $this->assertNotContains((string) $new->id, $ids); + } + + public function test_listener_class_exact_match(): void + { + $hit = $this->makeFailure(['listener_class' => ApplyBindingsOnFormSubmit::class]); + $this->makeFailure(['listener_class' => 'App\\Listeners\\Other\\Listener']); + + Sanctum::actingAs($this->orgAdminA); + $response = $this + ->getJson($this->orgUrl('listener_class='.urlencode(ApplyBindingsOnFormSubmit::class))) + ->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $hit->id], $ids); + } + + public function test_filters_combine_with_and_semantics(): void + { + $match = $this->makeFailure([ + 'exception_message' => 'crowd_type missing', + 'failed_at' => '2026-02-15 12:00:00', + 'listener_class' => ApplyBindingsOnFormSubmit::class, + ]); + $this->makeFailure([ + 'exception_message' => 'crowd_type missing', + 'failed_at' => '2025-01-01 12:00:00', + 'listener_class' => ApplyBindingsOnFormSubmit::class, + ]); + $this->makeFailure([ + 'exception_message' => 'unrelated', + 'failed_at' => '2026-02-15 12:00:00', + 'listener_class' => ApplyBindingsOnFormSubmit::class, + ]); + + Sanctum::actingAs($this->orgAdminA); + $response = $this + ->getJson($this->orgUrl('search=crowd_type&failed_at_from=2026-01-01&failed_at_to=2026-12-31&listener_class='.urlencode(ApplyBindingsOnFormSubmit::class))) + ->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $match->id], $ids); + } + + public function test_invalid_state_returns_422(): void + { + Sanctum::actingAs($this->orgAdminA); + $this->getJson($this->orgUrl('state=bogus')) + ->assertStatus(422) + ->assertJsonValidationErrors(['state']); + } + + public function test_invalid_date_range_returns_422(): void + { + Sanctum::actingAs($this->orgAdminA); + $this + ->getJson($this->orgUrl('failed_at_from=2026-03-01&failed_at_to=2026-01-01')) + ->assertStatus(422) + ->assertJsonValidationErrors(['failed_at_to']); + } + + public function test_platform_index_applies_same_filters(): void + { + $open = $this->makeFailure(); + $this->makeFailure(['resolved_at' => now()]); + + Sanctum::actingAs($this->superAdmin); + $response = $this->getJson('/api/v1/admin/form-failures?state=open')->assertOk(); + + $ids = array_column((array) $response->json('data'), 'id'); + $this->assertSame([(string) $open->id], $ids); + } + + public function test_pagination_preserves_query_string(): void + { + for ($i = 0; $i < 3; $i++) { + $this->makeFailure(['resolved_at' => now()]); + } + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson($this->orgUrl('state=resolved&search=Simulated'))->assertOk(); + + $firstPageUrl = (string) $response->json('links.first'); + $this->assertStringContainsString('state=resolved', $firstPageUrl); + $this->assertStringContainsString('search=Simulated', $firstPageUrl); + } +} diff --git a/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureKpisTest.php b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureKpisTest.php new file mode 100644 index 00000000..1ed3d988 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureKpisTest.php @@ -0,0 +1,148 @@ +seed(RoleSeeder::class); + + $this->orgA = Organisation::factory()->create(); + $this->orgB = Organisation::factory()->create(); + + $schemaA = FormSchema::factory()->create(['organisation_id' => $this->orgA->id]); + $schemaB = FormSchema::factory()->create(['organisation_id' => $this->orgB->id]); + $this->submissionA = FormSubmission::factory()->create([ + 'form_schema_id' => $schemaA->id, + 'organisation_id' => $this->orgA->id, + ]); + $this->submissionB = FormSubmission::factory()->create([ + 'form_schema_id' => $schemaB->id, + 'organisation_id' => $this->orgB->id, + ]); + + $this->orgAdminA = User::factory()->create(); + $this->orgA->users()->attach($this->orgAdminA, ['role' => 'org_admin']); + $this->orgAdminB = User::factory()->create(); + $this->orgB->users()->attach($this->orgAdminB, ['role' => 'org_admin']); + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + } + + public function test_org_kpis_counts_open_resolved_dismissed_and_total_submissions(): void + { + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission')->create(); + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission')->create(); + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission') + ->create(['resolved_at' => now()->subDays(5)]); + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission') + ->create(['dismissed_at' => now()->subDays(10), 'dismissed_reason_type' => 'schema_deleted']); + + // Outside-30d window — must NOT count. + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission') + ->create(['resolved_at' => now()->subDays(45)]); + + // Other tenant — must NOT count. + FormSubmissionActionFailure::factory()->for($this->submissionB, 'submission')->create(); + + Sanctum::actingAs($this->orgAdminA); + $response = $this->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/kpis")->assertOk(); + + $response->assertJsonPath('data.open', 2); + $response->assertJsonPath('data.resolved_30d', 1); + $response->assertJsonPath('data.dismissed_30d', 1); + $response->assertJsonPath('data.total_submissions', 1); + } + + public function test_org_kpis_cross_tenant_returns_404(): void + { + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission')->create(); + + Sanctum::actingAs($this->orgAdminB); + $this->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/kpis") + ->assertStatus(404); + } + + public function test_org_kpis_unauthenticated_returns_401(): void + { + $this->getJson("/api/v1/organisations/{$this->orgA->id}/form-failures/kpis") + ->assertStatus(401); + } + + public function test_platform_kpis_aggregates_across_all_orgs(): void + { + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission')->create(); + FormSubmissionActionFailure::factory()->for($this->submissionB, 'submission')->create(); + FormSubmissionActionFailure::factory()->for($this->submissionA, 'submission') + ->create(['resolved_at' => now()->subDays(2)]); + + Sanctum::actingAs($this->superAdmin); + $response = $this->getJson('/api/v1/admin/form-failures/kpis')->assertOk(); + + $response->assertJsonPath('data.open', 2); + $response->assertJsonPath('data.resolved_30d', 1); + $response->assertJsonPath('data.dismissed_30d', 0); + $response->assertJsonPath('data.total_submissions', 2); + } + + public function test_platform_kpis_org_admin_returns_403(): void + { + Sanctum::actingAs($this->orgAdminA); + $this->getJson('/api/v1/admin/form-failures/kpis') + ->assertStatus(403); + } + + public function test_platform_kpis_unauthenticated_returns_401(): void + { + $this->getJson('/api/v1/admin/form-failures/kpis')->assertStatus(401); + } + + public function test_kpis_with_no_data_returns_zeros(): void + { + $orgC = Organisation::factory()->create(); + $orgAdminC = User::factory()->create(); + $orgC->users()->attach($orgAdminC, ['role' => 'org_admin']); + + Sanctum::actingAs($orgAdminC); + $response = $this->getJson("/api/v1/organisations/{$orgC->id}/form-failures/kpis")->assertOk(); + + $response->assertJsonPath('data.open', 0); + $response->assertJsonPath('data.resolved_30d', 0); + $response->assertJsonPath('data.dismissed_30d', 0); + $response->assertJsonPath('data.total_submissions', 0); + } +} diff --git a/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureResourceShapeTest.php b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureResourceShapeTest.php new file mode 100644 index 00000000..033fc5a2 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Api/FormSubmissionActionFailureResourceShapeTest.php @@ -0,0 +1,160 @@ +seed(RoleSeeder::class); + + $this->orgA = Organisation::factory()->create(['name' => 'Festival X']); + $this->schemaA = FormSchema::factory()->create([ + 'organisation_id' => $this->orgA->id, + 'name' => 'Vrijwilligers aanmelding', + ]); + $this->submissionA = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schemaA->id, + 'organisation_id' => $this->orgA->id, + ]); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + } + + public function test_show_payload_includes_denormalized_labels_and_trace(): void + { + $resolver = User::factory()->create(['first_name' => 'Maud', 'last_name' => 'Admin']); + $failure = FormSubmissionActionFailure::factory() + ->for($this->submissionA, 'submission') + ->create([ + 'exception_trace' => "#0 stack frame\n#1 next frame", + 'resolved_at' => now(), + 'resolved_by_user_id' => $resolver->id, + 'resolved_note' => 'fixed manually', + ]); + + Sanctum::actingAs($this->superAdmin); + $response = $this->getJson("/api/v1/admin/form-failures/{$failure->id}")->assertOk(); + + $response + ->assertJsonPath('data.id', (string) $failure->id) + ->assertJsonPath('data.organisation_id', (string) $this->orgA->id) + ->assertJsonPath('data.organisation_name', 'Festival X') + ->assertJsonPath('data.form_schema_id', (string) $this->schemaA->id) + ->assertJsonPath('data.form_schema_label', 'Vrijwilligers aanmelding') + ->assertJsonPath('data.exception_trace', "#0 stack frame\n#1 next frame") + ->assertJsonPath('data.resolved_by_user_name', 'Maud Admin') + ->assertJsonPath('data.dismissed_by_user_name', null); + } + + public function test_show_payload_includes_retry_history_in_chronological_order(): void + { + $failure = FormSubmissionActionFailure::factory() + ->for($this->submissionA, 'submission') + ->create(); + + $actor = User::factory()->create(['first_name' => 'Alex', 'last_name' => 'Operator']); + + FormSubmissionActionFailureRetryAttempt::factory()->create([ + 'form_submission_action_failure_id' => $failure->id, + 'attempted_at' => now()->subMinutes(10), + 'attempted_by_user_id' => $actor->id, + 'outcome' => 'failed', + 'exception_class' => \RuntimeException::class, + 'exception_message' => 'first retry failed', + ]); + FormSubmissionActionFailureRetryAttempt::factory()->succeeded()->create([ + 'form_submission_action_failure_id' => $failure->id, + 'attempted_at' => now(), + 'attempted_by_user_id' => $actor->id, + ]); + + Sanctum::actingAs($this->superAdmin); + $response = $this->getJson("/api/v1/admin/form-failures/{$failure->id}")->assertOk(); + + $history = $response->json('data.retry_history'); + $this->assertIsArray($history); + $this->assertCount(2, $history); + // latest('attempted_at') in the relation puts the newest first. + $this->assertSame('succeeded', $history[0]['outcome']); + $this->assertSame('failed', $history[1]['outcome']); + $this->assertSame('Alex Operator', $history[0]['attempted_by_user_name']); + $this->assertSame('first retry failed', $history[1]['exception_message']); + } + + public function test_index_payload_omits_retry_history_to_keep_payload_small(): void + { + $failure = FormSubmissionActionFailure::factory() + ->for($this->submissionA, 'submission') + ->create(); + FormSubmissionActionFailureRetryAttempt::factory()->create([ + 'form_submission_action_failure_id' => $failure->id, + ]); + + Sanctum::actingAs($this->superAdmin); + $response = $this->getJson('/api/v1/admin/form-failures')->assertOk(); + + // whenLoaded() => the key resolves to MissingValue and is omitted. + $first = $response->json('data.0'); + $this->assertArrayNotHasKey('retry_history', $first); + $this->assertSame('Festival X', $first['organisation_name']); + $this->assertSame('Vrijwilligers aanmelding', $first['form_schema_label']); + } + + public function test_index_does_not_n_plus_one_on_relations(): void + { + for ($i = 0; $i < 5; $i++) { + $sub = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schemaA->id, + 'organisation_id' => $this->orgA->id, + ]); + FormSubmissionActionFailure::factory()->for($sub, 'submission')->create(); + } + + Sanctum::actingAs($this->superAdmin); + DB::enableQueryLog(); + $this->getJson('/api/v1/admin/form-failures')->assertOk(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + // Eager-loading bound: failures + submissions + organisations + schemas + // + resolvedBy + dismissedBy + count = 7 baseline; allow modest headroom + // for auth/sanctum/pagination overhead but reject linear growth in N. + $this->assertLessThanOrEqual(15, count($queries), sprintf( + 'Index endpoint may have N+1 (executed %d queries for 5 failures): %s', + count($queries), + implode("\n", array_column($queries, 'query')), + )); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 7781388d..97e06d67 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -57,7 +57,7 @@ final class FormFieldBindingMigrationTest extends TestCase // validation-rules-backfill, create-validation-rules) + // 2 WS-6 migrations (action-failures, apply-status) + // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 16. - $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -119,7 +119,7 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { // Walk back the full WS-5d + WS-5c + WS-6 + WS-5b + WS-5a stack (16 migrations). - $this->artisan('migrate:rollback', ['--step' => 20])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 21])->assertSuccessful(); [$fieldAId] = $this->seedFieldsWithBindingJson(); [$libAId] = $this->seedLibraryWithBindingJson(); @@ -134,7 +134,7 @@ final class FormFieldBindingMigrationTest extends TestCase // the pre-WS-5b state (conditional-logic, validation-rules, configs // and options tables gone, validation_rules + options JSON columns // reappear on source tables; binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_options')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 3dfc2b95..044d1655 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -49,7 +49,7 @@ final class ConditionalLogicBackfillTest extends TestCase // create-options + WS-5c drop-cl-col + WS-5c backfill-cl // migrations to land in the conditional-logic JSON-era state with // no relational form_field_options table yet. - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -170,7 +170,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -203,7 +203,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -216,7 +216,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $this->seedFieldWithJson([ 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php index a8beb727..a8b35ede 100644 --- a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase // Roll back 4 WS-5c migrations + 2 WS-6 migrations + 5 WS-5b // migrations = 11, to get the pre-WS-5b state where the JSON column // still exists on form_fields / form_field_library. - $this->artisan('migrate:rollback', ['--step' => 15])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 16])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $fieldId = $this->seedField([ diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php index 3d3bc176..4fc7b045 100644 --- a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -47,7 +47,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Roll back only the backfill migration (latest WS-5d step). // Leaves the form_field_options table in place, JSON columns // present on the source tables, and snapshots in pre-WS-5d shape. - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->assertTrue(Schema::hasTable('form_field_options')); $this->assertTrue(Schema::hasColumn('form_fields', 'options')); @@ -136,7 +136,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_rollback_reconstructs_json_columns_and_snapshots(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); $submissionId = $this->seedSubmissionWithSnapshot($selectId); @@ -149,7 +149,7 @@ final class FormFieldOptionsBackfillTest extends TestCase // Step back over only the backfill migration → JSON columns repopulate // and snapshots revert to flat-string-array shape. - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->assertSame(0, DB::table('form_field_options')->count()); $select = DB::table('form_fields')->where('id', $selectId)->first(); @@ -168,7 +168,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']); $this->expectException(\RuntimeException::class); @@ -178,7 +178,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_contains_non_string_entry(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ ['label' => 'A'], @@ -192,7 +192,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_options_is_object_shape(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode([ 'XS' => 'Extra small', @@ -206,7 +206,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_translations_length_mismatch(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([ 'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 ])); @@ -218,7 +218,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_non_string_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([ 'de' => ['options' => ['Klein', 42]], ])); @@ -230,7 +230,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_on_oversized_translation(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([ 'de' => ['options' => [str_repeat('x', 256)]], ])); @@ -242,7 +242,7 @@ final class FormFieldOptionsBackfillTest extends TestCase public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void { - $this->artisan('migrate:rollback', ['--step' => 5])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 6])->assertSuccessful(); $this->seedTemplateWithSnapshotRaw([ 'fields' => [[ 'id' => (string) Str::ulid(), diff --git a/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php b/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php index ede0082d..042d61b0 100644 --- a/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Schema/Ws6FoundationMigrationTest.php @@ -115,8 +115,15 @@ final class Ws6FoundationMigrationTest extends TestCase $retryAttempts = require database_path( 'migrations/2026_04_28_180000_create_form_submission_action_failure_retry_attempts_table.php', ); + // Sessie 3c also adds exception_trace to the parent table — chain + // its down() before the parent's drop so the column ordering on + // restore matches the production migration order. + $exceptionTrace = require database_path( + 'migrations/2026_04_28_181000_add_exception_trace_to_form_submission_action_failures.php', + ); $retryAttempts->down(); + $exceptionTrace->down(); $createFailures->down(); $applyStatus->down(); @@ -133,6 +140,7 @@ final class Ws6FoundationMigrationTest extends TestCase // Restore state for any subsequent tests in this class. $applyStatus->up(); $createFailures->up(); + $exceptionTrace->up(); $retryAttempts->up(); } } diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 002d6f85..295c5455 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -53,7 +53,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // validation-rules-backfill + create-validation-rules) = 14. // Brings us to the pre-WS-5b state: validation_rules JSON column // present, no relational tables for WS-5b/c/d. - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -114,7 +114,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -138,7 +138,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -165,7 +165,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -182,7 +182,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -201,7 +201,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // full-back-then-full-forward cycle — rolling back all WS-5b // migrations restores the pre-WS-5b state (columns present on // source tables; validation rules relational table gone). - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -216,7 +216,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // Roll back WS-5b fully → column reappears and carries canonical JSON // reconstructed from the relational rows. - $this->artisan('migrate:rollback', ['--step' => 18])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 19])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first(); diff --git a/apps/app/.eslintrc.cjs b/apps/app/.eslintrc.cjs new file mode 100644 index 00000000..a3d12025 --- /dev/null +++ b/apps/app/.eslintrc.cjs @@ -0,0 +1,208 @@ +// Sessie 3c (WS-6) — closes the apps/app ESLint config gap. +// Adapted from the Vuexy reference (resources/vuexy-admin-v10.11.1/.../full-version/.eslintrc.cjs) +// minus the Vuexy-internal lint rules (valid-appcardcode-*, internal regex +// rules) that don't apply outside the demo project. Plugin set matches +// what's installed in apps/app's package.json. +module.exports = { + root: true, + env: { + browser: true, + node: true, + es2022: true, + }, + extends: [ + '@antfu/eslint-config-vue', + 'plugin:vue/vue3-recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:promise/recommended', + 'plugin:sonarjs/recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:case-police/recommended', + 'plugin:regexp/recommended', + ], + parser: 'vue-eslint-parser', + parserOptions: { + ecmaVersion: 13, + parser: '@typescript-eslint/parser', + sourceType: 'module', + }, + plugins: [ + 'vue', + '@typescript-eslint', + 'regex', + 'regexp', + ], + ignorePatterns: [ + 'src/plugins/iconify/*.js', + 'node_modules', + 'dist', + '*.d.ts', + 'vendor', + '*.json', + ], + rules: { + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + + 'comma-spacing': ['error', { before: false, after: true }], + 'key-spacing': ['error', { afterColon: true }], + 'n/prefer-global/process': ['off'], + 'sonarjs/cognitive-complexity': ['off'], + + 'vue/first-attribute-linebreak': ['error', { + singleline: 'beside', + multiline: 'below', + }], + + 'antfu/top-level-function': 'off', + + // Project rule (CLAUDE.md frontend rules): no `any`. Override the + // Vuexy reference (which sets this off) — Crewli's stricter posture. + '@typescript-eslint/no-explicit-any': 'error', + + 'indent': ['error', 2], + 'comma-dangle': ['error', 'always-multiline'], + 'object-curly-spacing': ['error', 'always'], + 'camelcase': 'error', + 'max-len': 'off', + 'semi': ['error', 'never'], + 'arrow-parens': ['error', 'as-needed'], + 'newline-before-return': 'error', + + 'lines-around-comment': [ + 'error', + { + beforeBlockComment: true, + beforeLineComment: true, + allowBlockStart: true, + allowClassStart: true, + allowObjectStart: true, + allowArrayStart: true, + ignorePattern: '!SECTION', + }, + ], + + '@typescript-eslint/no-unused-vars': ['error', { + varsIgnorePattern: '^_+$', + argsIgnorePattern: '^_+$', + }], + + 'array-element-newline': ['error', 'consistent'], + 'array-bracket-newline': ['error', 'consistent'], + + 'vue/multi-word-component-names': 'off', + + 'padding-line-between-statements': [ + 'error', + { blankLine: 'always', prev: 'expression', next: 'const' }, + { blankLine: 'always', prev: 'const', next: 'expression' }, + { blankLine: 'always', prev: 'multiline-const', next: '*' }, + { blankLine: 'always', prev: '*', next: 'multiline-const' }, + ], + + 'import/prefer-default-export': 'off', + 'import/newline-after-import': ['error', { count: 1 }], + 'no-restricted-imports': ['error', 'vuetify/components', { + name: 'vue3-apexcharts', + message: 'apexcharts are auto imported', + }], + + 'import/extensions': [ + 'error', + 'ignorePackages', + { + js: 'never', + jsx: 'never', + ts: 'never', + tsx: 'never', + }, + ], + + 'import/no-unresolved': [2, { + ignore: [ + '~pages$', + 'virtual:meta-layouts', + '#auth$', + '#components$', + '.*\\?raw', + ], + }], + + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': ['error'], + '@typescript-eslint/consistent-type-imports': 'error', + + // CLAUDE.md frontend convention — backend enums are mirrored as + // `as const` objects WITH a same-named `type` alias. The two live + // in different namespaces (value vs. type) and are intentional; + // both base `no-redeclare` and the typed variant flag them anyway. + 'no-redeclare': 'off', + '@typescript-eslint/no-redeclare': 'off', + + 'promise/always-return': 'off', + 'promise/catch-or-return': 'off', + + 'vue/block-tag-newline': 'error', + 'vue/component-api-style': 'error', + 'vue/component-name-in-template-casing': ['error', 'PascalCase', { + registeredComponentsOnly: false, + ignores: ['/^swiper-/'], + }], + 'vue/custom-event-name-casing': ['error', 'camelCase', { + ignores: [ + '/^(click):[a-z]+((\\d)|([A-Z0-9][a-z0-9]+))*([A-Z])?/', + ], + }], + 'vue/define-macros-order': 'error', + 'vue/html-comment-content-newline': 'error', + 'vue/html-comment-content-spacing': 'error', + 'vue/html-comment-indent': 'error', + 'vue/match-component-file-name': 'error', + 'vue/no-child-content': 'error', + 'vue/require-default-prop': 'off', + + 'vue/no-duplicate-attr-inheritance': 'error', + 'vue/no-empty-component-block': 'error', + 'vue/no-multiple-objects-in-class': 'error', + 'vue/no-reserved-component-names': 'error', + 'vue/no-template-target-blank': 'error', + 'vue/no-useless-mustaches': 'error', + 'vue/no-useless-v-bind': 'error', + 'vue/padding-line-between-blocks': 'error', + 'vue/prefer-separate-static-class': 'error', + 'vue/prefer-true-attribute-shorthand': 'error', + 'vue/v-on-function-call': 'error', + 'vue/no-restricted-class': ['error', '/^(p|m)(l|r)-/'], + 'vue/valid-v-slot': ['error', { allowModifiers: true }], + + 'vue/no-irregular-whitespace': 'error', + 'vue/template-curly-spacing': 'error', + + 'sonarjs/no-duplicate-string': 'off', + 'sonarjs/no-nested-template-literals': 'off', + + 'regex/invalid': [ + 'error', + [ + { + regex: '@/assets/images', + replacement: '@images', + message: 'Use \'@images\' path alias for image imports', + }, + { + regex: '@/assets/styles', + replacement: '@styles', + message: 'Use \'@styles\' path alias for importing styles from \'src/assets/styles\'', + }, + ], + '\\.eslintrc\\.cjs', + ], + }, + settings: { + 'import/resolver': { + node: true, + typescript: {}, + }, + }, +} diff --git a/apps/app/src/components/form-failures/FormFailureDetail.vue b/apps/app/src/components/form-failures/FormFailureDetail.vue index 5bbf2c4e..621daaa9 100644 --- a/apps/app/src/components/form-failures/FormFailureDetail.vue +++ b/apps/app/src/components/form-failures/FormFailureDetail.vue @@ -21,7 +21,8 @@ const stateLabel = { open: 'Open', resolved: 'Opgelost', dismissed: 'Dismissed' const stateColor = { open: 'error', resolved: 'success', dismissed: 'warning' } as const function formatDateTime(iso: string | null): string { - if (!iso) return '—' + if (!iso) + return '—' return new Date(iso).toLocaleDateString('nl-NL', { day: '2-digit', @@ -39,13 +40,13 @@ const resolveDialogOpen = ref(false) const dismissDialogOpen = ref(false) function copyText(text: string): void { - if (navigator?.clipboard) { + if (navigator?.clipboard) void navigator.clipboard.writeText(text) - } } const formattedContext = computed(() => { - if (!failure.value?.context) return null + if (!failure.value?.context) + return null try { return JSON.stringify(failure.value.context, null, 2) } @@ -53,6 +54,12 @@ const formattedContext = computed(() => { return null } }) + +const traceExpanded = ref(false) + +function formatAttemptOutcome(outcome: 'succeeded' | 'failed'): string { + return outcome === 'succeeded' ? 'Geslaagd' : 'Mislukt' +}