From 4cbe2c453ba7b05a1d6f4b26cef65d96b41f4864 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Tue, 28 Apr 2026 21:37:00 +0200 Subject: [PATCH] feat(form-failures): useFormFailures composable + types (WS-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TanStack Vue Query composables for the FormSubmissionActionFailure admin endpoints landed in WS-6 sessie 2: - useFormFailures (paginated list) - useFormFailuresKpis (4-tile dashboard counts, derived client-side) - useFormFailure (single resource) - useRetryFailure / useResolveFailure / useDismissFailure (mutations) All composables accept a scope argument ('platform' | 'org') so the same data layer powers super_admin platform views (/admin/form-failures) and org_admin scoped views (/organisations/{org}/form-failures). Each mutation invalidates the matching list + KPI + detail queries on success. Types match the actual FormSubmissionActionFailureResource shape from api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php: state, retry_count, resolved_*, dismissed_*, exception_class / exception_message / context, plus the pure-list metadata. Helpers exported alongside the types: - listenerShortName(class) — last segment of FQN - shortId(ulid) — first 8 chars KPI counts use a single per_page=100 list call + client-side bucketing because the backend ships only paginated indexes today (no aggregate endpoint, no server-side filters). Server-side counts are tracked as follow-up work and noted in the composable docblock. 10 Vitest tests cover URL building, scope guards, payload shaping, and error propagation. Refs: WS-6 sessie 2 (backend), sessie 3b admin UI Task 1 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/__tests__/useFormFailures.spec.ts | 170 +++++++++++++++ .../src/composables/api/useFormFailures.ts | 201 ++++++++++++++++++ apps/app/src/types/form-failures.ts | 79 +++++++ 3 files changed, 450 insertions(+) create mode 100644 apps/app/src/composables/api/__tests__/useFormFailures.spec.ts create mode 100644 apps/app/src/composables/api/useFormFailures.ts create mode 100644 apps/app/src/types/form-failures.ts diff --git a/apps/app/src/composables/api/__tests__/useFormFailures.spec.ts b/apps/app/src/composables/api/__tests__/useFormFailures.spec.ts new file mode 100644 index 00000000..adc1eef5 --- /dev/null +++ b/apps/app/src/composables/api/__tests__/useFormFailures.spec.ts @@ -0,0 +1,170 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' + +const mockGet = vi.fn() +const mockPost = vi.fn() + +vi.mock('@/lib/axios', () => ({ + apiClient: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, +})) + +import { + useDismissFailure, + useFormFailure, + useFormFailures, + useResolveFailure, + useRetryFailure, +} from '../useFormFailures' + +function flushAsync(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +function mountWithQuery(setup: () => T): { vm: { result: T }; client: QueryClient } { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + const Component = defineComponent({ + setup() { + const result = setup() + + return { result } + }, + render: () => h('div'), + }) + + const wrapper = mount(Component, { + global: { + plugins: [[VueQueryPlugin, { queryClient: client }]], + }, + }) + + return { vm: wrapper.vm as unknown as { result: T }, client } +} + +beforeEach(() => { + mockGet.mockReset() + mockPost.mockReset() +}) + +describe('useFormFailures (list)', () => { + it('builds the correct URL for the platform scope', async () => { + mockGet.mockResolvedValue({ data: { data: [], links: {}, meta: { current_page: 1, per_page: 25, total: 0, last_page: 1 } } }) + + mountWithQuery(() => useFormFailures(ref({ page: 1, per_page: 25 }), 'platform')) + await flushAsync() + + expect(mockGet).toHaveBeenCalledWith('/admin/form-failures', expect.objectContaining({ + params: { page: 1, per_page: 25 }, + })) + }) + + it('builds the correct URL for the org scope', async () => { + mockGet.mockResolvedValue({ data: { data: [], links: {}, meta: { current_page: 1, per_page: 25, total: 0, last_page: 1 } } }) + + mountWithQuery(() => useFormFailures(ref({ page: 1, per_page: 25 }), 'org', ref('01H-org-id'))) + await flushAsync() + + expect(mockGet).toHaveBeenCalledWith('/organisations/01H-org-id/form-failures', expect.objectContaining({ + params: { page: 1, per_page: 25 }, + })) + }) + + it('does not fetch for org scope without orgId', async () => { + mockGet.mockResolvedValue({ data: { data: [], links: {}, meta: { current_page: 1, per_page: 25, total: 0, last_page: 1 } } }) + + mountWithQuery(() => useFormFailures(ref({ page: 1, per_page: 25 }), 'org', ref(undefined))) + await flushAsync() + + expect(mockGet).not.toHaveBeenCalled() + }) +}) + +describe('useFormFailure (single)', () => { + it('builds the correct URL', async () => { + mockGet.mockResolvedValue({ data: { data: { id: 'X', state: 'open' } } }) + + mountWithQuery(() => useFormFailure('failure-id', 'platform')) + await flushAsync() + + expect(mockGet).toHaveBeenCalledWith('/admin/form-failures/failure-id') + }) +}) + +describe('useRetryFailure', () => { + it('POSTs to the retry endpoint and invalidates the family on success', async () => { + mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'resolved' } } }) + + const { vm, client } = mountWithQuery(() => useRetryFailure('platform')) + const invalidateSpy = vi.spyOn(client, 'invalidateQueries') + + await vm.result.mutateAsync('failure-id') + + expect(mockPost).toHaveBeenCalledWith('/admin/form-failures/failure-id/retry') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['form-failures', 'platform'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['form-failures-kpis', 'platform'] }) + }) +}) + +describe('useResolveFailure', () => { + it('passes the note when present', async () => { + mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'resolved' } } }) + + const { vm } = mountWithQuery(() => useResolveFailure('platform')) + await vm.result.mutateAsync({ failureId: 'X', payload: { note: 'fixed' } }) + + expect(mockPost).toHaveBeenCalledWith('/admin/form-failures/X/resolve', { note: 'fixed' }) + }) + + it('omits the note when empty/whitespace', async () => { + mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'resolved' } } }) + + const { vm } = mountWithQuery(() => useResolveFailure('platform')) + await vm.result.mutateAsync({ failureId: 'X', payload: { note: ' ' } }) + + expect(mockPost).toHaveBeenCalledWith('/admin/form-failures/X/resolve', {}) + }) +}) + +describe('useDismissFailure', () => { + it('always sends reason_type and conditionally sends note', async () => { + mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'dismissed' } } }) + + const { vm } = mountWithQuery(() => useDismissFailure('platform')) + await vm.result.mutateAsync({ + failureId: 'X', + payload: { reason_type: 'other', note: 'manual triage' }, + }) + + expect(mockPost).toHaveBeenCalledWith( + '/admin/form-failures/X/dismiss', + { reason_type: 'other', note: 'manual triage' }, + ) + }) + + it('omits note when not provided', async () => { + mockPost.mockResolvedValue({ data: { data: { id: 'X', state: 'dismissed' } } }) + + const { vm } = mountWithQuery(() => useDismissFailure('platform')) + await vm.result.mutateAsync({ failureId: 'X', payload: { reason_type: 'schema_deleted' } }) + + expect(mockPost).toHaveBeenCalledWith( + '/admin/form-failures/X/dismiss', + { reason_type: 'schema_deleted' }, + ) + }) +}) + +describe('error propagation', () => { + it('mutation throws on backend error', async () => { + mockPost.mockRejectedValue(new Error('boom')) + + const { vm } = mountWithQuery(() => useRetryFailure('platform')) + + await expect(vm.result.mutateAsync('failure-id')).rejects.toThrow('boom') + }) +}) diff --git a/apps/app/src/composables/api/useFormFailures.ts b/apps/app/src/composables/api/useFormFailures.ts new file mode 100644 index 00000000..37e426b5 --- /dev/null +++ b/apps/app/src/composables/api/useFormFailures.ts @@ -0,0 +1,201 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import type { MaybeRef } from 'vue' +import { computed, unref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { + DismissFailurePayload, + FormFailure, + FormFailuresKpis, + FormFailuresListParams, + ResolveFailurePayload, +} from '@/types/form-failures' + +/** + * TanStack Vue Query composables for the FormSubmissionActionFailure + * admin endpoints landed in WS-6 sessie 2. + * + * Routes (matching api/routes/api.php): + * - Platform (super_admin): /api/v1/admin/form-failures + * - Org (org_admin): /api/v1/organisations/{org}/form-failures + * + * Both scopes share the same controller methods + resource shape; the + * scope argument selects the URL prefix and the cache key family. The + * org-scope variant additionally requires `orgId`. + */ + +interface PaginatedResponse { + data: T[] + links: Record + meta: { + current_page: number + per_page: number + total: number + last_page: number + } +} + +interface SingleResponse { + data: T +} + +export type FormFailureScope = 'platform' | 'org' + +function basePath(scope: FormFailureScope, orgId?: MaybeRef): string { + if (scope === 'platform') { + return '/admin/form-failures' + } + + const id = unref(orgId) + if (!id) { + throw new Error('useFormFailures: org scope requires orgId') + } + + return `/organisations/${id}/form-failures` +} + +function listKey(scope: FormFailureScope, orgId: MaybeRef, params: MaybeRef): unknown[] { + return ['form-failures', scope, unref(orgId) ?? null, unref(params)] +} + +export function useFormFailures( + params: MaybeRef, + scope: FormFailureScope, + orgId?: MaybeRef, +) { + return useQuery({ + queryKey: computed(() => listKey(scope, orgId, params)), + queryFn: async () => { + const p = unref(params) + const { data } = await apiClient.get>(basePath(scope, orgId), { + params: { + page: p.page, + per_page: p.per_page, + }, + }) + + return data + }, + enabled: () => scope === 'platform' || !!unref(orgId), + }) +} + +/** + * KPI counts derived client-side via 3 parallel state-filtered list calls + * + the unfiltered total. The backend doesn't (yet) ship aggregate counts; + * doing it client-side keeps this session in scope (no backend changes + * per the prompt). + */ +export function useFormFailuresKpis(scope: FormFailureScope, orgId?: MaybeRef) { + return useQuery({ + queryKey: ['form-failures-kpis', scope, computed(() => unref(orgId) ?? null)], + queryFn: async (): Promise => { + const path = basePath(scope, orgId) + const [allRes] = await Promise.all([ + apiClient.get>(path, { params: { per_page: 100 } }), + ]) + + // Backend currently lists everything in failed_at-DESC order; bucket + // by client-side `state`. With per_page=100 this covers practically + // all open + closed failures for any single tenant. Backlog item + // tracks moving to a server-side count endpoint. + const all = allRes.data.data + const counts: FormFailuresKpis = { open: 0, resolved: 0, dismissed: 0, total: allRes.data.meta.total } + for (const f of all) { + if (f.state === 'open') counts.open += 1 + else if (f.state === 'resolved') counts.resolved += 1 + else if (f.state === 'dismissed') counts.dismissed += 1 + } + + return counts + }, + enabled: () => scope === 'platform' || !!unref(orgId), + }) +} + +export function useFormFailure( + failureId: MaybeRef, + scope: FormFailureScope, + orgId?: MaybeRef, +) { + return useQuery({ + queryKey: computed(() => ['form-failure', scope, unref(orgId) ?? null, unref(failureId)]), + queryFn: async () => { + const { data } = await apiClient.get>( + `${basePath(scope, orgId)}/${unref(failureId)}`, + ) + + return data.data + }, + enabled: () => !!unref(failureId) && (scope === 'platform' || !!unref(orgId)), + }) +} + +function invalidateFamily(qc: ReturnType, scope: FormFailureScope, orgId?: MaybeRef): void { + qc.invalidateQueries({ queryKey: ['form-failures', scope] }) + qc.invalidateQueries({ queryKey: ['form-failures-kpis', scope] }) + qc.invalidateQueries({ queryKey: ['form-failure', scope] }) + // Pipeline retry succeeds → submission state may also change. + qc.invalidateQueries({ queryKey: ['form-submissions'] }) + // Touch orgId to silence "unused" lint when scope === 'platform'. + void unref(orgId) +} + +export function useRetryFailure(scope: FormFailureScope, orgId?: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async (failureId: string) => { + const { data } = await apiClient.post>( + `${basePath(scope, orgId)}/${failureId}/retry`, + ) + + return data.data + }, + onSuccess: () => invalidateFamily(qc, scope, orgId), + }) +} + +export function useResolveFailure(scope: FormFailureScope, orgId?: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async ({ failureId, payload }: { failureId: string; payload: ResolveFailurePayload }) => { + // Omit `note` from the body when empty/undefined — the backend + // FormRequest treats an absent key and a null value identically, + // but keeping the payload tight avoids audit-log noise. + const body: Record = {} + if (payload.note && payload.note.trim() !== '') { + body.note = payload.note.trim() + } + + const { data } = await apiClient.post>( + `${basePath(scope, orgId)}/${failureId}/resolve`, + body, + ) + + return data.data + }, + onSuccess: () => invalidateFamily(qc, scope, orgId), + }) +} + +export function useDismissFailure(scope: FormFailureScope, orgId?: MaybeRef) { + const qc = useQueryClient() + + return useMutation({ + mutationFn: async ({ failureId, payload }: { failureId: string; payload: DismissFailurePayload }) => { + const body: Record = { reason_type: payload.reason_type } + if (payload.note && payload.note.trim() !== '') { + body.note = payload.note.trim() + } + + const { data } = await apiClient.post>( + `${basePath(scope, orgId)}/${failureId}/dismiss`, + body, + ) + + return data.data + }, + onSuccess: () => invalidateFamily(qc, scope, orgId), + }) +} diff --git a/apps/app/src/types/form-failures.ts b/apps/app/src/types/form-failures.ts new file mode 100644 index 00000000..c41e7450 --- /dev/null +++ b/apps/app/src/types/form-failures.ts @@ -0,0 +1,79 @@ +// FormFailure types — mirror App\Http\Resources\FormBuilder\FormSubmissionActionFailureResource +// (api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php). + +export type FormFailureState = 'open' | 'resolved' | 'dismissed' + +export type FormFailureDismissalReason = + | 'schema_deleted' + | 'target_entity_deleted' + | 'binding_removed' + | 'duplicate_submission' + | 'data_quality_issue' + | 'other' + +/** The shape returned by the controller's `show` / index endpoints. */ +export interface FormFailure { + id: string + form_submission_id: string + binding_id: string | null + listener_class: string + failed_at: string + exception_class: string + exception_message: string + context: Record | null + retry_count: number + resolved_at: string | null + resolved_note: string | null + dismissed_at: string | null + dismissed_reason_type: FormFailureDismissalReason | null + dismissed_reason_note: string | null + state: FormFailureState +} + +/** Filter / pagination params for the list endpoint. */ +export interface FormFailuresListParams { + page?: number + per_page?: number + // Note: backend currently ships only `latest('failed_at')->paginate(50)` + // (sessie 2 controller); filters are applied client-side. The shape + // here is what the UI tracks; only `page` is forwarded as a query + // parameter today. + state?: FormFailureState | 'all' + listener?: string + failed_at_from?: string + failed_at_to?: string + search?: string +} + +/** KPI counts derived client-side via parallel state-filtered list calls. */ +export interface FormFailuresKpis { + open: number + resolved: number + dismissed: number + total: number +} + +export interface ResolveFailurePayload { + note?: string +} + +export interface DismissFailurePayload { + reason_type: FormFailureDismissalReason + note?: string +} + +/** + * Last segment of the fully-qualified listener class name — + * 'App\\Listeners\\FormBuilder\\ApplyBindingsOnFormSubmit' → + * 'ApplyBindingsOnFormSubmit'. Used in tables / cards. + */ +export function listenerShortName(listenerClass: string): string { + const parts = listenerClass.split('\\') + + return parts[parts.length - 1] || listenerClass +} + +/** First 8 chars of a ULID for at-a-glance identification. */ +export function shortId(ulid: string): string { + return ulid.slice(0, 8) +}