Skip to content

Commit d44c39c

Browse files
authored
Merge pull request #125 from Resgrid/develop
RC-T39 Dispatch fixes
2 parents df24b8b + 758cf44 commit d44c39c

42 files changed

Lines changed: 6855 additions & 5904 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/__tests__/app/call/[id].test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ jest.mock('react-native-webview', () => ({
222222
default: 'WebView',
223223
}));
224224

225+
// Mock react-native-restart - pulled in transitively via the i18n utils used by the
226+
// chat store; the native module is absent under jest
227+
jest.mock('react-native-restart', () => ({
228+
__esModule: true,
229+
default: {
230+
Restart: jest.fn(),
231+
},
232+
}));
233+
225234
jest.mock('@/hooks/use-analytics', () => ({
226235
useAnalytics: jest.fn(),
227236
}));

src/__tests__/app/calls.test.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,33 @@ const mockCallsStore = {
5656
};
5757

5858
const mockSecurityStore = {
59-
canUserCreateCalls: true,
59+
rights: { CanCreateCalls: true } as { CanCreateCalls: boolean } | undefined,
6060
};
6161

6262
const mockAnalytics = {
6363
trackEvent: jest.fn(),
6464
};
6565

66-
// Mock the stores with proper getState method
66+
// Mock the stores with proper getState method. The hook mocks apply an optional
67+
// selector, matching how zustand hooks are called with field selectors.
6768
jest.mock('@/stores/calls/store', () => {
68-
const useCallsStore = jest.fn(() => mockCallsStore);
69+
const useCallsStore = jest.fn((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
6970
(useCallsStore as any).getState = jest.fn(() => mockCallsStore);
7071

7172
return {
7273
useCallsStore,
7374
};
7475
});
7576

76-
jest.mock('@/stores/security/store', () => ({
77-
useSecurityStore: jest.fn(() => mockSecurityStore),
78-
}));
77+
jest.mock('@/stores/security/store', () => {
78+
const securityStore = jest.fn((selector?: (state: typeof mockSecurityStore) => unknown) => (selector ? selector(mockSecurityStore) : mockSecurityStore));
79+
(securityStore as any).getState = jest.fn(() => mockSecurityStore);
80+
81+
return {
82+
securityStore,
83+
useSecurityStore: jest.fn(() => ({ canUserCreateCalls: mockSecurityStore.rights?.CanCreateCalls })),
84+
};
85+
});
7986

8087
jest.mock('@/hooks/use-analytics', () => ({
8188
useAnalytics: jest.fn(() => mockAnalytics),
@@ -216,9 +223,9 @@ describe('CallsScreen', () => {
216223
beforeEach(() => {
217224
jest.clearAllMocks();
218225

219-
// Reset mock returns to defaults
220-
useCallsStore.mockReturnValue(mockCallsStore);
221-
useSecurityStore.mockReturnValue(mockSecurityStore);
226+
// Reset mock behavior to defaults (selector-aware, like the real zustand hooks)
227+
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
228+
useSecurityStore.mockImplementation(() => ({ canUserCreateCalls: mockSecurityStore.rights?.CanCreateCalls }));
222229
useAnalytics.mockReturnValue(mockAnalytics);
223230

224231
// Reset the mock store state
@@ -227,13 +234,12 @@ describe('CallsScreen', () => {
227234
mockCallsStore.error = null;
228235
mockCallsStore.callPriorities = [];
229236

230-
mockSecurityStore.canUserCreateCalls = true;
237+
mockSecurityStore.rights = { CanCreateCalls: true };
231238
});
232239

233240
describe('when user has create calls permission', () => {
234241
beforeEach(() => {
235-
mockSecurityStore.canUserCreateCalls = true;
236-
useSecurityStore.mockReturnValue(mockSecurityStore);
242+
mockSecurityStore.rights = { CanCreateCalls: true };
237243
});
238244

239245
it('renders the new call FAB button', () => {
@@ -244,7 +250,7 @@ describe('CallsScreen', () => {
244250
expect(htmlContent).toBeTruthy();
245251

246252
// Since we can see the button in debug output, let's just verify the mock is working
247-
expect(mockSecurityStore.canUserCreateCalls).toBe(true);
253+
expect(mockSecurityStore.rights?.CanCreateCalls).toBe(true);
248254
});
249255

250256
it('navigates to new call screen when FAB is pressed', () => {
@@ -262,8 +268,7 @@ describe('CallsScreen', () => {
262268

263269
describe('when user does not have create calls permission', () => {
264270
beforeEach(() => {
265-
mockSecurityStore.canUserCreateCalls = false;
266-
useSecurityStore.mockReturnValue(mockSecurityStore);
271+
mockSecurityStore.rights = { CanCreateCalls: false };
267272
});
268273

269274
it('does not render the new call FAB button', () => {
@@ -291,7 +296,7 @@ describe('CallsScreen', () => {
291296

292297
beforeEach(() => {
293298
mockCallsStore.calls = mockCalls;
294-
useCallsStore.mockReturnValue(mockCallsStore);
299+
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
295300
});
296301

297302
it('renders call cards for each call', () => {
@@ -333,7 +338,7 @@ describe('CallsScreen', () => {
333338
describe('loading and error states', () => {
334339
it('shows loading state when isLoading is true', () => {
335340
mockCallsStore.isLoading = true;
336-
useCallsStore.mockReturnValue(mockCallsStore);
341+
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
337342

338343
render(<CallsScreen />);
339344

@@ -346,7 +351,7 @@ describe('CallsScreen', () => {
346351

347352
it('shows error state when there is an error', () => {
348353
mockCallsStore.error = 'Network error';
349-
useCallsStore.mockReturnValue(mockCallsStore);
354+
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
350355

351356
render(<CallsScreen />);
352357

@@ -359,7 +364,7 @@ describe('CallsScreen', () => {
359364

360365
it('shows zero state when there are no calls', () => {
361366
mockCallsStore.calls = [];
362-
useCallsStore.mockReturnValue(mockCallsStore);
367+
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
363368

364369
render(<CallsScreen />);
365370

@@ -383,7 +388,7 @@ describe('CallsScreen', () => {
383388
it('tracks view rendered event with correct parameters', () => {
384389
const mockCalls = [{ CallId: 'call-1', Nature: 'Test' }];
385390
mockCallsStore.calls = mockCalls;
386-
useCallsStore.mockReturnValue(mockCallsStore);
391+
useCallsStore.mockImplementation((selector?: (state: typeof mockCallsStore) => unknown) => (selector ? selector(mockCallsStore) : mockCallsStore));
387392

388393
render(<CallsScreen />);
389394

src/__tests__/security-integration.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ describe('Security Permission Logic', () => {
2626
CanCreateCalls: true,
2727
CanAddNote: false,
2828
CanCreateMessage: false,
29+
CanLoginToDispatchApp: true,
30+
CanLoginToCommandApp: true,
2931
Groups: []
3032
};
3133

@@ -44,6 +46,8 @@ describe('Security Permission Logic', () => {
4446
CanCreateCalls: false,
4547
CanAddNote: true,
4648
CanCreateMessage: true,
49+
CanLoginToDispatchApp: true,
50+
CanLoginToCommandApp: true,
4751
Groups: []
4852
};
4953

@@ -65,6 +69,8 @@ describe('Security Permission Logic', () => {
6569
CanViewPII: true,
6670
CanAddNote: true,
6771
CanCreateMessage: true,
72+
CanLoginToDispatchApp: true,
73+
CanLoginToCommandApp: true,
6874
Groups: []
6975
} as unknown as DepartmentRightsResultData;
7076

@@ -85,6 +91,8 @@ describe('Security Permission Logic', () => {
8591
CanCreateCalls: true,
8692
CanAddNote: false,
8793
CanCreateMessage: false,
94+
CanLoginToDispatchApp: true,
95+
CanLoginToCommandApp: true,
8896
Groups: []
8997
};
9098

@@ -107,6 +115,8 @@ describe('Security Permission Logic', () => {
107115
CanCreateCalls: false,
108116
CanAddNote: true,
109117
CanCreateMessage: true,
118+
CanLoginToDispatchApp: true,
119+
CanLoginToCommandApp: true,
110120
Groups: []
111121
};
112122

src/api/chat/chat.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,25 @@ const MODERATION = '/ChatModeration';
3131
// Channels
3232
// ---------------------------------------------------------------------------
3333

34-
export const getChannels = async (activeUnitId?: number, signal?: AbortSignal) => {
34+
/**
35+
* The caller's channels. `includeArchived` pulls in the point-in-time record of closed incidents and
36+
* calls — off by default so the everyday list stays current. `callId` narrows the result server-side
37+
* to channels attached to that call (older servers ignore it and return the full list).
38+
*/
39+
export const getChannels = async (activeUnitId?: number, includeArchived = false, callId?: number, signal?: AbortSignal) => {
40+
const params: Record<string, unknown> = {};
41+
if (activeUnitId != null) {
42+
params.activeUnitId = activeUnitId;
43+
}
44+
if (includeArchived) {
45+
params.includeArchived = true;
46+
}
47+
if (callId != null) {
48+
params.callId = callId;
49+
}
50+
3551
const response = await api.get<ChatV4Response<ChatChannelResultData[]>>(`${CHAT}/GetChannels`, {
36-
params: activeUnitId != null ? { activeUnitId } : undefined,
52+
params: Object.keys(params).length > 0 ? params : undefined,
3753
signal,
3854
});
3955
return response.data;

src/api/common/client.tsx

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios';
22

3-
import { refreshTokenRequest } from '@/lib/auth/api';
3+
import { performTokenRefresh } from '@/lib/auth/token-refresh';
44
import { logger } from '@/lib/logging';
55
import { getBaseApiUrl } from '@/lib/storage/app';
66
import useAuthStore from '@/stores/auth/store';
@@ -78,35 +78,31 @@ axiosInstance.interceptors.response.use(
7878
isRefreshing = true;
7979

8080
try {
81-
const refreshToken = useAuthStore.getState().refreshToken;
82-
if (!refreshToken) {
83-
throw new Error('No refresh token available');
81+
// Single-flight refresh shared with the auth store's refresh timer, so a
82+
// timer refresh and a 401-triggered refresh can never rotate the refresh
83+
// token twice in parallel. Failure handling (logout) happens inside
84+
// performTokenRefresh.
85+
const refreshed = await performTokenRefresh();
86+
if (!refreshed) {
87+
throw new Error('Token refresh failed');
8488
}
8589

86-
const response = await refreshTokenRequest(refreshToken);
87-
const { access_token, refresh_token: newRefreshToken } = response;
88-
89-
// Update tokens in store
90-
useAuthStore.setState({
91-
accessToken: access_token,
92-
refreshToken: newRefreshToken,
93-
status: 'signedIn',
94-
error: null,
95-
});
90+
const accessToken = useAuthStore.getState().accessToken;
91+
if (!accessToken) {
92+
throw new Error('No access token available after refresh');
93+
}
9694

9795
// Update Authorization header
98-
axiosInstance.defaults.headers.common.Authorization = `Bearer ${access_token}`;
99-
originalRequest.headers.Authorization = `Bearer ${access_token}`;
96+
axiosInstance.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
97+
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
10098

10199
processQueue(null);
102100
return axiosInstance(originalRequest);
103101
} catch (refreshError) {
104102
processQueue(refreshError as Error);
105-
// Handle refresh token failure
106-
useAuthStore.getState().logout();
107103
logger.error({
108104
message: 'Token refresh failed',
109-
context: { error: refreshError },
105+
context: { error: refreshError instanceof Error ? refreshError.message : String(refreshError) },
110106
});
111107
return Promise.reject(refreshError);
112108
} finally {

src/app/(app)/_layout.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,54 @@ import { Env } from '@/lib/env';
2727
import { logger } from '@/lib/logging';
2828
import { useIsFirstTime } from '@/lib/storage';
2929
import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData';
30-
import { usePushNotifications } from '@/services/push-notification';
30+
import { audioService } from '@/services/audio.service';
31+
import { pushNotificationService, usePushNotifications } from '@/services/push-notification';
32+
import { useAudioStreamStore } from '@/stores/app/audio-stream-store';
3133
import { useCoreStore } from '@/stores/app/core-store';
34+
import { useLiveKitStore } from '@/stores/app/livekit-store';
3235
import { useCallsStore } from '@/stores/calls/store';
36+
import { useChatStore } from '@/stores/chat/store';
37+
import { useCheckInStore } from '@/stores/checkIn/store';
3338
import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store';
3439
import useLockscreenStore from '@/stores/lockscreen/store';
3540
import { useRolesStore } from '@/stores/roles/store';
3641
import { securityStore } from '@/stores/security/store';
3742
import { useSignalRStore } from '@/stores/signalr/signalr-store';
43+
import { useToastStore } from '@/stores/toast/store';
3844
import { useWeatherAlertsStore } from '@/stores/weatherAlerts/store';
3945

46+
/**
47+
* Tear down every per-session resource on sign-out: SignalR hubs and their heartbeats,
48+
* the LiveKit voice room, audio streams and cached sounds, check-in polling, chat
49+
* timers, and push-notification listeners. Without this, all of them keep running
50+
* against a signed-out session until the process dies.
51+
*/
52+
async function teardownSignedInSession(): Promise<void> {
53+
const signalR = useSignalRStore.getState();
54+
const teardowns: [string, () => Promise<unknown> | unknown][] = [
55+
['SignalR update hub', () => signalR.disconnectUpdateHub()],
56+
['SignalR chat hub', () => signalR.disconnectChatHub()],
57+
['SignalR geolocation hub', () => signalR.disconnectGeolocationHub()],
58+
['LiveKit room', () => useLiveKitStore.getState().disconnectFromRoom()],
59+
['audio stream', () => useAudioStreamStore.getState().cleanup()],
60+
['audio service', () => audioService.cleanup()],
61+
['check-in polling', () => useCheckInStore.getState().stopPolling()],
62+
['chat store', () => useChatStore.getState().reset()],
63+
['push notification listeners', () => pushNotificationService.cleanup()],
64+
];
65+
66+
for (const [label, teardown] of teardowns) {
67+
try {
68+
await teardown();
69+
} catch (error) {
70+
logger.error({
71+
message: `Failed to tear down ${label} on sign-out`,
72+
context: { error: error instanceof Error ? error.message : String(error) },
73+
});
74+
}
75+
}
76+
}
77+
4078
export default function TabLayout() {
4179
const { t } = useTranslation();
4280
const status = useAuthStore((state) => state.status);
@@ -156,6 +194,23 @@ export default function TabLayout() {
156194

157195
await securityStore.getState().getRights();
158196

197+
// Dispatch shows private command, unit and responder traffic, so a member the department has
198+
// not authorized must not get past initialization. The server is the real boundary — it simply
199+
// never hands an unauthorized user the dispatch channels — but signing them straight back out
200+
// is far clearer than a silently empty app.
201+
if (!isCurrentRun()) {
202+
return;
203+
}
204+
if (securityStore.getState().rights?.CanLoginToDispatchApp === false) {
205+
logger.warn({
206+
message: 'User is not authorized to use the Dispatch app; signing out',
207+
context: { userId },
208+
});
209+
useToastStore.getState().showToast('error', t('login.dispatch_not_authorized'));
210+
await useAuthStore.getState().logout();
211+
return;
212+
}
213+
159214
logger.info({
160215
message: 'Security rights retrieved, fetching feature flags',
161216
context: { platform: Platform.OS },
@@ -273,7 +328,7 @@ export default function TabLayout() {
273328
// If the init promise is still hanging, clear the guard so a retry is possible
274329
isInitializing.current = false;
275330
}
276-
}, [status]);
331+
}, [status, t, userId]);
277332

278333
const refreshDataFromBackground = useCallback(async () => {
279334
if (status !== 'signedIn' || !hasInitialized.current) return;
@@ -377,6 +432,12 @@ export default function TabLayout() {
377432
// so the next sign-in is not skipped as "already initializing".
378433
initGeneration.current += 1;
379434
isInitializing.current = false;
435+
// Clear the initialized flag too, or a sign-in later in this process fails
436+
// shouldInitialize and initializeApp never runs for the new session.
437+
hasInitialized.current = false;
438+
439+
// Stop hubs, voice, audio and timers that belong to the ended session
440+
void teardownSignedInSession();
380441
}
381442

382443
// Update last known status

0 commit comments

Comments
 (0)