Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/api/config/terminology.ts
Original file line number Diff line number Diff line change
@@ -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<GetTerminologyResult>();
return response.data;
};
16 changes: 14 additions & 2 deletions src/api/devices/__tests__/push.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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);
});
});
7 changes: 7 additions & 0 deletions src/api/devices/push.ts
Original file line number Diff line number Diff line change
@@ -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<PushRegistrationResult>({ ...data }, signal);
Expand All @@ -15,3 +17,8 @@ export const unRegisterWebPush = async (data: WebPushUnRegistrationInput, signal
const response = await unRegisterWebPushApi.post<PushRegistrationResult>({ ...data }, signal);
return response.data;
};

export const unRegisterNativePush = async (data: NativePushUnRegistrationInput, signal?: AbortSignal) => {
const response = await unRegisterNativePushApi.post<PushRegistrationResult>({ ...data }, signal);
return response.data;
};
49 changes: 49 additions & 0 deletions src/api/novu/__tests__/inbox.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
22 changes: 21 additions & 1 deletion src/api/novu/inbox.ts
Original file line number Diff line number Diff line change
@@ -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<UnitSubscriberHashResult>({
unitId,
});
return response.data;
};
3 changes: 3 additions & 0 deletions src/app/(app)/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 27 additions & 14 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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()]);
Expand Down Expand Up @@ -404,20 +409,25 @@ 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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;
} | null>(null);
if (novuReady) {
lastNovuConfig.current = {
subscriberId: `${rights?.DepartmentCode}_Unit_${activeUnitId}`,
subscriberHash: unitSubscriberHash.subscriberHash,
applicationIdentifier: config!.NovuApplicationId,
backendUrl: config!.NovuBackendApiUrl,
socketUrl: config!.NovuSocketUrl,
Expand Down Expand Up @@ -474,15 +484,17 @@ export default function TabLayout() {

// Memoize header left/right renders
const headerLeftMap = useCallback(() => <CreateDrawerMenuButton setIsOpen={setIsOpen} isLandscape={isLandscape} />, [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(
() => <CreateNotificationButton config={config} setIsNotificationsOpen={handleOpenNotifications} activeUnitId={activeUnitId} departmentCode={rights?.DepartmentCode} />,
[config, handleOpenNotifications, activeUnitId, rights?.DepartmentCode]
() => (novuReady ? <CreateNotificationButton config={config} setIsNotificationsOpen={handleOpenNotifications} activeUnitId={activeUnitId} departmentCode={rights?.DepartmentCode} /> : 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,
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -638,9 +650,9 @@ export default function TabLayout() {
<Tabs.Screen name="routes" options={routesOptions} />

<Tabs.Screen name="contacts" options={contactsOptions} />
<Tabs.Screen name="checklists" options={{ href: null, title: t('checklists.labels.Checklists') }} />
<Tabs.Screen name="checklists" options={{ href: null, title: termNames[TermKeys.Checklists] || t('checklists.labels.Checklists') }} />
<Tabs.Screen name="operations" options={{ href: null, headerShown: false }} />
<Tabs.Screen name="records" options={{ href: null, title: t('tabs.records') }} />
<Tabs.Screen name="records" options={{ href: null, title: termNames[TermKeys.Records] || t('tabs.records') }} />
<Tabs.Screen name="inventory" options={{ href: null, headerShown: false }} />

<Tabs.Screen name="notes" options={notesOptions} />
Expand Down Expand Up @@ -673,6 +685,7 @@ export default function TabLayout() {
return (
<NovuProvider
subscriberId={lastNovuConfig.current.subscriberId}
subscriberHash={lastNovuConfig.current.subscriberHash}
applicationIdentifier={lastNovuConfig.current.applicationIdentifier}
backendUrl={lastNovuConfig.current.backendUrl}
socketUrl={lastNovuConfig.current.socketUrl}
Expand Down
4 changes: 3 additions & 1 deletion src/app/(app)/checklists.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { useProtectedReveal } from '@/hooks/use-protected-reveal';
import { checklistError } from '@/lib/checklists/sync';
import { useCoreStore } from '@/stores/app/core-store';
import { useChecklistsStore } from '@/stores/checklists/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

export default function ChecklistsScreen() {
const { t } = useTranslation();
const title = useTerm(TermKeys.Checklists, t('checklists.labels.Checklists'));
const { occurrenceId } = useLocalSearchParams<{ occurrenceId?: string }>();
const activeUnit = useCoreStore((s) => s.activeUnitId);
const unitId = activeUnit || undefined;
Expand Down Expand Up @@ -48,7 +50,7 @@ export default function ChecklistsScreen() {
const message = error ?? state.error;
return (
<View className="flex-1 bg-background-0">
<Stack.Screen options={{ title: t('checklists.labels.Checklists') }} />
<Stack.Screen options={{ title }} />
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
<Button variant="outline" onPress={() => setCalendar((v) => !v)}>
<ButtonText>{t('checklists.ui.calendar')}</ButtonText>
Expand Down
4 changes: 3 additions & 1 deletion src/app/(app)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useCoreStore } from '@/stores/app/core-store';
import { useLocationStore } from '@/stores/app/location-store';
import { useMapsStore } from '@/stores/maps/store';
import { useRoutesStore } from '@/stores/routes/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';
import { useToastStore } from '@/stores/toast/store';
import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';

Expand All @@ -56,6 +57,7 @@ export default function Map() {

function MapContent() {
const { t } = useTranslation();
const mapTitle = useTerm(TermKeys.Mapping, t('tabs.map'));
const { trackEvent } = useAnalytics();
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
Expand Down Expand Up @@ -567,7 +569,7 @@ function MapContent() {
<>
<Stack.Screen
options={{
title: t('tabs.map'),
title: mapTitle,
headerTitle: t('app.title'),
headerShown: true,
headerBackTitle: '',
Expand Down
12 changes: 10 additions & 2 deletions src/app/(app)/inventory/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { contentOf, groupEquipment, locationNames, orderLocations } from '@/lib/
import { InventoryAssetStatus, InventoryCountStatus } from '@/models/v4/inventory';
import { useCoreStore } from '@/stores/app/core-store';
import { useInventoryStore } from '@/stores/inventory/store';
import { TermKeys, useTerm } from '@/stores/terminology/store';

const todayKey = () => {
const now = new Date();
Expand All @@ -30,6 +31,13 @@ export default function UnitInventoryScreen() {
const { access, equipment, counts, busy, error } = useInventoryStore();
const [countLocation, setCountLocation] = useState('');
const unitId = activeUnitId ? Number(activeUnitId) : null;
const renamed = useTerm(TermKeys.Inventory, '');
const title = renamed || t('inventory.title');
let heading = title;
if (activeUnit?.Name) {
// Every locale's titleFor reads "<title> · <unit>"; the department's own word keeps that shape.
heading = renamed ? `${renamed} · ${activeUnit.Name}` : t('inventory.titleFor', { name: activeUnit.Name });
}

useFocusEffect(
useCallback(() => {
Expand All @@ -53,9 +61,9 @@ export default function UnitInventoryScreen() {

return (
<VStack className="flex-1 bg-background-0">
<Stack.Screen options={{ title: t('inventory.title') }} />
<Stack.Screen options={{ title }} />
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }} refreshControl={<RefreshControl refreshing={busy} onRefresh={() => unitId && void useInventoryStore.getState().load(unitId)} />}>
<Text className="text-xl font-bold">{activeUnit?.Name ? t('inventory.titleFor', { name: activeUnit.Name }) : t('inventory.title')}</Text>
<Text className="text-xl font-bold">{heading}</Text>
{!unitId ? <Text className="text-typography-500">{t('inventory.selectUnit')}</Text> : null}
{busy && !access ? <Spinner /> : null}
{error ? (
Expand Down
Loading
Loading