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

@@ -6,6 +6,8 @@ export * from './lib/features/location';
export * from './lib/features/seafarer';
export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/personal-document';
export * from './lib/features/biometric-enrollment';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { resolveSessionContext } from "../session";
/**
* The one place the backend URL is resolved: VITE_BASE_API_URL from the env,
* falling back to the local dev API (3001 — the portal itself owns 3000 for
* the Fayda redirect). Import this; do not re-derive it.
*/
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
]?.trim() || "http://localhost:3000/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -172,6 +172,15 @@ const handlers: MockHandler[] = [
return detail ? clone(detail) : undefined;
},
},
{
method: 'DELETE',
pattern: /^\/license-applications\/([\w-]+)$/,
respond: (_req, match) => {
delete mockApplications[match[1]];
delete mockApplicationDetails[match[1]];
return { deleted: true };
},
},
{
method: 'PATCH',
pattern: /^\/license-applications\/([\w-]+)\/sections\/([\w-]+)$/,

View File

@@ -0,0 +1,67 @@
import { baseApi } from '../../base-api';
import type { BiometricEnrollment, EnrollBiometric } from './biometric-enrollment.types';
const TAG = 'BiometricEnrollment' as const;
const forProfile = (profileId: string) => ({ type: TAG, id: profileId }) as const;
/**
* Scanner capture (fingerprint, face) stored per profile. No applicant-facing
* endpoint — enrollment happens at a counter with a scanner.
*/
export const biometricEnrollmentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
enrollBiometric: builder.mutation<BiometricEnrollment, EnrollBiometric>({
query: (body) => ({ url: '/biometric-enrollments', method: 'POST', body }),
invalidatesTags: (r, error) => (error || !r ? [] : [forProfile(r.profileId)]),
}),
getBiometricEnrollments: builder.query<BiometricEnrollment[], string>({
query: (profileId) => ({ url: `/biometric-enrollments/profile/${profileId}` }),
providesTags: (_r, _e, profileId) => [forProfile(profileId)],
}),
/** Self-service, view-only: the caller's own live enrollments. */
getMyBiometricEnrollments: builder.query<BiometricEnrollment[], void>({
query: () => ({ url: '/biometric-enrollments/mine' }),
providesTags: [{ type: TAG, id: 'MINE' }],
}),
/** Dev/test only — the API reports false in production. */
getBiometricSimulateCapabilities: builder.query<{ simulateEnabled: boolean }, void>({
query: () => ({ url: '/biometric-enrollments/simulate/capabilities' }),
}),
revokeBiometricEnrollment: builder.mutation<
BiometricEnrollment,
{ id: string; profileId: string; reason: string }
>({
query: ({ id, reason }) => ({
url: `/biometric-enrollments/${id}/revoke`,
method: 'POST',
body: { reason },
}),
invalidatesTags: (_r, error, { profileId }) => (error ? [] : [forProfile(profileId)]),
}),
/** Stamps the profile's BSID once enrollment is confirmed. Requires an active enrollment. */
generateBsid: builder.mutation<{ id: string; bsid: string | null }, string>({
query: (profileId) => ({
url: `/biometric-enrollments/profile/${profileId}/generate-bsid`,
method: 'POST',
}),
invalidatesTags: (_r, error, profileId) => (error ? [] : [forProfile(profileId)]),
}),
}),
overrideExisting: false,
});
export const {
useEnrollBiometricMutation,
useGetBiometricEnrollmentsQuery,
useGetMyBiometricEnrollmentsQuery,
useGetBiometricSimulateCapabilitiesQuery,
useRevokeBiometricEnrollmentMutation,
useGenerateBsidMutation,
} = biometricEnrollmentApi;

View File

@@ -0,0 +1,32 @@
export type BiometricModality = 'FINGERPRINT' | 'FACE';
export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED';
export interface BiometricEnrollment {
id: string;
profileId: string;
modality: BiometricModality;
templateFormat: string;
qualityScore: number | null;
deviceId: string | null;
status: BiometricEnrollmentStatus;
enrolledById: string;
enrolledAt: string;
consentAt: string;
revokedReason: string | null;
revokedById: string | null;
revokedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface EnrollBiometric {
profileId: string;
modality: BiometricModality;
/** Vendor SDK template, base64. Never the raw scan image. */
template: string;
templateFormat: string;
qualityScore?: number;
deviceId?: string;
/** ISO 8601 — when the subject consented to capture. */
consentAt: string;
}

View File

@@ -0,0 +1,2 @@
export * from './biometric-enrollment.types';
export * from './biometric-enrollment-api';

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,

View File

@@ -1,6 +1,8 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
import type {
ApplicationKind,
Bilingual,
FamilyKind,
FieldCondition,
@@ -11,9 +13,10 @@ import type {
ValidationIssue,
} from './licensing.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/** A section with no `applicationKinds` applies to every kind, as before that field existed. */
function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind): boolean {
return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
}
/**
* Uploads a document straight to the API.
@@ -63,6 +66,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
UNDER_REVIEW: 'Under Review',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_EVALUATION: 'Under Evaluation',
RESUBMIT_REQUIRED: 'Resubmit Required',
INSPECTION_PENDING: 'Inspection Pending',
@@ -95,6 +99,7 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
DRAFT: 'gray',
SUBMITTED: 'blue',
UNDER_REVIEW: 'indigo',
AWAITING_BIOMETRICS: 'indigo',
UNDER_EVALUATION: 'indigo',
RESUBMIT_REQUIRED: 'orange',
INSPECTION_PENDING: 'cyan',
@@ -134,6 +139,7 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
DRAFT: 5,
SUBMITTED: 15,
UNDER_REVIEW: 30,
AWAITING_BIOMETRICS: 30,
UNDER_EVALUATION: 45,
RESUBMIT_REQUIRED: 30,
INSPECTION_PENDING: 55,
@@ -396,6 +402,15 @@ const ERROR_MESSAGES: Record<string, string> = {
inspection_not_passed:
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
license_type_inactive: 'This licence type is not currently accepting applications.',
// Licence-type configuration guards. Each of these refuses a change that
// would leave applications already in progress unable to move.
license_type_in_use:
'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
stage_fee_in_use:
'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
form_schema_missing_protected_paths:
'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
};
export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
@@ -451,12 +466,28 @@ export function buildWizardSteps(
* instead of showing an empty page.
*/
hasStaff?: boolean;
/**
* Whether this application has any document requirements to upload.
* False for a Damaged/Reissue application, which asks nothing beyond the
* Damage Information step — showing an empty Documents page would be a
* page to click past for nothing.
*/
hasDocuments?: boolean;
/** Active UI language. Components get this from `useLocalized`; this is a
* pure function, so the caller passes `i18n.language` through. */
language?: string;
/**
* The application's kind — NEW unless the caller is renewing or
* reissuing. A section scoped to a different kind via
* `applicationKinds` is left out entirely, the same as a `showWhen`
* that never holds.
*/
applicationKind?: ApplicationKind;
},
): WizardStep[] {
const kind = options?.applicationKind ?? 'NEW';
const visible = [...sections]
.filter((section) => sectionAppliesToKind(section, kind))
.filter((section) => conditionHolds(section.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
@@ -511,7 +542,9 @@ export function buildWizardSteps(
...(options?.hasStaff === false
? []
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
...(options?.hasDocuments === false
? []
: [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]),
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
];
}
@@ -597,6 +630,49 @@ interface ConditionLike {
anyOf?: ConditionLike[];
}
/**
* Section keys a condition reads, recursing `anyOf`.
*
* `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
* the section whose answer decides the condition.
*/
export function conditionSections(
condition: FieldCondition | undefined | null,
): string[] {
if (!condition) return [];
if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
if (!condition.field) return [];
const [sectionKey] = condition.field.split('.');
return sectionKey ? [sectionKey] : [];
}
/**
* Sections whose visibility hangs on an answer in one of `flagged`.
*
* Mirrors the server's `sectionsDependingOn`: an officer flagging the section
* that holds the vessel category is asking for an answer that decides which
* fields in other sections are required, so those sections have to open too —
* otherwise the applicant sees newly-required fields they cannot edit and
* cannot resubmit.
*/
export function sectionsDependingOn(
sections: FormSectionConfig[],
flagged: Set<string>,
): Set<string> {
const dependent = new Set<string>();
if (flagged.size === 0) return dependent;
for (const section of sections) {
if (flagged.has(section.key)) continue;
const reads = [
section.showWhen,
...(section.fields ?? []).map((field) => field.showWhen),
].flatMap(conditionSections);
if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
}
return dependent;
}
export function conditionHolds(
condition: FieldCondition | undefined | null,
formData: Record<string, Record<string, unknown>>,

View File

@@ -25,6 +25,9 @@ export type LicenseStatus =
| "DRAFT"
| "SUBMITTED"
| "UNDER_REVIEW"
// Seafarer registration only: same slot UNDER_REVIEW occupies elsewhere,
// but approval is blocked until the applicant's profile has a BSID.
| "AWAITING_BIOMETRICS"
| "UNDER_EVALUATION"
// Employee filed their review; parked with the team leader for a decision.
| "REVIEW_REPORTED"
@@ -60,7 +63,10 @@ export type LicenseStatus =
| "EXAM_PASSED"
| "EXAM_FAILED";
export type ApplicationKind = "NEW" | "RENEWAL";
export type ApplicationKind = "NEW" | "RENEWAL" | "REISSUE";
/** Half-day window a team leader books an applicant's document pickup into. */
export type IssuancePeriod = "MORNING" | "AFTERNOON";
export type FormFieldType =
| "TEXT"
@@ -117,6 +123,12 @@ export interface FormSectionConfig {
group?: string;
/** Position of the group in the stepper; lowest value in a group wins. */
groupOrder?: number;
/**
* Restricts this section to specific application kinds — e.g. the
* Damaged/Reissue "Damage Information" step. Undefined or empty means
* every kind.
*/
applicationKinds?: ApplicationKind[];
}
/** Grouping the portal organises the licence catalogue by. */
@@ -175,6 +187,11 @@ export interface LicenseType {
feeCurrency: string;
capitalThreshold: string | number | null;
validityMonths: number;
/**
* A term in days instead of months, for licences shorter than a month can
* express. Wins over `validityMonths` when set; null keeps calendar months.
*/
validityDays?: number | null;
/**
* Target turnaround in hours. Null means this type is not tracked against
* an SLA, which the grid renders as "—" rather than as instantly overdue.
@@ -204,11 +221,39 @@ export interface LicenseType {
// --------------------------------------------------- examined certificates
/** Approval establishes eligibility; the certificate is earned by exam. */
requiresExamination?: boolean;
/** Assessment fee, due on submission before any officer review. */
feeEligibility?: string | number | null;
/** Fee per sitting. Falls back to `feeNewApplication` when null. */
feeExamination?: string | number | null;
/** Fee to issue after a pass. Falls back to `feeNewApplication` when null. */
feeCertificate?: string | number | null;
// ------------------------------------------------------- behaviour config
// Editable from the backoffice Behavior tab. Optional because the API has
// only recently begun returning them and older mocks/fixtures omit them.
/** Licence or registration. Catalogue metadata; no behaviour hangs off it. */
serviceKind?: ServiceKind;
/** Platform side effect fired when an application of this type completes. */
completionEffect?: CompletionEffect | null;
/** Only ACTIVE registered seafarers may apply. */
requiresSeafarerRegistration?: boolean;
/** Submission requires a current, unexpired medical certificate. */
requiresValidMedical?: boolean;
/** Minimum VERIFIED sea time in days at submission. Null means no floor. */
minSeaTimeDays?: number | null;
/** Whether several open drafts may exist at once, for per-asset registrations. */
allowMultipleOpenDrafts?: boolean;
/** Days before expiry that renewal opens. */
renewalWindowDays?: number;
/** Days before expiry to remind the holder, most distant first. */
expiryReminderDays?: number[];
/**
* Dotted `formData` path whose answer may appear on only one live
* application of this type. Null means no such rule.
*/
uniqueFormKeyPath?: string | null;
// ---------------------------------------------------------- STCW mapping
certificateCategory?: CertificateCategory | null;
stcwControlled?: boolean;
@@ -252,7 +297,12 @@ export interface StcwCapacityRow {
export interface DocumentRequirement {
id: string;
licenseTypeId: string;
/**
* Null for a personal document — one every applicant keeps in their own
* vault regardless of what they apply for, rather than a slot on one
* licence's application form.
*/
licenseTypeId: string | null;
key: string;
name: Bilingual;
description?: Bilingual;
@@ -263,6 +313,14 @@ export interface DocumentRequirement {
maxSizeMb: number;
requiresValidityDates: boolean;
allowMultiple: boolean;
/**
* True for a personal document — one the applicant keeps in their own vault
* — rather than an upload slot on an application form. Orthogonal to
* `licenseTypeId`, which still says which licences it applies to.
*/
isPersonal: boolean;
/** Files the slot accepts: 1, 2 for a front and back, null for unlimited. */
maxFiles: number | null;
sortOrder: number;
isActive: boolean;
}
@@ -346,6 +404,7 @@ export interface LicenseApplication {
issuedLicenseId: string | null;
/** Set once an officer schedules pickup for a document requiring in-person handover. */
scheduledIssuanceDate: string | null;
scheduledIssuancePeriod: IssuancePeriod | null;
scheduledBy: string | null;
createdAt: string;
}
@@ -437,6 +496,13 @@ export interface ApplicationApplicant {
export interface ApplicationDetail {
application: LicenseApplication;
/**
* Current status of the license this application issued, independent of
* the application's own (permanently historical) status — a later
* reissue/renewal can supersede the license without changing what this
* application itself accomplished. Null when nothing has been issued yet.
*/
issuedLicenseStatus: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED" | null;
relatedApplications?: LicenseApplication[];
/** Null when the applicant has no profile row (never expected in practice). */
applicant: ApplicationApplicant | null;
@@ -463,6 +529,50 @@ export interface Inspection {
findings: string | null;
}
export type PickupAppointmentStatus =
| "SCHEDULED"
| "CHECKED_IN"
| "ISSUED"
| "NO_SHOW"
| "RESCHEDULED"
| "CANCELLED";
export interface PickupOffice {
id: string;
name: string;
address: string | null;
/** 0=Sunday .. 6=Saturday. */
workingDays: number[];
startTime: string;
endTime: string;
slotDurationMinutes: number;
maxApplicantsPerSlot: number;
rescheduleMinNoticeHours: number;
isActive: boolean;
}
export interface PickupSlot {
date: string;
slotStartTime: string;
capacity: number;
booked: number;
available: number;
}
export interface PickupAppointment {
id: string;
applicationId: string;
appointmentNumber: string;
officeId: string;
date: string;
slotStartTime: string;
status: PickupAppointmentStatus;
rescheduledFromId: string | null;
rescheduleCount: number;
checkedInAt: string | null;
checkedInById: string | null;
}
export interface AppNotification {
id: string;
subject: Bilingual;
@@ -479,6 +589,7 @@ export interface QueueFilter {
licenseTypeId?: string;
search?: string;
status?: LicenseStatus[];
kind?: ApplicationKind;
/** Officer uuid, or the literal 'unassigned'. */
assignee?: string;
submittedFrom?: string;
@@ -501,6 +612,15 @@ export type QueueSortField =
/** Review → evaluation → (inspection) → approval, or the short registration course. */
export type WorkflowProfile = "STANDARD" | "REGISTRATION";
/** A permission to operate, or a registration granting a status and a number. */
export type ServiceKind = "LICENSE" | "REGISTRATION";
/** Platform side effect fired when an application reaches COMPLETED. */
export type CompletionEffect =
| "REGISTER_SEAFARER"
| "REGISTER_VESSEL"
| "OPEN_SEAFARER_DOCUMENTS";
/** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts {
unassigned: number;
@@ -744,10 +864,40 @@ export interface IssuedLicense {
* configuration.
*/
renewable?: boolean;
/** Whether a Damaged/Reissue replacement may be requested for this licence. */
reissuable?: boolean;
verificationCode: string;
certificateFileKey: string | null;
}
/**
* One personal document as the backoffice manages it: every configured row
* sharing a key, which is one slot in the applicant's vault. Several rows mean
* the document is scoped to several licence types.
*/
export interface PersonalDocumentGroup {
key: string;
rows: DocumentRequirement[];
}
export interface PersonalDocumentFilter {
/** Matches the key and the name in either locale. */
search?: string;
/** A licence type also matches the documents every licence asks for. */
licenseTypeId?: string;
/** Narrows to the documents configured against no licence type at all. */
globalOnly?: boolean;
sortBy?: 'sortOrder' | 'key' | 'name';
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
/** Which locale `sortBy: "name"` sorts on. */
locale?: 'en' | 'am';
}
// No `EligibleExam`: it typed the schedule-exam picker's options, and
// picking a sitting for a named applicant is not something the back office
// does any more — candidates register for a published sitting themselves.
/**
* Where a certificate application stands in the examination leg, derived

View File

@@ -0,0 +1,3 @@
export * from './personal-document.types';
export * from './personal-document.upload';
export * from './personal-document-api';

View File

@@ -0,0 +1,40 @@
import { baseApi } from '../../base-api';
import type { PersonalDocumentSlot } from './personal-document.types';
const TAG = 'PersonalDocument' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
/**
* The applicant's own documents — identity card, photograph, education —
* kept against their profile rather than any one application, so they survive
* having no seafarer registration yet.
*
* Reads and deletes live here; the two uploads do not. `fetch` — what
* `fetchBaseQuery` runs on — cannot report how much of a request body has gone
* up, so they use XHR instead (`personal-document.upload.ts`) and the page
* refetches this query when one finishes.
*/
export const personalDocumentApi = baseApi
.enhanceEndpoints({ addTagTypes: [TAG] })
.injectEndpoints({
endpoints: (builder) => ({
getMyPersonalDocuments: builder.query<{ slots: PersonalDocumentSlot[] }, void>({
query: () => ({ url: '/profiles/me/documents' }),
providesTags: () => [LIST],
}),
deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({
query: (fileId) => ({
url: `/profiles/me/documents/files/${fileId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
}),
overrideExisting: false,
});
export const {
useGetMyPersonalDocumentsQuery,
useDeletePersonalDocumentFileMutation,
} = personalDocumentApi;

View File

@@ -0,0 +1,22 @@
import type { AttachmentFile, Bilingual } from '../licensing/licensing.types';
/**
* One slot in the applicant's personal document vault, with whatever they
* have put in it.
*
* The slot itself is configuration: a document requirement that names no
* licence type applies to every licence, so the backoffice adds and retires
* these without a release. That is why the label, the accepted types and the
* limits arrive from the API rather than living in the portal.
*/
export interface PersonalDocumentSlot {
key: string;
name: Bilingual;
description: Bilingual | null;
/** How many files the slot holds; null means as many as the holder has. */
maxFiles: number | null;
allowedMimeTypes: string[];
maxSizeMb: number;
sortOrder: number;
files: AttachmentFile[];
}

View File

@@ -0,0 +1,116 @@
import { resolveTokenFromStorage } from '../../session';
import type { PersonalDocumentSlot } from './personal-document.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/** What the server says when it refuses a file — see ProfileDocumentsService. */
export interface PersonalDocumentError {
message?: string;
[detail: string]: unknown;
}
export type PersonalDocumentUploadResult =
| { ok: true; slot: PersonalDocumentSlot }
| { ok: false; error: PersonalDocumentError };
/**
* Uploads one file and reports how far it has got.
*
* XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no
* upload progress event, so a request body of any size is a spinner with
* nothing behind it. That is tolerable for a 5 MB scan and not for the video a
* slot can now be opened to, where the difference between "uploading" and
* "uploading, 12%" is the difference between waiting and reloading the page.
*
* The caller refetches the vault afterwards; nothing here touches the cache.
*/
function upload(
path: string,
method: 'POST' | 'PUT',
body: FormData,
onProgress?: (percent: number) => void,
): Promise<PersonalDocumentUploadResult> {
return new Promise((resolve) => {
const request = new XMLHttpRequest();
request.open(method, `${BASE_API_URL}${path}`);
const token = resolveTokenFromStorage();
if (token) request.setRequestHeader('Authorization', `Bearer ${token}`);
request.upload.onprogress = (event) => {
// Not every browser knows the total for a streamed body; without it a
// percentage would be invented, so the caller keeps its spinner.
if (!event.lengthComputable || !onProgress) return;
onProgress(Math.round((event.loaded / event.total) * 100));
};
request.onload = () => {
const parsed = parseBody(request.responseText);
if (request.status >= 200 && request.status < 300) {
resolve({ ok: true, slot: parsed as PersonalDocumentSlot });
return;
}
resolve({ ok: false, error: toError(parsed, request.status) });
};
// A dropped connection and a cancelled request both land here; neither
// carries a server message, so the caller falls back to its own wording.
request.onerror = () => resolve({ ok: false, error: {} });
request.onabort = () => resolve({ ok: false, error: {} });
request.send(body);
});
}
function parseBody(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Nest wraps a thrown `BadRequestException({ message, ... })` as
* `{ message: { message, ... } }`, and a plain string message as
* `{ message: "slot_full" }`. Both are flattened to the object the UI
* translates by its `message` key.
*/
function toError(parsed: unknown, status: number): PersonalDocumentError {
const message = (parsed as { message?: unknown } | null)?.message;
if (typeof message === 'object' && message !== null) {
return message as PersonalDocumentError;
}
if (typeof message === 'string') return { message };
return { message: `http_${status}` };
}
/** Adds one file to a personal document slot. */
export function uploadPersonalDocumentFile(params: {
documentKey: string;
file: File;
onProgress?: (percent: number) => void;
}): Promise<PersonalDocumentUploadResult> {
const body = new FormData();
body.append('documentKey', params.documentKey);
body.append('file', params.file, params.file.name);
return upload('/profiles/me/documents', 'POST', body, params.onProgress);
}
/** Swaps one file for another in the same slot. */
export function replacePersonalDocumentFile(params: {
fileId: string;
file: File;
onProgress?: (percent: number) => void;
}): Promise<PersonalDocumentUploadResult> {
const body = new FormData();
body.append('file', params.file, params.file.name);
return upload(
`/profiles/me/documents/files/${params.fileId}`,
'PUT',
body,
params.onProgress,
);
}

View File

@@ -4,6 +4,7 @@ import type {
SeafarerDocument,
SeafarerDocumentDetail,
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentRow,
SeafarerDocumentStatus,
} from './seafarer-document.types';
@@ -14,6 +15,7 @@ const item = (id: string) => ({ type: TAG, id }) as const;
export interface SeafarerDocumentListFilter {
kind?: SeafarerDocumentKind;
requestKind?: SeafarerDocumentRequestKind;
status?: SeafarerDocumentStatus;
search?: string;
take?: number;
@@ -63,6 +65,16 @@ export const seafarerDocumentApi = baseApi
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
renewSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/renew`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
replaceSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/replace`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
// --------------------------------------------------------------- review
listSeafarerDocuments: builder.query<
{ total: number; items: SeafarerDocumentRow[] },
@@ -121,6 +133,8 @@ export const {
useInitiateDocumentPaymentMutation,
useGetDocumentPaymentQuery,
useBypassDocumentPaymentMutation,
useRenewSeafarerDocumentMutation,
useReplaceSeafarerDocumentMutation,
useListSeafarerDocumentsQuery,
useGetSeafarerDocumentReviewQuery,
useLazyGetSeafarerDocumentReviewDownloadQuery,

View File

@@ -1,10 +1,26 @@
import type { SeafarerDocumentKind, SeafarerDocumentStatus } from './seafarer-document.types';
import type {
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentStatus,
} from './seafarer-document.types';
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_LABELS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'New',
RENEWAL: 'Renewal',
REPLACEMENT: 'Replacement',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_COLORS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'gray',
RENEWAL: 'blue',
REPLACEMENT: 'orange',
};
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
AWAITING_REGISTRATION: 'Awaiting Registration',
PAYMENT_PENDING: 'Payment Pending',

View File

@@ -12,14 +12,19 @@ export type SeafarerDocumentStatus =
| 'REJECTED'
| 'CANCELLED';
/** A Seaman Book or BTC request — opened by a seafarer registration. */
/** NEW comes from a seafarer registration; RENEWAL/REPLACEMENT are applicant-initiated. */
export type SeafarerDocumentRequestKind = 'NEW' | 'RENEWAL' | 'REPLACEMENT';
/** A Seaman Book or BTC request — opened by a seafarer registration, or by the applicant as a renewal/replacement. */
export interface SeafarerDocument {
id: string;
kind: SeafarerDocumentKind;
requestKind: SeafarerDocumentRequestKind;
requestNumber: string;
applicantUserId: string;
profileId: string | null;
seafarerRegistrationId: string | null;
previousDocumentId: string | null;
status: SeafarerDocumentStatus;
feeAmount: number | null;
feeCurrency: string;

View File

@@ -10,9 +10,17 @@ const TAG = 'SeafarerRegistration' as const;
const LIST = { type: TAG, id: 'LIST' } as const;
const item = (id: string) => ({ type: TAG, id }) as const;
export type SeafarerRegistrationSortField =
| 'submittedAt'
| 'registrationNumber'
| 'lastName'
| 'status';
export interface SeafarerRegistrationListFilter {
status?: SeafarerRegistrationStatus;
search?: string;
sortBy?: SeafarerRegistrationSortField;
sortDir?: 'ASC' | 'DESC';
take?: number;
skip?: number;
}

View File

@@ -25,6 +25,19 @@ export const DEPARTMENT_OPTIONS = [
{ value: 'CATERING', label: 'Catering' },
];
/**
* The STCW limitation a Certificate of Competency is issued under.
*
* One choice, worded generically, because the threshold it means differs by
* department — gross tonnage on deck, propulsion power in the engine room. The
* certificate states the department-specific wording; the applicant only picks
* which side of the line they serve.
*/
export const RANK_TIER_OPTIONS = [
{ value: 'ABOVE', label: 'Above' },
{ value: 'BELOW', label: 'Below' },
];
export const HAIR_COLOR_OPTIONS = [
{ value: 'BLACK', label: 'Black' },
{ value: 'BROWN', label: 'Brown' },
@@ -117,6 +130,8 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
export const SEAFARER_REGISTRATION_STATUS_LABELS: Record<SeafarerRegistrationStatus, string> = {
DRAFT: 'Draft',
SUBMITTED: 'Submitted',
AWAITING_BIOMETRICS: 'Awaiting Biometrics',
UNDER_REVIEW: 'Under Review',
RESUBMIT_REQUIRED: 'Corrections Requested',
APPROVED: 'Approved',
REJECTED: 'Rejected',
@@ -139,6 +154,8 @@ export const SEAFARER_REGISTRATION_STATUS_TONES: Record<
> = {
DRAFT: 'neutral',
SUBMITTED: 'info',
AWAITING_BIOMETRICS: 'pending',
UNDER_REVIEW: 'info',
RESUBMIT_REQUIRED: 'pending',
APPROVED: 'success',
REJECTED: 'danger',
@@ -158,6 +175,7 @@ export const SEAFARER_REGISTRATION_FIELD_LABELS: Record<keyof SeafarerRegistrati
passportNumber: 'Passport Number',
passportExpiry: 'Passport Expiry Date',
department: 'Department',
tier: 'Certificate Limitation',
locationId: 'Location',
permanentAddress: 'Permanent Address',
currentAddress: 'Current Address',
@@ -192,7 +210,7 @@ export const SEAFARER_REGISTRATION_SECTIONS: {
{
key: 'identity',
title: 'Identity',
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department'],
fields: ['placeOfBirth', 'passportNumber', 'passportExpiry', 'department', 'tier'],
},
{
key: 'address',
@@ -220,6 +238,7 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
gender: GENDER_OPTIONS,
maritalStatus: MARITAL_STATUS_OPTIONS,
department: DEPARTMENT_OPTIONS,
tier: RANK_TIER_OPTIONS,
hairColor: HAIR_COLOR_OPTIONS,
eyeColor: EYE_COLOR_OPTIONS,
bloodType: BLOOD_TYPE_OPTIONS,

View File

@@ -1,7 +1,16 @@
import type { SeafarerDepartment } from '../seafarer/seafarer.types';
/** Above/Below — the STCW limitation a Certificate of Competency is issued under. */
export type RankTier = 'ABOVE' | 'BELOW';
export type SeafarerRegistrationStatus =
| 'DRAFT'
// Filed, waiting on the counter-side biometric capture that produces the
// BSID approval is blocked on.
| 'AWAITING_BIOMETRICS'
// BSID issued — the file is a reviewer's to decide.
| 'UNDER_REVIEW'
// Retained for registrations filed before biometrics moved ahead of review.
| 'SUBMITTED'
| 'RESUBMIT_REQUIRED'
| 'APPROVED'
@@ -30,6 +39,11 @@ export interface SeafarerRegistrationAnswers {
passportNumber: string | null;
passportExpiry: string | null;
department: SeafarerDepartment | null;
/**
* The STCW ship-size limitation this seafarer's CoCs are issued under.
* Read with `department` to pick the CoC ladder; CoP carries no tier.
*/
tier: RankTier | null;
locationId: string | null;
permanentAddress: string | null;
currentAddress: string | null;

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import Cookies from "js-cookie";
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
currentPositionId: 'x-current-position-id',
currentProjectId: 'x-current-project-id',
tenantId: "x-tenant-id",
organizationUnitId: "x-organization-unit-id",
currentPositionId: "x-current-position-id",
currentProjectId: "x-current-project-id",
} as const;
/**
* Which app this bundle is, so it reads its own session and no one else's.
*
* Set by each app's store via `configureSessionScope`. Cookies ignore the
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
* port, so `localhost:3000` and `localhost:4201` share one jar: without a
* scope the backoffice would happily authenticate as whoever last signed into
* the portal, and render a staff console with an applicant's permissions.
*/
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
}
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
const LEGACY_TOKEN_KEY = 'auth-token';
const LEGACY_TOKEN_KEY = "auth-token";
export function resolveTokenFromStorage(): string | undefined {
// Only this app's key, then the legacy unprefixed one. Never another app's: