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 02345683..011e7a8b 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, @@ -474,15 +484,17 @@ export default function TabLayout() { // Memoize header left/right renders const headerLeftMap = useCallback(() => , [isLandscape]); + // Gated on novuReady like the inbox: before the unit's subscriberHash settles there is no NovuProvider yet, and + // the button's useCounts throws outside one. const headerRightNotification = useCallback( - () => , - [config, handleOpenNotifications, activeUnitId, rights?.DepartmentCode] + () => (novuReady ? : null), + [novuReady, config, handleOpenNotifications, activeUnitId, rights?.DepartmentCode] ); // 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, @@ -494,29 +506,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( @@ -532,13 +544,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( @@ -638,9 +650,9 @@ export default function TabLayout() { - + - + @@ -673,6 +685,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 ( - +