import { baseApi } from '../../base-api'; import type { AppNotification, ApplicationDetail, ApplicationKind, ApplicationPayment, ApplicationStaff, Attachment, InitiatePaymentResult, IssuedLicense, Inspection, LicenseApplication, LicenseCategoryDefinition, LicenseStatus, LicenseType, LicenseTypeRequirements, OperatorType, AssignableOfficer, DocumentDecision, DocumentReview, ExportResult, LicenseTemplate, Paginated, QueueCounts, QueueFilter, RemarkTargetType, SavedQueueView, TemplatePageOptions, TemplateVariable, } from './licensing.types'; /** * Drops empty filters and flattens the status array. * * The grid keeps every facet in one object and serialises it to the URL, so * cleared facets arrive as undefined/[]; sending those verbatim would put * `status=` and `assignee=` on the wire and fail DTO validation. */ function serialiseQueueFilter( filter: QueueFilter | void, ): Record { if (!filter) return {}; const params: Record = {}; for (const [key, value] of Object.entries(filter)) { if (value === undefined || value === null || value === '') continue; if (Array.isArray(value)) { if (value.length === 0) continue; params[key] = value.join(','); continue; } params[key] = value; } return params; } const TAGS = [ 'LicenseType', 'OperatorType', 'LicenseApplication', 'ApplicationQueue', 'Attachment', 'Notification', 'Inspection', 'License', 'SavedView', 'LicenseTemplate', ] as const; const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const; const itemTag = (type: (typeof TAGS)[number], id: string) => ({ type, id }) as const; /** * Typed licensing endpoints, shared by the portal and the backoffice. * * Each resource gets its own cache tag so a transition invalidates the one * application and its queue, rather than the whole store. */ export const licensingApi = baseApi .enhanceEndpoints({ addTagTypes: TAGS }) .injectEndpoints({ endpoints: (builder) => ({ // ------------------------------------------------------------- config /** Active licence types an applicant can choose from. */ getLicenseTypes: builder.query, void>({ query: () => ({ url: '/license-types' }), providesTags: () => [listTag('LicenseType')], }), /** * The signed-in applicant's declared modes of operation. * * Separate from the licence-type catalogue on purpose: the catalogue is * the same for everyone and heavily cached, while this is per-user and * changes the moment they edit their profile. */ getMyOperatorTypes: builder.query<{ items: OperatorType[] }, void>({ query: () => ({ url: '/profiles/me/operations' }), providesTags: () => [listTag('OperatorType')], }), /** Replaces the set — see the Operations tab in the portal profile. */ updateMyOperatorTypes: builder.mutation< { items: OperatorType[] }, { licenseTypeIds: string[] } >({ query: (body) => ({ url: '/profiles/me/operations', method: 'PUT', body, }), // The catalogue is filtered by this, so it has to refetch too. invalidatesTags: (_r, error) => error ? [] : [listTag('OperatorType'), listTag('LicenseType')], }), /** * Category catalogue used to group the licence cards. Static on the * server, so it is cached under the licence-type list tag. */ getLicenseCategories: builder.query< Paginated, void >({ query: () => ({ url: '/license-categories' }), providesTags: () => [listTag('LicenseType')], }), /** * Fee configuration, for licensing administrators. * * `null` is a meaningful value for both amounts and must survive the * round trip: null on the new-application fee means the licence is free, * null on the renewal fee means it is charged at the new-application * rate. Sending `undefined` instead would leave the column untouched. */ updateLicenseFees: builder.mutation< LicenseType, { id: string; feeNewApplication?: number | null; feeRenewal?: number | null; feeCurrency?: string; } >({ query: ({ id, ...body }) => ({ url: `/license-types/${id}/fees`, method: 'PATCH', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)], }), /** Validity is edited beside the certificate design, not with the fees. */ updateLicenseValidity: builder.mutation< LicenseType, { id: string; validityMonths: number } >({ query: ({ id, validityMonths }) => ({ url: `/license-types/${id}/validity`, method: 'PATCH', body: { validityMonths }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseType', id), listTag('LicenseType')], }), getLicenseTypeRequirements: builder.query< LicenseTypeRequirements, { idOrKey: string; kind?: ApplicationKind } >({ query: ({ idOrKey, kind = 'NEW' }) => ({ url: `/license-types/requirements/${idOrKey}`, params: { kind }, }), providesTags: (_r, _e, arg) => [itemTag('LicenseType', arg.idOrKey)], }), // -------------------------------------------------------- application createApplication: builder.mutation< LicenseApplication, { licenseType: string; kind?: ApplicationKind; previousLicenseId?: string } >({ query: (body) => ({ url: '/license-applications', method: 'POST', body }), invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]), }), getMyApplications: builder.query, void>({ query: () => ({ url: '/license-applications/mine' }), providesTags: () => [listTag('LicenseApplication')], }), getApplication: builder.query({ query: (id) => ({ url: `/license-applications/${id}` }), providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], }), patchSection: builder.mutation< LicenseApplication, { id: string; sectionKey: string; values: Record } >({ query: ({ id, sectionKey, values }) => ({ url: `/license-applications/${id}/sections/${sectionKey}`, method: 'PATCH', body: { values }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id)], }), addStaff: builder.mutation< ApplicationStaff, { id: string; roleKey: string; fullName: string; position?: string; yearsOfExperience?: number } >({ query: ({ id, ...body }) => ({ url: `/license-applications/${id}/staff`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id)], }), removeStaff: builder.mutation({ query: ({ id, staffId }) => ({ url: `/license-applications/${id}/staff/${staffId}`, method: 'DELETE', }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id)], }), submitApplication: builder.mutation({ query: (id) => ({ url: `/license-applications/${id}/submit`, method: 'POST' }), invalidatesTags: (_r, error, id) => error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication'), listTag('ApplicationQueue')], }), resolveRemark: builder.mutation({ query: ({ id, remarkId }) => ({ url: `/license-applications/${id}/remarks/${remarkId}/resolve`, method: 'PATCH', }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id)], }), resubmitApplication: builder.mutation({ query: (id) => ({ url: `/license-applications/${id}/resubmit`, method: 'POST' }), invalidatesTags: (_r, error, id) => error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')], }), // -------------------------------------------------------- attachments getAttachments: builder.query({ query: ({ ownerType, ownerId }) => ({ url: '/attachments', params: { ownerType, ownerId, withUrls: true }, }), providesTags: (_r, _e, arg) => [itemTag('Attachment', arg.ownerId)], }), deleteAttachment: builder.mutation({ query: ({ attachmentId }) => ({ url: `/attachments/${attachmentId}`, method: 'DELETE', }), invalidatesTags: (_r, error, { ownerId }) => error ? [] : [itemTag('Attachment', ownerId)], }), // ----------------------------------------------------------- payments initiatePayment: builder.mutation< InitiatePaymentResult, { id: string; provider?: string; platform?: 'web' | 'mobile'; payerAccount?: string } >({ query: ({ id, ...body }) => ({ url: `/license-applications/${id}/payments/initiate`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id)], }), getApplicationPayment: builder.query({ query: (id) => ({ url: `/license-applications/${id}/payments` }), providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], }), /** Testing shortcut — only offered when the API reports it enabled. */ bypassPayment: builder.mutation< { status: LicenseStatus; certificateIssued: boolean }, string >({ query: (id) => ({ url: `/license-applications/${id}/payments/bypass`, method: 'POST', }), invalidatesTags: (_r, error, id) => error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication'), listTag('License')], }), getPaymentCapabilities: builder.query<{ bypassEnabled: boolean }, void>({ query: () => ({ url: '/license-applications/payments/capabilities' }), }), // ------------------------------------------------------------ licences /** * Public QR-code verification (US-PORTAL-008). No auth required — * the endpoint returns only what a verifier needs to trust the * document, never the holder's contact details. */ verifyCertificate: builder.query< { valid: boolean; reason?: string; certificateNumber?: string; licenseType?: string | null; companyName?: string | null; issueDate?: string; expiryDate?: string; status?: string; }, string >({ query: (code) => ({ url: `/licenses/verify/${code}` }), }), /** The licence register for enforcement officers. */ getLicenses: builder.query< Paginated, { search?: string } | void >({ query: (args) => ({ url: '/licenses', params: args?.search ? { search: args.search } : undefined, }), providesTags: () => [listTag('License')], }), getMyLicenses: builder.query, void>({ query: () => ({ url: '/licenses/mine' }), providesTags: () => [listTag('License')], }), suspendLicense: builder.mutation({ query: ({ id, reason }) => ({ url: `/licenses/${id}/suspend`, method: 'POST', body: { reason }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('License', id), listTag('License')], }), revokeLicense: builder.mutation({ query: ({ id, reason }) => ({ url: `/licenses/${id}/revoke`, method: 'POST', body: { reason }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('License', id), listTag('License')], }), reinstateLicense: builder.mutation({ query: ({ id, reason }) => ({ url: `/licenses/${id}/reinstate`, method: 'POST', body: { reason }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('License', id), listTag('License')], }), getCertificateUrl: builder.mutation<{ url: string }, string>({ query: (id) => ({ url: `/licenses/${id}/certificate` }), }), // ------------------------------------------------------------- review getQueue: builder.query, QueueFilter | void>({ query: (params) => ({ url: '/license-application-review/queue', params: serialiseQueueFilter(params), }), providesTags: () => [listTag('ApplicationQueue')], }), getAssignedToMe: builder.query< Paginated, QueueFilter | void >({ query: (params) => ({ url: '/license-application-review/assigned-to-me', params: serialiseQueueFilter(params), }), providesTags: () => [listTag('ApplicationQueue')], }), /** Every non-draft application — backs the grid's "All" view. */ getAllApplications: builder.query< Paginated, QueueFilter | void >({ query: (params) => ({ url: '/license-application-review/all', params: serialiseQueueFilter(params), }), providesTags: () => [listTag('ApplicationQueue')], }), /** * Counts for the saved-view tabs. Shares the ApplicationQueue tag, so a * claim or a decision refreshes the badges along with the list and the * numbers can never drift from what the grid is showing. */ getQueueCounts: builder.query({ query: () => ({ url: '/license-application-review/counts' }), providesTags: () => [listTag('ApplicationQueue')], }), getApplicationForReview: builder.query({ query: (id) => ({ url: `/license-application-review/${id}` }), providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], }), claimApplication: builder.mutation({ query: (id) => ({ url: `/license-application-review/${id}/claim`, method: 'POST' }), invalidatesTags: (_r, error, id) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), completeReview: builder.mutation< LicenseApplication, { id: string; capitalAmountVerified?: number; remark?: string } >({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/complete-review`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), requestAdjustment: builder.mutation< LicenseApplication, { id: string; generalRemark?: string; items: { targetType: RemarkTargetType; targetKey: string; remark: string }[]; /** Officer-edited wording for the applicant notification. */ notificationBody?: string; } >({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/request-adjustment`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), approveDocuments: builder.mutation({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/approve-documents`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), /** * `capitalAmountVerified` is what the officer confirms against the bank * letter. Licence types with a capital threshold reject the approval * without it, so it is part of the contract, not an optional extra. */ finalApprove: builder.mutation< LicenseApplication, { id: string; remark?: string; capitalAmountVerified?: number } >({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/final-approve`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), rejectApplication: builder.mutation< LicenseApplication, { id: string; reason: string; notificationBody?: string } >({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/reject`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), /** Places a candidate who has paid the examination fee into a sitting. */ scheduleExam: builder.mutation< LicenseApplication, { id: string; examId: string; admissionNumber?: string; examDate?: string } >({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/schedule-exam`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), /** * Raises the examination fee — after eligibility approval, or again when * a failed candidate elects to resit. */ requestExamPayment: builder.mutation({ query: (id) => ({ url: `/license-applications/${id}/request-exam-payment`, method: 'POST', }), invalidatesTags: (_r, error, id) => error ? [] : [itemTag('LicenseApplication', id), listTag('LicenseApplication')], }), // ------------------------------------------------- certificate designs getLicenseTemplates: builder.query({ query: (licenseTypeId) => ({ url: '/license-templates', params: licenseTypeId ? { licenseTypeId } : {}, }), providesTags: () => [listTag('LicenseTemplate')], }), getTemplateVariables: builder.query({ query: () => ({ url: '/license-templates/variables' }), }), getBuiltInTemplate: builder.query<{ hbsSource: string }, void>({ query: () => ({ url: '/license-templates/built-in' }), }), createLicenseTemplate: builder.mutation< LicenseTemplate, { licenseTypeId: string; name: string; hbsSource?: string; pageOptions?: TemplatePageOptions; } >({ query: (body) => ({ url: '/license-templates', method: 'POST', body }), invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]), }), updateLicenseTemplate: builder.mutation< LicenseTemplate, { id: string; name?: string; hbsSource?: string; pageOptions?: TemplatePageOptions; backgroundUrl?: string; } >({ query: ({ id, ...body }) => ({ url: `/license-templates/${id}`, method: 'PATCH', body, }), invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]), }), publishLicenseTemplate: builder.mutation({ query: (id) => ({ url: `/license-templates/${id}/publish`, method: 'POST' }), // The previously published design is archived by the same call, so the // whole list is refetched rather than patching two rows by hand. invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]), }), archiveLicenseTemplate: builder.mutation({ query: (id) => ({ url: `/license-templates/${id}/archive`, method: 'POST' }), invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]), }), deleteLicenseTemplate: builder.mutation({ query: (id) => ({ url: `/license-templates/${id}`, method: 'DELETE' }), invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseTemplate')]), }), // ------------------------------------------------------ document review getDocumentReviews: builder.query({ query: (id) => ({ url: `/license-application-review/${id}/document-reviews` }), providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)], }), reviewDocument: builder.mutation< DocumentReview, { id: string; documentKey: string; decision: DocumentDecision; reason?: string; attachmentId?: string; } >({ query: ({ id, documentKey, ...body }) => ({ url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id)], }), clearDocumentReview: builder.mutation({ query: ({ id, documentKey }) => ({ url: `/license-application-review/${id}/documents/${encodeURIComponent(documentKey)}/review`, method: 'DELETE', }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id)], }), // ----------------------------------------------------------- saved views getSavedViews: builder.query({ query: () => ({ url: '/license-application-review/saved-views' }), providesTags: () => [listTag('SavedView')], }), createSavedView: builder.mutation< SavedQueueView, { name: string; queryString: string; isShared?: boolean; isDefault?: boolean } >({ query: (body) => ({ url: '/license-application-review/saved-views', method: 'POST', body, }), invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]), }), deleteSavedView: builder.mutation({ query: (viewId) => ({ url: `/license-application-review/saved-views/${viewId}`, method: 'DELETE', }), invalidatesTags: (_r, error) => (error ? [] : [listTag('SavedView')]), }), // ------------------------------------------------------------- export /** * The whole filtered result set, not the current page. Lazy so it only * runs when the officer actually asks for a file. */ exportApplications: builder.query({ query: (params) => ({ url: '/license-application-review/export', params: serialiseQueueFilter(params), }), }), /** Officers the Assign and Escalate dialogs can offer. */ getAssignableOfficers: builder.query({ query: () => ({ url: '/license-application-review/officers' }), }), // ----------------------------------------------------- workflow controls assignApplication: builder.mutation< LicenseApplication, { id: string; officerId: string; remark?: string } >({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/assign`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), holdApplication: builder.mutation< LicenseApplication, { id: string; reason: string } >({ query: ({ id, reason }) => ({ url: `/license-application-review/${id}/hold`, method: 'POST', body: { reason }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), resumeApplication: builder.mutation< LicenseApplication, { id: string; remark?: string } >({ query: ({ id, remark }) => ({ url: `/license-application-review/${id}/resume`, method: 'POST', body: { remark }, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), escalateApplication: builder.mutation< LicenseApplication, { id: string; supervisorId: string; reason: string } >({ query: ({ id, ...body }) => ({ url: `/license-application-review/${id}/escalate`, method: 'POST', body, }), invalidatesTags: (_r, error, { id }) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), confirmPayment: builder.mutation({ query: (id) => ({ url: `/license-application-review/${id}/confirm-payment`, method: 'POST', }), invalidatesTags: (_r, error, id) => error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], }), // --------------------------------------------------------- inspection scheduleInspection: builder.mutation< Inspection, { applicationId: string; scheduledDate: string; location?: string } >({ query: (body) => ({ url: '/inspections', method: 'POST', body }), invalidatesTags: (_r, error, { applicationId }) => error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')], }), getInspections: builder.query({ query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }), providesTags: () => [listTag('Inspection')], }), recordInspectionResult: builder.mutation< Inspection, { inspectionId: string; applicationId: string; result: 'PASSED' | 'FAILED'; findings: string; /** Structured per-item outcomes (office, warehouse, vehicles, …). */ checklist?: { key: string; label: string; outcome: 'PASS' | 'FAIL' | 'NEEDS_CORRECTION'; note?: string; }[]; } >({ query: ({ inspectionId, result, findings, checklist }) => ({ url: `/inspections/${inspectionId}/result`, method: 'PATCH', body: { result, findings, ...(checklist?.length ? { checklist } : {}) }, }), invalidatesTags: (_r, error, { applicationId }) => error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')], }), // ------------------------------------------------------ notifications getNotifications: builder.query<{ count: number; items: AppNotification[] }, void>({ query: () => ({ url: '/notifications' }), providesTags: () => [listTag('Notification')], }), getUnseenNotifications: builder.query<{ count: number; items: AppNotification[] }, void>({ query: () => ({ url: '/notifications/unseen' }), providesTags: () => [listTag('Notification')], }), markNotificationRead: builder.mutation({ query: (id) => ({ url: `/notifications/${id}/read`, method: 'PATCH' }), invalidatesTags: (_r, error) => (error ? [] : [listTag('Notification')]), }), }), overrideExisting: false, }); export const { useVerifyCertificateQuery, useGetLicenseTypesQuery, useGetLicenseCategoriesQuery, useUpdateLicenseFeesMutation, useUpdateLicenseValidityMutation, useGetLicenseTypeRequirementsQuery, useCreateApplicationMutation, useGetMyApplicationsQuery, useGetApplicationQuery, useInitiatePaymentMutation, useBypassPaymentMutation, useGetPaymentCapabilitiesQuery, useGetMyLicensesQuery, useGetLicensesQuery, useGetCertificateUrlMutation, useGetApplicationPaymentQuery, usePatchSectionMutation, useAddStaffMutation, useRemoveStaffMutation, useSubmitApplicationMutation, useResolveRemarkMutation, useResubmitApplicationMutation, useGetAttachmentsQuery, useDeleteAttachmentMutation, useGetQueueQuery, useGetAssignedToMeQuery, useGetAllApplicationsQuery, useGetQueueCountsQuery, useGetLicenseTemplatesQuery, useGetTemplateVariablesQuery, useGetBuiltInTemplateQuery, useCreateLicenseTemplateMutation, useUpdateLicenseTemplateMutation, usePublishLicenseTemplateMutation, useArchiveLicenseTemplateMutation, useDeleteLicenseTemplateMutation, useGetDocumentReviewsQuery, useReviewDocumentMutation, useClearDocumentReviewMutation, useGetSavedViewsQuery, useCreateSavedViewMutation, useDeleteSavedViewMutation, useLazyExportApplicationsQuery, useGetAssignableOfficersQuery, useSuspendLicenseMutation, useRevokeLicenseMutation, useReinstateLicenseMutation, useAssignApplicationMutation, useHoldApplicationMutation, useResumeApplicationMutation, useEscalateApplicationMutation, useGetApplicationForReviewQuery, useClaimApplicationMutation, useCompleteReviewMutation, useRequestAdjustmentMutation, useApproveDocumentsMutation, useFinalApproveMutation, useRejectApplicationMutation, useScheduleExamMutation, useRequestExamPaymentMutation, useConfirmPaymentMutation, useScheduleInspectionMutation, useGetInspectionsQuery, useRecordInspectionResultMutation, useGetNotificationsQuery, useGetUnseenNotificationsQuery, useMarkNotificationReadMutation, useGetMyOperatorTypesQuery, useUpdateMyOperatorTypesMutation, } = licensingApi;