Merge remote-tracking branch 'origin/dev' into fix/coc-exam-workflow-defects

# Conflicts:
#	apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx
#	libs/api/src/lib/features/licensing/licensing-api.ts
#	libs/api/src/lib/features/licensing/licensing.types.ts
This commit is contained in:
mihretue
2026-08-31 09:16:24 +00:00
101 changed files with 8172 additions and 2077 deletions

View File

@@ -14,6 +14,7 @@ import type {
InitiatePaymentResult,
IssuedLicense,
Inspection,
IssuancePeriod,
LicenseApplication,
LicenseCategoryDefinition,
LicenseStatus,
@@ -21,11 +22,16 @@ import type {
LicenseTypeRequirements,
OperatorType,
AssignableOfficer,
CertificateCategory,
CompletionEffect,
DocumentDecision,
DocumentReview,
ExportResult,
LicenseTemplate,
Paginated,
PickupAppointment,
PickupOffice,
PickupSlot,
QueueCounts,
QueueFilter,
Rank,
@@ -33,10 +39,14 @@ import type {
RemarkTargetType,
SavedQueueView,
SchemaIssue,
ServiceKind,
TemplateFieldPlacement,
TemplateLogoPlacement,
TemplatePageOptions,
TemplateVariable,
PersonalDocumentFilter,
PersonalDocumentGroup,
WorkflowProfile,
} from './licensing.types';
/**
@@ -63,6 +73,16 @@ function serialiseQueueFilter(
return params;
}
/** Sends only the facets that are set; `search=` would match nothing. */
function dropEmpty(filter: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (value === undefined || value === null || value === '' || value === false) continue;
params[key] = value;
}
return params;
}
const TAGS = [
'LicenseType',
'OperatorType',
@@ -75,8 +95,13 @@ const TAGS = [
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
'PickupOffice',
'PickupAppointment',
'Department',
'Rank',
// Owned by the personal-document slice; named here so declaring a mode of
// operation can invalidate the vault, whose slots depend on it.
'PersonalDocument',
] as const;
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
@@ -121,9 +146,17 @@ export const licensingApi = baseApi
method: 'PUT',
body,
}),
// The catalogue is filtered by this, so it has to refetch too.
// The catalogue is filtered by this, so it has to refetch too — and so
// is the personal document vault, which asks for the documents the
// declared modes of operation need.
invalidatesTags: (_r, error) =>
error ? [] : [listTag('OperatorType'), listTag('LicenseType')],
error
? []
: [
listTag('OperatorType'),
listTag('LicenseType'),
listTag('PersonalDocument'),
],
}),
/**
@@ -153,6 +186,12 @@ export const licensingApi = baseApi
feeNewApplication?: number | null;
feeRenewal?: number | null;
feeCurrency?: string;
// The examined-certificate stages. Unlike the two above, these are
// read live off the licence type rather than snapshotted, so the
// server refuses to clear one a candidate is currently waiting on.
feeEligibility?: number | null;
feeExamination?: number | null;
feeCertificate?: number | null;
}
>({
query: ({ id, ...body }) => ({
@@ -164,6 +203,53 @@ export const licensingApi = baseApi
error ? [] : [listTag('LicenseType'), itemTag('LicenseType', id)],
}),
/**
* How a licence type behaves: its workflow, eligibility gates, renewal
* policy and applicant rules — everything that used to be settable only
* by editing a seed file.
*
* Three of these (`workflowProfile`, `completionEffect`,
* `requiresExamination`) decide the course an application runs, and are
* read live rather than snapshotted. The server answers 409
* `license_type_in_use` when changing one would strand applications that
* have not yet had their approval decision.
*/
updateLicenseBehavior: builder.mutation<
LicenseType,
{
id: string;
workflowProfile?: WorkflowProfile;
serviceKind?: ServiceKind;
completionEffect?: CompletionEffect | null;
certificateCategory?: CertificateCategory | null;
requiresExamination?: boolean;
inspectionRequired?: boolean;
issuesCertificate?: boolean;
renewalEnabled?: boolean;
requiresSeafarerRegistration?: boolean;
requiresValidMedical?: boolean;
minSeaTimeDays?: number | null;
validityMonths?: number;
validityDays?: number | null;
capitalThreshold?: number | null;
renewalWindowDays?: number;
expiryReminderDays?: number[];
requiresOperatorMode?: boolean;
allowMultipleOpenDrafts?: boolean;
requiresIssuanceScheduling?: boolean;
uniqueFormKeyPath?: string | null;
slaHours?: number | null;
}
>({
query: ({ id, ...body }) => ({
url: `/license-types/${id}/behavior`,
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,
@@ -234,9 +320,30 @@ export const licensingApi = baseApi
providesTags: () => [listTag('DocumentRequirement')],
}),
/**
* Personal document slots, grouped by key and paged by the server.
*
* Its own endpoint rather than filtering `getDocumentRequirements` in the
* browser: one document can be configured against several licence types,
* so a page of rows would split a document in half and misreport its
* scope. The server groups first, then pages.
*/
getPersonalDocuments: builder.query<
Paginated<PersonalDocumentGroup>,
PersonalDocumentFilter | void
>({
query: (filter) => ({
url: '/document-requirements/personal',
params: dropEmpty(filter ?? {}),
}),
providesTags: () => [listTag('DocumentRequirement')],
}),
createDocumentRequirement: builder.mutation<
DocumentRequirement,
Partial<DocumentRequirement> & { licenseTypeId: string; key: string; name: DocumentRequirement['name'] }
// No `licenseTypeId` means a personal document, required for every
// licence and served from the applicant's own vault.
Partial<DocumentRequirement> & { key: string; name: DocumentRequirement['name'] }
>({
query: (body) => ({ url: '/document-requirements', method: 'POST', body }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
@@ -337,6 +444,11 @@ export const licensingApi = baseApi
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
}),
discardApplication: builder.mutation<{ deleted: boolean }, string>({
query: (id) => ({ url: `/license-applications/${id}`, method: 'DELETE' }),
invalidatesTags: (_r, error) => (error ? [] : [listTag('LicenseApplication')]),
}),
getMyApplications: builder.query<Paginated<LicenseApplication>, void>({
query: () => ({ url: '/license-applications/mine' }),
providesTags: () => [listTag('LicenseApplication')],
@@ -958,12 +1070,12 @@ export const licensingApi = baseApi
scheduleIssuance: builder.mutation<
LicenseApplication,
{ id: string; scheduledDate: string }
{ id: string; scheduledDate: string; scheduledPeriod: IssuancePeriod }
>({
query: ({ id, scheduledDate }) => ({
query: ({ id, scheduledDate, scheduledPeriod }) => ({
url: `/license-application-review/${id}/schedule-issuance`,
method: 'POST',
body: { scheduledDate },
body: { scheduledDate, scheduledPeriod },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
@@ -978,6 +1090,104 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// ------------------------------------------------------------- pickup
getPickupOffices: builder.query<PickupOffice[], void>({
query: () => ({ url: '/pickup/offices' }),
providesTags: [listTag('PickupOffice')],
}),
getPickupSlots: builder.query<
PickupSlot[],
{ officeId: string; from: string; to: string }
>({
query: ({ officeId, from, to }) => ({
url: `/pickup/offices/${officeId}/slots`,
params: { from, to },
}),
}),
schedulePickup: builder.mutation<
PickupAppointment,
{ applicationId: string; officeId: string; date: string; slotStartTime: string }
>({
query: (body) => ({ url: '/pickup/appointments', method: 'POST', body }),
invalidatesTags: (_r, error, { applicationId }) =>
error
? []
: [
itemTag('LicenseApplication', applicationId),
listTag('ApplicationQueue'),
listTag('PickupAppointment'),
],
}),
reschedulePickup: builder.mutation<
PickupAppointment,
{ appointmentId: string; officeId: string; date: string; slotStartTime: string }
>({
query: ({ appointmentId, ...body }) => ({
url: `/pickup/appointments/${appointmentId}/reschedule`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { appointmentId }) =>
error ? [] : [itemTag('PickupAppointment', appointmentId), listTag('PickupAppointment')],
}),
getPickupAppointmentsForApplication: builder.query<PickupAppointment[], string>({
query: (applicationId) => ({
url: `/pickup/applications/${applicationId}/appointments`,
}),
providesTags: (_r, _e, applicationId) => [itemTag('PickupAppointment', applicationId)],
}),
getPickupWorklist: builder.query<
PickupAppointment[],
{ date: string; officeId?: string }
>({
query: ({ date, officeId }) => ({
url: '/pickup/appointments',
params: officeId ? { date, officeId } : { date },
}),
providesTags: [listTag('PickupAppointment')],
}),
checkInPickup: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/check-in`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupIssued: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/issued`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupNoShow: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/no-show`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
createPickupOffice: builder.mutation<PickupOffice, Partial<PickupOffice>>({
query: (body) => ({ url: '/pickup/offices', method: 'POST', body }),
invalidatesTags: [listTag('PickupOffice')],
}),
updatePickupOffice: builder.mutation<
PickupOffice,
{ id: string } & Partial<PickupOffice>
>({
query: ({ id, ...body }) => ({
url: `/pickup/offices/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('PickupOffice', id), listTag('PickupOffice')],
}),
// --------------------------------------------------------- inspection
scheduleInspection: builder.mutation<
Inspection,
@@ -993,6 +1203,30 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
/** Moves a booked visit: another day, slot, inspector or place. */
rescheduleInspection: builder.mutation<
Inspection,
{
inspectionId: string;
applicationId: string;
scheduledDate: string;
timeSlot: 'MORNING' | 'AFTERNOON';
inspectorId?: string;
location?: string;
reason?: string;
}
>({
query: ({ inspectionId, scheduledDate, timeSlot, inspectorId, location, reason }) => ({
url: `/inspections/${inspectionId}/schedule`,
method: 'PATCH',
// applicationId is for cache invalidation only; the visit knows its
// own application.
body: { scheduledDate, timeSlot, inspectorId, location, reason },
}),
invalidatesTags: (_r, error, { applicationId }) =>
error ? [] : [itemTag('LicenseApplication', applicationId), listTag('Inspection')],
}),
getInspections: builder.query<Inspection[], string>({
query: (applicationId) => ({ url: `/inspections/application/${applicationId}` }),
providesTags: () => [listTag('Inspection')],
@@ -1046,10 +1280,12 @@ export const {
useGetLicenseTypesQuery,
useGetLicenseCategoriesQuery,
useUpdateLicenseFeesMutation,
useUpdateLicenseBehaviorMutation,
useUpdateFormSchemaMutation,
useValidateFormSchemaMutation,
useGetFormSchemaPaletteQuery,
useGetDocumentRequirementsQuery,
useGetPersonalDocumentsQuery,
useCreateDocumentRequirementMutation,
useUpdateDocumentRequirementMutation,
useDeleteDocumentRequirementMutation,
@@ -1067,6 +1303,7 @@ export const {
useGetLicenseTypeRequirementsQuery,
useCreateApplicationMutation,
useGetExamStateForApplicationQuery,
useDiscardApplicationMutation,
useGetMyApplicationsQuery,
useGetApplicationQuery,
useInitiatePaymentMutation,
@@ -1127,7 +1364,19 @@ export const {
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useGetPickupOfficesQuery,
useGetPickupSlotsQuery,
useSchedulePickupMutation,
useReschedulePickupMutation,
useGetPickupAppointmentsForApplicationQuery,
useGetPickupWorklistQuery,
useCheckInPickupMutation,
useMarkPickupIssuedMutation,
useMarkPickupNoShowMutation,
useCreatePickupOfficeMutation,
useUpdatePickupOfficeMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetInspectionsQuery,
useRecordInspectionResultMutation,
useGetNotificationsQuery,