From c40fbbfb3fd94ab79cd68ae79b0c8a3a6cee711f Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 14 Oct 2026 10:45:11 -0700 Subject: [PATCH 1/2] RU-T56 Department Custom names, base type fixes --- src/api/config/terminology.ts | 11 + src/api/devices/__tests__/push.test.ts | 16 +- src/api/devices/push.ts | 7 + src/api/novu/__tests__/inbox.test.ts | 49 ++++ src/api/novu/inbox.ts | 22 +- src/app/(app)/__tests__/index.test.tsx | 3 + src/app/(app)/_layout.tsx | 35 ++- src/app/(app)/checklists.tsx | 4 +- src/app/(app)/index.tsx | 4 +- src/app/(app)/inventory/index.tsx | 12 +- src/app/(app)/records.tsx | 4 +- src/app/call/[id]/edit.tsx | 5 +- src/app/call/__tests__/edit.test.tsx | 20 ++ src/app/maps/_layout.tsx | 5 +- src/app/records/[id].tsx | 14 +- src/app/records/__tests__/[id].test.tsx | 18 ++ src/app/records/new.tsx | 12 +- .../notifications/NotificationInbox.tsx | 8 +- .../__tests__/record-list-item.test.tsx | 115 ++++++++ src/components/records/record-list-item.tsx | 7 +- .../__tests__/sidebar-content.test.tsx | 49 +++- src/components/sidebar/sidebar-content.tsx | 10 +- .../use-unit-subscriber-hash.test.ts | 67 +++++ src/hooks/use-record-type-name.ts | 17 ++ src/hooks/use-unit-subscriber-hash.ts | 45 ++++ src/lib/__tests__/base-types.test.ts | 84 ++++++ src/lib/__tests__/call-field-policy.test.ts | 27 ++ src/lib/__tests__/unit-status-set.test.ts | 64 +++++ src/lib/base-types.ts | 207 +++++++++++++++ src/lib/call-field-policy.ts | 18 ++ src/lib/unit-status-set.ts | 72 +++++ .../callPriorities/callPriorityResultData.ts | 2 + src/models/v4/callTypes/callTypeResultData.ts | 2 + src/models/v4/calls/callResultData.ts | 2 + src/models/v4/configs/getTerminologyResult.ts | 17 ++ .../device/nativePushUnRegistrationInput.ts | 6 + .../v4/personnel/personnelInfoResultData.ts | 6 + .../v4/security/departmentRightsResultData.ts | 8 + src/models/v4/statuses/statusesResultData.ts | 4 +- src/models/v4/units/unitInfoResultData.ts | 2 + .../__tests__/app-reset.service.test.ts | 20 ++ .../__tests__/push-notification-hook.test.ts | 4 + .../push-notification-sign-out.test.ts | 251 ++++++++++++++++++ .../__tests__/push-notification.test.ts | 4 + src/services/app-reset.service.ts | 12 + src/services/push-notification.ts | 69 ++++- src/stores/app/__tests__/core-store.test.ts | 71 +++++ src/stores/app/core-store.ts | 21 +- .../terminology/__tests__/store.test.ts | 116 ++++++++ src/stores/terminology/store.ts | 140 ++++++++++ 50 files changed, 1724 insertions(+), 64 deletions(-) create mode 100644 src/api/config/terminology.ts create mode 100644 src/api/novu/__tests__/inbox.test.ts create mode 100644 src/components/records/__tests__/record-list-item.test.tsx create mode 100644 src/hooks/__tests__/use-unit-subscriber-hash.test.ts create mode 100644 src/hooks/use-record-type-name.ts create mode 100644 src/hooks/use-unit-subscriber-hash.ts create mode 100644 src/lib/__tests__/base-types.test.ts create mode 100644 src/lib/__tests__/unit-status-set.test.ts create mode 100644 src/lib/base-types.ts create mode 100644 src/lib/unit-status-set.ts create mode 100644 src/models/v4/configs/getTerminologyResult.ts create mode 100644 src/models/v4/device/nativePushUnRegistrationInput.ts create mode 100644 src/services/__tests__/push-notification-sign-out.test.ts create mode 100644 src/stores/terminology/__tests__/store.test.ts create mode 100644 src/stores/terminology/store.ts diff --git a/src/api/config/terminology.ts b/src/api/config/terminology.ts new file mode 100644 index 00000000..48e65948 --- /dev/null +++ b/src/api/config/terminology.ts @@ -0,0 +1,11 @@ +import { type GetTerminologyResult } from '@/models/v4/configs/getTerminologyResult'; + +import { createApiEndpoint } from '../common/client'; + +const getTerminologyApi = createApiEndpoint('/Config/GetTerminology'); + +/** The department's own names for modules and built-in record types (setting 118). */ +export const getTerminology = async () => { + const response = await getTerminologyApi.get(); + return response.data; +}; diff --git a/src/api/devices/__tests__/push.test.ts b/src/api/devices/__tests__/push.test.ts index 1b20874e..3855eddb 100644 --- a/src/api/devices/__tests__/push.test.ts +++ b/src/api/devices/__tests__/push.test.ts @@ -1,9 +1,10 @@ jest.mock('@/api/common/client', () => ({ createApiEndpoint: jest.fn(() => ({ post: jest.fn() })) })); import { createApiEndpoint } from '@/api/common/client'; -import { registerUnitDevice, unRegisterWebPush } from '@/api/devices/push'; +import { registerUnitDevice, unRegisterNativePush, unRegisterWebPush } from '@/api/devices/push'; -const [registerEndpoint, unregisterEndpoint] = (createApiEndpoint as jest.Mock).mock.results.map((result) => result.value as { post: jest.Mock }); +const [registerEndpoint, unregisterEndpoint, unregisterNativeEndpoint] = (createApiEndpoint as jest.Mock).mock.results.map((result) => result.value as { post: jest.Mock }); +const endpointPaths = (createApiEndpoint as jest.Mock).mock.calls.map(([path]) => path as string); describe('web push requests', () => { beforeEach(() => { @@ -31,4 +32,15 @@ describe('web push requests', () => { expect(unregisterEndpoint.post).toHaveBeenCalledWith(input, controller.signal); }); + + it('sends a native token to the native push unregistration endpoint', async () => { + const input = { UnitId: '12', Token: 'native-token', Prefix: 'DEPT' }; + const response = { Status: 'success' }; + unregisterNativeEndpoint.post.mockResolvedValueOnce({ data: response }); + + await expect(unRegisterNativePush(input)).resolves.toBe(response); + + expect(endpointPaths[2]).toBe('/Devices/UnRegisterNativePush'); + expect(unregisterNativeEndpoint.post).toHaveBeenCalledWith(input, undefined); + }); }); diff --git a/src/api/devices/push.ts b/src/api/devices/push.ts index d466e878..946be3b8 100644 --- a/src/api/devices/push.ts +++ b/src/api/devices/push.ts @@ -1,10 +1,12 @@ import { createApiEndpoint } from '@/api/common/client'; +import { type NativePushUnRegistrationInput } from '@/models/v4/device/nativePushUnRegistrationInput'; import { type PushRegistrationResult } from '@/models/v4/device/pushRegistrationResult'; import { type PushRegistrationUnitInput } from '@/models/v4/device/pushRegistrationUnitInput'; import { type WebPushUnRegistrationInput } from '@/models/v4/device/webPushUnRegistrationInput'; const registerUnitDeviceApi = createApiEndpoint('/Devices/RegisterUnitDevice'); const unRegisterWebPushApi = createApiEndpoint('/Devices/UnRegisterWebPush'); +const unRegisterNativePushApi = createApiEndpoint('/Devices/UnRegisterNativePush'); export const registerUnitDevice = async (data: PushRegistrationUnitInput, signal?: AbortSignal) => { const response = await registerUnitDeviceApi.post({ ...data }, signal); @@ -15,3 +17,8 @@ export const unRegisterWebPush = async (data: WebPushUnRegistrationInput, signal const response = await unRegisterWebPushApi.post({ ...data }, signal); return response.data; }; + +export const unRegisterNativePush = async (data: NativePushUnRegistrationInput, signal?: AbortSignal) => { + const response = await unRegisterNativePushApi.post({ ...data }, signal); + return response.data; +}; diff --git a/src/api/novu/__tests__/inbox.test.ts b/src/api/novu/__tests__/inbox.test.ts new file mode 100644 index 00000000..4cea056f --- /dev/null +++ b/src/api/novu/__tests__/inbox.test.ts @@ -0,0 +1,49 @@ +import { deleteMessage, getUnitSubscriberHash } from '../inbox'; + +jest.mock('../../common/client', () => { + const del = jest.fn(); + const get = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get, post: jest.fn(), put: jest.fn(), delete: del })), + __mockDelete: del, + __mockGet: get, + }; +}); + +const { __mockDelete: mockDelete, __mockGet: mockGet } = jest.requireMock('../../common/client') as { __mockDelete: jest.Mock; __mockGet: jest.Mock }; + +describe('inbox deleteMessage api', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDelete.mockResolvedValue({ data: undefined }); + }); + + // The server only deletes from an inbox the caller owns; the Unit app's inbox is the active unit's, so + // without the unit it looks in the user's own inbox and answers 404. + it('sends the active unit so the server deletes from the unit inbox', async () => { + await deleteMessage('66f1c2a9e4b0a1b2c3d4e5f6', '15298'); + + expect(mockDelete).toHaveBeenCalledWith({ messageId: '66f1c2a9e4b0a1b2c3d4e5f6', unitId: '15298' }); + }); + + it('leaves unitId out when there is no active unit', async () => { + await deleteMessage('66f1c2a9e4b0a1b2c3d4e5f6', null); + + expect(mockDelete).toHaveBeenCalledWith({ messageId: '66f1c2a9e4b0a1b2c3d4e5f6' }); + }); +}); + +describe('inbox getUnitSubscriberHash api', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('asks for the unit inbox hash and returns the response body', async () => { + mockGet.mockResolvedValue({ data: { Data: { UnitId: '15298', SubscriberHash: 'abc123' } } }); + + const result = await getUnitSubscriberHash('15298'); + + expect(mockGet).toHaveBeenCalledWith({ unitId: '15298' }); + expect(result.Data.SubscriberHash).toBe('abc123'); + }); +}); diff --git a/src/api/novu/inbox.ts b/src/api/novu/inbox.ts index 86f1bc4b..c3b2c3b9 100644 --- a/src/api/novu/inbox.ts +++ b/src/api/novu/inbox.ts @@ -1,10 +1,30 @@ import { createApiEndpoint } from '../common/client'; const getGroupsApi = createApiEndpoint('/Inbox/DeleteMessage'); +const getUnitSubscriberHashApi = createApiEndpoint('/Inbox/GetUnitSubscriberHash'); -export const deleteMessage = async (messageId: string) => { +export interface UnitSubscriberHashResult { + Data: { + UnitId: string; + SubscriberHash?: string | null; + }; +} + +// The inbox belongs to the active unit's Novu subscriber, so the server needs the unit to find the message. +// Without one it only looks in the signed-in user's own inbox and answers 404. +export const deleteMessage = async (messageId: string, unitId?: string | null) => { const response = await getGroupsApi.delete({ messageId: encodeURIComponent(messageId), + ...(unitId ? { unitId } : {}), + }); + return response.data; +}; + +// The Novu subscriberHash for the unit's inbox ({DepartmentCode}_Unit_{unitId}). Novu refuses an inbox session +// without it once HMAC is enforced on the environment. +export const getUnitSubscriberHash = async (unitId: string) => { + const response = await getUnitSubscriberHashApi.get({ + unitId, }); return response.data; }; diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/app/(app)/__tests__/index.test.tsx index 6326abf0..0ca7b7b2 100644 --- a/src/app/(app)/__tests__/index.test.tsx +++ b/src/app/(app)/__tests__/index.test.tsx @@ -41,6 +41,9 @@ jest.mock('@/api/mapping/mapping', () => ({ Data: { MapMakerInfos: [] }, }), })); +jest.mock('@/api/config/terminology', () => ({ + getTerminology: jest.fn(), +})); // Camera commands issued by the screen. The MapView mock reports the map ready // on mount and the Camera mock exposes a real imperative handle, so the // follow-camera effects actually run in tests. diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index e5a1fbbe..2af254d1 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -27,6 +27,7 @@ import { useAnalytics } from '@/hooks/use-analytics'; import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { useSharedSessionLifecycle } from '@/hooks/use-shared-session-lifecycle'; import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle'; +import { useUnitSubscriberHash } from '@/hooks/use-unit-subscriber-hash'; import { getAppTabBarHeight } from '@/lib/app-shell-layout'; import { useAuthStore } from '@/lib/auth'; import { cacheManager } from '@/lib/cache/cache-manager'; @@ -43,6 +44,7 @@ import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; +import { terminologyStore, TermKeys } from '@/stores/terminology/store'; import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; import { isNetworkError } from '@/utils/network'; @@ -194,6 +196,9 @@ export default function TabLayout() { if (!isCurrentRun()) return; + // After rights (its identity key needs the department); labels only, so it never holds up init. + void terminologyStore.getState().fetchTerminology(); + // SignalR needs config (EventingUrl) and rights (DepartmentId) — both // available now. The two hub connects are independent. await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]); @@ -404,13 +409,17 @@ export default function TabLayout() { const config = useCoreStore((state) => state.config); const activeUnitId = useCoreStore((state) => state.activeUnitId); const rights = securityStore((state) => state.rights); + const termNames = terminologyStore((state) => state.names); + const unitSubscriberHash = useUnitSubscriberHash(activeUnitId); - // Compute Novu readiness once for consistent gating across the render - const novuReady = !!(activeUnitId && config?.NovuApplicationId && config?.NovuBackendApiUrl && config?.NovuSocketUrl && rights?.DepartmentCode); + // Compute Novu readiness once for consistent gating across the render. It also waits for the unit's + // subscriberHash fetch to settle, so a unit switch keeps the previous inbox up until the new one can open. + const novuReady = !!(activeUnitId && config?.NovuApplicationId && config?.NovuBackendApiUrl && config?.NovuSocketUrl && rights?.DepartmentCode && unitSubscriberHash.settled); // Cache the last known-good Novu config so NovuProvider stays mounted stably // even if a transient state update briefly nullifies one of the values. const lastNovuConfig = useRef<{ subscriberId: string; + subscriberHash?: string; applicationIdentifier: string; backendUrl: string; socketUrl: string; @@ -418,6 +427,7 @@ export default function TabLayout() { if (novuReady) { lastNovuConfig.current = { subscriberId: `${rights?.DepartmentCode}_Unit_${activeUnitId}`, + subscriberHash: unitSubscriberHash.subscriberHash, applicationIdentifier: config!.NovuApplicationId, backendUrl: config!.NovuBackendApiUrl, socketUrl: config!.NovuSocketUrl, @@ -481,7 +491,7 @@ export default function TabLayout() { // Memoize per-screen options to prevent new objects every render const indexOptions = useMemo( () => ({ - title: t('tabs.map'), + title: termNames[TermKeys.Mapping] || t('tabs.map'), tabBarIcon: mapIcon, headerLeft: headerLeftMap, tabBarButtonTestID: 'map-tab' as const, @@ -493,29 +503,29 @@ export default function TabLayout() { // a use-after-free). Freezing preserves the native MapView, shrinking that race. freezeOnBlur: true, }), - [t, mapIcon, headerLeftMap, headerRightNotification] + [t, termNames, mapIcon, headerLeftMap, headerRightNotification] ); const callsOptions = useMemo( () => ({ - title: t('tabs.calls'), + title: termNames[TermKeys.Calls] || t('tabs.calls'), headerShown: true as const, tabBarIcon: callsIcon, tabBarButtonTestID: 'calls-tab' as const, headerRight: headerRightNotification, }), - [t, callsIcon, headerRightNotification] + [t, termNames, callsIcon, headerRightNotification] ); const contactsOptions = useMemo( () => ({ - title: t('tabs.contacts'), + title: termNames[TermKeys.Contacts] || t('tabs.contacts'), headerShown: true as const, tabBarIcon: contactsIcon, tabBarButtonTestID: 'contacts-tab' as const, headerRight: headerRightNotification, }), - [t, contactsIcon, headerRightNotification] + [t, termNames, contactsIcon, headerRightNotification] ); const routesOptions = useMemo( @@ -531,13 +541,13 @@ export default function TabLayout() { const notesOptions = useMemo( () => ({ - title: t('tabs.notes'), + title: termNames[TermKeys.Notes] || t('tabs.notes'), headerShown: true as const, tabBarIcon: notesIcon, tabBarButtonTestID: 'notes-tab' as const, headerRight: headerRightNotification, }), - [t, notesIcon, headerRightNotification] + [t, termNames, notesIcon, headerRightNotification] ); const weatherAlertsOptions = useMemo( @@ -637,9 +647,9 @@ export default function TabLayout() { - + - + @@ -672,6 +682,7 @@ export default function TabLayout() { return ( (); const activeUnit = useCoreStore((s) => s.activeUnitId); const unitId = activeUnit || undefined; @@ -48,7 +50,7 @@ export default function ChecklistsScreen() { const message = error ?? state.error; return ( - +