From 52b1e726f600b22a83a5bd209a37442efb01b228 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 3 Oct 2026 22:45:41 -0700 Subject: [PATCH 1/3] RR-T48 Fixing issue with call locations --- .../__tests__/callLocationHistory.test.ts | 42 +++ src/api/calls/callLocationHistory.ts | 15 ++ .../__tests__/contactCallHistory.test.ts | 33 +++ src/api/contacts/contactCallHistory.ts | 14 + .../__tests__/timeline-activity-link.test.tsx | 1 + src/app/call/[id]/index.tsx | 8 + .../__tests__/location-history-panel.test.tsx | 160 ++++++++++++ .../calls/location-history-panel.tsx | 245 ++++++++++++++++++ .../contacts/contact-details-sheet.tsx | 20 +- src/models/v4/calls/locationHistoryResult.ts | 61 +++++ .../__tests__/location-history-store.test.ts | 99 +++++++ src/stores/calls/location-history-store.ts | 79 ++++++ src/translations/ar.json | 36 +++ src/translations/de.json | 36 +++ src/translations/el.json | 36 +++ src/translations/en.json | 36 +++ src/translations/es.json | 36 +++ src/translations/fr.json | 36 +++ src/translations/it.json | 36 +++ src/translations/pl.json | 36 +++ src/translations/sv.json | 36 +++ src/translations/uk.json | 36 +++ 22 files changed, 1135 insertions(+), 2 deletions(-) create mode 100644 src/api/calls/__tests__/callLocationHistory.test.ts create mode 100644 src/api/calls/callLocationHistory.ts create mode 100644 src/api/contacts/__tests__/contactCallHistory.test.ts create mode 100644 src/api/contacts/contactCallHistory.ts create mode 100644 src/components/calls/__tests__/location-history-panel.test.tsx create mode 100644 src/components/calls/location-history-panel.tsx create mode 100644 src/models/v4/calls/locationHistoryResult.ts create mode 100644 src/stores/calls/__tests__/location-history-store.test.ts create mode 100644 src/stores/calls/location-history-store.ts diff --git a/src/api/calls/__tests__/callLocationHistory.test.ts b/src/api/calls/__tests__/callLocationHistory.test.ts new file mode 100644 index 0000000..0de0115 --- /dev/null +++ b/src/api/calls/__tests__/callLocationHistory.test.ts @@ -0,0 +1,42 @@ +import { createApiEndpoint } from '../../common/client'; +import { getCallLocationHistory } from '../callLocationHistory'; + +jest.mock('../../common/client', () => { + const get = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get })), + __mockGet: get, + }; +}); + +const { __mockGet: mockGet } = jest.requireMock('../../common/client') as { __mockGet: jest.Mock }; + +// Endpoints are created at module load; capture them before beforeEach clears the mock. +const registeredPaths = (createApiEndpoint as jest.Mock).mock.calls.map((c) => c[0]); + +describe('callLocationHistory api', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('targets Calls/GetCallLocationHistory', () => { + expect(registeredPaths).toEqual(['/Calls/GetCallLocationHistory']); + }); + + it('passes callId as a query parameter and returns the body', async () => { + const payload = { Data: { AddressMatchingAvailable: true, IndexComplete: true, HasMore: false, IsProtected: false, Calls: [] } }; + mockGet.mockResolvedValueOnce({ data: payload }); + + await expect(getCallLocationHistory('42')).resolves.toBe(payload); + expect(mockGet).toHaveBeenCalledWith({ callId: '42' }, undefined); + }); + + it('forwards the abort signal', async () => { + const controller = new AbortController(); + mockGet.mockResolvedValueOnce({ data: { Data: null } }); + + await getCallLocationHistory('42', controller.signal); + + expect(mockGet).toHaveBeenCalledWith({ callId: '42' }, controller.signal); + }); +}); diff --git a/src/api/calls/callLocationHistory.ts b/src/api/calls/callLocationHistory.ts new file mode 100644 index 0000000..1537e3a --- /dev/null +++ b/src/api/calls/callLocationHistory.ts @@ -0,0 +1,15 @@ +import { type LocationHistoryResult } from '@/models/v4/calls/locationHistoryResult'; + +import { createApiEndpoint } from '../common/client'; + +// v4 Calls/GetCallLocationHistory: other calls at this call's location (address however it was typed, or nearby when one +// side has no street address) and calls linked to the same contacts, newest first with their notes. Needs Call_View and is +// limited to the calls the user may see. The client attaches the Protected Data Grant header automatically; without one, +// a protected department's call text and notes come back REDACTED. + +const getCallLocationHistoryApi = createApiEndpoint('/Calls/GetCallLocationHistory'); + +export const getCallLocationHistory = async (callId: string, signal?: AbortSignal) => { + const response = await getCallLocationHistoryApi.get({ callId }, signal); + return response.data; +}; diff --git a/src/api/contacts/__tests__/contactCallHistory.test.ts b/src/api/contacts/__tests__/contactCallHistory.test.ts new file mode 100644 index 0000000..25969b1 --- /dev/null +++ b/src/api/contacts/__tests__/contactCallHistory.test.ts @@ -0,0 +1,33 @@ +import { createApiEndpoint } from '../../common/client'; +import { getContactCallHistory } from '../contactCallHistory'; + +jest.mock('../../common/client', () => { + const get = jest.fn(); + return { + createApiEndpoint: jest.fn(() => ({ get })), + __mockGet: get, + }; +}); + +const { __mockGet: mockGet } = jest.requireMock('../../common/client') as { __mockGet: jest.Mock }; + +// Endpoints are created at module load; capture them before beforeEach clears the mock. +const registeredPaths = (createApiEndpoint as jest.Mock).mock.calls.map((c) => c[0]); + +describe('contactCallHistory api', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('targets Contacts/GetContactCallHistory', () => { + expect(registeredPaths).toEqual(['/Contacts/GetContactCallHistory']); + }); + + it('passes contactId as a query parameter and returns the body', async () => { + const payload = { Data: { AddressMatchingAvailable: true, IndexComplete: true, HasMore: false, IsProtected: false, Calls: [] } }; + mockGet.mockResolvedValueOnce({ data: payload }); + + await expect(getContactCallHistory('c1')).resolves.toBe(payload); + expect(mockGet).toHaveBeenCalledWith({ contactId: 'c1' }, undefined); + }); +}); diff --git a/src/api/contacts/contactCallHistory.ts b/src/api/contacts/contactCallHistory.ts new file mode 100644 index 0000000..2376df9 --- /dev/null +++ b/src/api/contacts/contactCallHistory.ts @@ -0,0 +1,14 @@ +import { type LocationHistoryResult } from '@/models/v4/calls/locationHistoryResult'; + +import { createApiEndpoint } from '../common/client'; + +// v4 Contacts/GetContactCallHistory: calls linked to a contact plus calls at every occupancy it is linked to (one contact can +// have several, such as a business with more than one location), newest first with their notes. Needs Contacts_View and is +// limited to the calls the user may see. + +const getContactCallHistoryApi = createApiEndpoint('/Contacts/GetContactCallHistory'); + +export const getContactCallHistory = async (contactId: string, signal?: AbortSignal) => { + const response = await getContactCallHistoryApi.get({ contactId }, signal); + return response.data; +}; diff --git a/src/app/call/[id]/__tests__/timeline-activity-link.test.tsx b/src/app/call/[id]/__tests__/timeline-activity-link.test.tsx index daa985b..93cf095 100644 --- a/src/app/call/[id]/__tests__/timeline-activity-link.test.tsx +++ b/src/app/call/[id]/__tests__/timeline-activity-link.test.tsx @@ -48,6 +48,7 @@ jest.mock('@/components/calls/call-files-modal', () => () => null); jest.mock('@/components/calls/call-images-modal', () => () => null); jest.mock('@/components/calls/call-notes-modal', () => () => null); jest.mock('@/components/calls/call-site-info-tab-panel', () => ({ CallSiteInfoTabPanel: () => null })); +jest.mock('@/components/calls/location-history-panel', () => ({ LocationHistoryPanel: () => null })); jest.mock('@/components/calls/close-call-bottom-sheet', () => ({ CloseCallBottomSheet: () => null })); jest.mock('@/components/check-in/check-in-tab-panel', () => ({ CheckInTabPanel: () => null })); jest.mock('@/components/common/header-back-button', () => ({ HeaderBackButton: () => null })); diff --git a/src/app/call/[id]/index.tsx b/src/app/call/[id]/index.tsx index fe2b5e1..0b6bce3 100644 --- a/src/app/call/[id]/index.tsx +++ b/src/app/call/[id]/index.tsx @@ -15,6 +15,7 @@ import CallImagesModal from '@/components/calls/call-images-modal'; import CallNotesModal from '@/components/calls/call-notes-modal'; import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel'; import { CloseCallBottomSheet } from '@/components/calls/close-call-bottom-sheet'; +import { LocationHistoryPanel } from '@/components/calls/location-history-panel'; import { CheckInTabPanel } from '@/components/check-in/check-in-tab-panel'; import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; @@ -489,6 +490,13 @@ export default function CallDetail() { content: , }); + // History tab: previous calls at this location (address however it was typed) or with the same contacts. + builtTabs.push({ + key: 'history', + title: t('call_detail.tabs.history'), + content: , + }); + builtTabs.push({ key: 'command', title: t('call_detail.tabs.command'), diff --git a/src/components/calls/__tests__/location-history-panel.test.tsx b/src/components/calls/__tests__/location-history-panel.test.tsx new file mode 100644 index 0000000..650b5a2 --- /dev/null +++ b/src/components/calls/__tests__/location-history-panel.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { router } from 'expo-router'; +import React from 'react'; + +import { getCallLocationHistory } from '@/api/calls/callLocationHistory'; +import { getContactCallHistory } from '@/api/contacts/contactCallHistory'; +import { type LocationHistoryData } from '@/models/v4/calls/locationHistoryResult'; +import { useLocationHistoryStore } from '@/stores/calls/location-history-store'; + +import { LocationHistoryPanel } from '../location-history-panel'; + +jest.mock('@/api/calls/callLocationHistory', () => ({ getCallLocationHistory: jest.fn() })); +jest.mock('@/api/contacts/contactCallHistory', () => ({ getContactCallHistory: jest.fn() })); + +jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); + +jest.mock('@/stores/data-protection/store', () => { + const { create } = jest.requireActual('zustand'); + return { dataProtectionStore: create(() => ({ grantToken: null })) }; +}); + +const mockTrackEvent = jest.fn(); +jest.mock('@/hooks/use-analytics', () => ({ + useAnalytics: () => ({ trackEvent: mockTrackEvent }), +})); + +jest.mock('lucide-react-native', () => { + const { View } = jest.requireActual('react-native'); + return { ChevronDownIcon: View, ChevronUpIcon: View, HistoryIcon: View, InfoIcon: View, LockIcon: View, MapPinIcon: View }; +}); + +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: unknown) => { + if (typeof options === 'string') return options; + if (options && typeof options === 'object') return `${key}:${JSON.stringify(options)}`; + return key; + }, + }), +})); + +const mockCallHistory = getCallLocationHistory as jest.MockedFunction; +const mockContactHistory = getContactCallHistory as jest.MockedFunction; + +const baseHistory: LocationHistoryData = { + AddressMatchingAvailable: true, + IndexComplete: true, + HasMore: false, + InterpretedAddress: '110 S MAIN ST', + IsProtected: false, + Calls: [ + { + CallId: '9012', + Number: '26-1188', + Name: 'Structure fire', + Nature: 'Smoke from 2nd floor', + Address: '110 South Main', + Priority: 3, + PriorityText: 'Emergency', + PriorityColor: '#d9534f', + State: 1, + LoggedOnUtc: '2026-10-01T14:14:09Z', + LoggedOn: '10/01/2026 2:14:09 PM', + CompletedNotes: 'Knox box key missing - owner notified.', + Matches: ['SameAddress', 'SameContact'], + DistanceMeters: 12.4, + Notes: [{ CallNoteId: '1', FullName: 'Taylor Reed', Note: 'Gate code changed, use 4471.', TimestampUtc: '2026-10-01T14:20:00Z' }], + }, + { + CallId: '8800', + Number: '26-0904', + Name: 'REDACTED', + Address: null, + Priority: 1, + State: 0, + LoggedOnUtc: '2026-08-14T09:02:11Z', + Matches: ['Nearby'], + Notes: [], + }, + ], +}; + +describe('LocationHistoryPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + useLocationHistoryStore.setState({ entries: {} }); + }); + + it('lists previous calls with their match reasons and expands notes and closing notes', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + + render(); + + await waitFor(() => expect(screen.getByTestId('location-history-call-9012')).toBeTruthy()); + expect(mockCallHistory).toHaveBeenCalledWith('42'); + expect(screen.getByText('location_history.header_call')).toBeTruthy(); + expect(screen.getByText('location_history.matched_as:{"address":"110 S MAIN ST"}')).toBeTruthy(); + expect(screen.getByTestId('location-history-match-9012-SameAddress')).toBeTruthy(); + expect(screen.getByTestId('location-history-match-9012-SameContact')).toBeTruthy(); + expect(screen.getByText('location_history.meters_away:{"distance":12}')).toBeTruthy(); + expect(screen.getByText('Structure fire')).toBeTruthy(); + expect(screen.queryByText('Gate code changed, use 4471.')).toBeNull(); + + fireEvent.press(screen.getByTestId('location-history-notes-toggle-9012')); + + expect(screen.getByText('Gate code changed, use 4471.')).toBeTruthy(); + expect(screen.getByText('Knox box key missing - owner notified.')).toBeTruthy(); + expect(screen.queryByTestId('location-history-notes-toggle-8800')).toBeNull(); + expect(mockTrackEvent).toHaveBeenCalledWith('location_history_viewed', expect.objectContaining({ kind: 'call', id: '42', callCount: 2 })); + }); + + it('shows a lock instead of the REDACTED sentinel for protected call text', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + + render(); + + await waitFor(() => expect(screen.getByTestId('location-history-call-8800')).toBeTruthy()); + expect(screen.queryByText('REDACTED')).toBeNull(); + }); + + it('opens a call on the call detail route, or through onOpenCall when given', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + const { unmount } = render(); + await waitFor(() => expect(screen.getByTestId('location-history-open-9012')).toBeTruthy()); + + fireEvent.press(screen.getByTestId('location-history-open-9012')); + expect(router.push).toHaveBeenCalledWith('/call/9012'); + unmount(); + + const onOpenCall = jest.fn(); + mockContactHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + render(); + await waitFor(() => expect(screen.getByTestId('location-history-open-8800')).toBeTruthy()); + + fireEvent.press(screen.getByTestId('location-history-open-8800')); + expect(onOpenCall).toHaveBeenCalledWith('8800'); + expect(screen.getByText('location_history.header_contact')).toBeTruthy(); + }); + + it('explains that only contact links were searched while data protection is on', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: { ...baseHistory, AddressMatchingAvailable: false, InterpretedAddress: null, Calls: [] } } as never); + + render(); + + await waitFor(() => expect(screen.getByTestId('location-history-address-matching-off')).toBeTruthy()); + expect(screen.getByTestId('location-history-empty')).toBeTruthy(); + }); + + it('shows the load error when the request fails', async () => { + mockCallHistory.mockRejectedValueOnce(new Error('offline')); + + render(); + + await waitFor(() => expect(screen.getByTestId('location-history-error')).toBeTruthy()); + }); +}); diff --git a/src/components/calls/location-history-panel.tsx b/src/components/calls/location-history-panel.tsx new file mode 100644 index 0000000..166489f --- /dev/null +++ b/src/components/calls/location-history-panel.tsx @@ -0,0 +1,245 @@ +import { router } from 'expo-router'; +import { ChevronDownIcon, ChevronUpIcon, HistoryIcon, InfoIcon, MapPinIcon } from 'lucide-react-native'; +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ScrollView } from 'react-native'; + +import { ProtectedText } from '@/components/data-protection/protected-text'; +import { Box } from '@/components/ui/box'; +import { HStack } from '@/components/ui/hstack'; +import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; +import { Text } from '@/components/ui/text'; +import { VStack } from '@/components/ui/vstack'; +import { useAnalytics } from '@/hooks/use-analytics'; +import { ProtectedFieldIds } from '@/lib/data-protection/redacted'; +import { formatDateForDisplay, parseDateISOString } from '@/lib/utils'; +import { type LocationHistoryCallData, type LocationHistoryMatch } from '@/models/v4/calls/locationHistoryResult'; +import { locationHistoryKey, type LocationHistorySource, useLocationHistoryStore } from '@/stores/calls/location-history-store'; +import { dataProtectionStore } from '@/stores/data-protection/store'; + +interface LocationHistoryPanelProps { + source: LocationHistorySource; + /** Opens a call; defaults to the call detail route. The contact sheet closes itself first. */ + onOpenCall?: (callId: string) => void; +} + +const MATCH_STYLES: Record = { + SameAddress: { box: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-800 dark:text-blue-200', key: 'location_history.match.same_address' }, + SimilarAddress: { box: 'bg-amber-100 dark:bg-amber-900/40', text: 'text-amber-800 dark:text-amber-200', key: 'location_history.match.similar_address' }, + Nearby: { box: 'bg-sky-100 dark:bg-sky-900/40', text: 'text-sky-800 dark:text-sky-200', key: 'location_history.match.nearby' }, + SameContact: { box: 'bg-green-100 dark:bg-green-900/40', text: 'text-green-800 dark:text-green-200', key: 'location_history.match.same_contact' }, +}; + +const formatTimestamp = (value?: string | null): string => { + if (!value) { + return ''; + } + try { + return formatDateForDisplay(parseDateISOString(value), 'yyyy-MM-dd HH:mm'); + } catch { + return ''; + } +}; + +interface HistoryCallCardProps { + call: LocationHistoryCallData; + onOpenCall: (callId: string) => void; +} + +const HistoryCallCard: React.FC = ({ call, onOpenCall }) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const hasNotes = call.Notes.length > 0 || !!call.CompletedNotes; + const loggedOn = call.LoggedOn || formatTimestamp(call.LoggedOnUtc); + + return ( + + onOpenCall(call.CallId)} className="p-3" testID={`location-history-open-${call.CallId}`}> + + + + {call.Number} + + {call.PriorityText || t('location_history.unknown_priority')} + + {t(`location_history.state.${call.State}`, String(call.State))} + + + {call.Nature ? : null} + + {loggedOn} + + + {call.Address ? ( + + + + {call.DistanceMeters != null && call.DistanceMeters >= 1 ? ( + {t('location_history.meters_away', { distance: Math.round(call.DistanceMeters) })} + ) : null} + + ) : null} + + {call.Matches.length > 0 ? ( + + {call.Matches.map((match) => { + const style = MATCH_STYLES[match]; + return style ? ( + + {t(style.key)} + + ) : null; + })} + + ) : null} + + + {hasNotes ? ( + + setExpanded((value) => !value)} className="px-3 py-2" testID={`location-history-notes-toggle-${call.CallId}`}> + + {expanded ? : } + {expanded ? t('location_history.hide_notes') : t('location_history.show_notes', { count: call.Notes.length })} + + + {expanded ? ( + + {call.CompletedNotes ? ( + + {t('location_history.closing_notes')} + + + ) : null} + {call.Notes.map((note) => ( + + + {formatTimestamp(note.TimestampUtc)} · {note.FullName || t('location_history.unknown_user')} + + + + ))} + + ) : null} + + ) : null} + + ); +}; + +/** + * Previous calls at a location: on the call detail screen, other calls at the same address (however it was typed), nearby + * calls without a street address and calls with the same contacts; on the contact sheet, calls linked to the contact and + * calls at every occupancy it is linked to. Newest first, with notes and closing notes so a crew sees what happened before. + * Re-fetches when the Protected Data Grant changes so a step-up replaces REDACTED values. + */ +export const LocationHistoryPanel: React.FC = ({ source, onOpenCall }) => { + const { t } = useTranslation(); + const { trackEvent } = useAnalytics(); + const key = locationHistoryKey(source); + const entry = useLocationHistoryStore((state) => state.entries[key]); + const fetchHistory = useLocationHistoryStore((state) => state.fetchHistory); + const clear = useLocationHistoryStore((state) => state.clear); + const grantToken = dataProtectionStore((state) => state.grantToken); + const { kind, id } = source; + + React.useEffect(() => { + if (id) { + fetchHistory({ kind, id }); + } + return () => { + clear({ kind, id }); + }; + }, [kind, id, fetchHistory, clear]); + + const previousGrant = React.useRef(grantToken); + React.useEffect(() => { + if (previousGrant.current !== grantToken) { + previousGrant.current = grantToken; + if (id) { + fetchHistory({ kind, id }); + } + } + }, [grantToken, kind, id, fetchHistory]); + + const history = entry?.history ?? null; + React.useEffect(() => { + if (history) { + trackEvent('location_history_viewed', { + kind, + id, + callCount: history.Calls.length, + addressMatchingAvailable: history.AddressMatchingAvailable, + isProtected: history.IsProtected, + }); + } + }, [history, kind, id, trackEvent]); + + const openCall = useCallback( + (callId: string) => { + if (onOpenCall) { + onOpenCall(callId); + } else { + router.push(`/call/${callId}`); + } + }, + [onOpenCall] + ); + + if (entry?.isLoading && !history) { + return ( + + + {t('location_history.loading')} + + ); + } + + if (entry?.error && !history) { + return ( + + {t('location_history.load_failed')} + + ); + } + + const calls = history?.Calls ?? []; + + return ( + + + {kind === 'call' ? t('location_history.header_call') : t('location_history.header_contact')} + {kind === 'call' ? t('location_history.help_call') : t('location_history.help_contact')} + + {history && !history.AddressMatchingAvailable ? ( + + + {t('location_history.address_matching_off')} + + ) : null} + {history && history.AddressMatchingAvailable && !history.IndexComplete ? {t('location_history.index_incomplete')} : null} + {history?.InterpretedAddress ? ( + + {t('location_history.matched_as', { address: history.InterpretedAddress })} + + ) : null} + + {calls.length === 0 ? ( + + + + + + {t('location_history.empty')} + {t('location_history.empty_description')} + + + ) : ( + calls.map((call) => ) + )} + + {history?.HasMore ? {t('location_history.has_more')} : null} + + + ); +}; diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx index 3e15c2b..6e020ab 100644 --- a/src/components/contacts/contact-details-sheet.tsx +++ b/src/components/contacts/contact-details-sheet.tsx @@ -1,9 +1,11 @@ +import { router } from 'expo-router'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Linking, Platform, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import WebView from 'react-native-webview'; +import { LocationHistoryPanel } from '@/components/calls/location-history-panel'; import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { @@ -232,7 +234,14 @@ export const ContactDetailsSheet: React.FC = () => { const isDetailsOpen = useContactsStore((state) => state.isDetailsOpen); const closeDetails = useContactsStore((state) => state.closeDetails); const selectedContactDetails = useContactsStore((state) => state.selectedContactDetails); - const [activeTab, setActiveTab] = useState<'details' | 'notes' | 'preplan' | 'files'>('details'); + const [activeTab, setActiveTab] = useState<'details' | 'notes' | 'preplan' | 'files' | 'calls'>('details'); + const handleOpenCall = useCallback( + (callId: string) => { + closeDetails(); + router.push(`/call/${callId}`); + }, + [closeDetails] + ); const selectedContact = React.useMemo(() => { if (!selectedContactId) return null; @@ -302,7 +311,7 @@ export const ContactDetailsSheet: React.FC = () => { // Handle tab changes with analytics const handleTabChange = useCallback( - (newTab: 'details' | 'notes' | 'preplan' | 'files') => { + (newTab: 'details' | 'notes' | 'preplan' | 'files' | 'calls') => { const fromTab = activeTab; setActiveTab(newTab); @@ -434,6 +443,11 @@ export const ContactDetailsSheet: React.FC = () => { {t('contacts.tabs.files')} + handleTabChange('calls')} className={`flex-1 rounded-md ${isLandscape ? 'px-4 py-2' : 'px-3 py-1.5'} ${activeTab === 'calls' ? 'bg-white shadow-xs dark:bg-gray-700' : ''}`}> + + {t('contacts.tabs.calls')} + + {/* Tab Content */} @@ -550,6 +564,8 @@ export const ContactDetailsSheet: React.FC = () => { ) : activeTab === 'preplan' ? ( + ) : activeTab === 'calls' ? ( + ) : ( )} diff --git a/src/models/v4/calls/locationHistoryResult.ts b/src/models/v4/calls/locationHistoryResult.ts new file mode 100644 index 0000000..23c8a15 --- /dev/null +++ b/src/models/v4/calls/locationHistoryResult.ts @@ -0,0 +1,61 @@ +import { BaseV4Request } from '../baseV4Request'; + +/** Why a call is in a location history; a call can match more than one way. */ +export type LocationHistoryMatch = 'SameContact' | 'SameAddress' | 'SimilarAddress' | 'Nearby'; + +/** A note on a call in a location history (v4 LocationHistoryNoteData). */ +export interface LocationHistoryNoteData { + CallNoteId: string; + UserId?: string | null; + FullName?: string | null; + /** The REDACTED placeholder in a protected department without a grant. */ + Note: string; + TimestampUtc: string; +} + +/** One call in a location history (v4 LocationHistoryCallData). */ +export interface LocationHistoryCallData { + CallId: string; + Number: string; + Name: string; + Nature?: string | null; + Address?: string | null; + Type?: string | null; + Priority: number; + PriorityText?: string | null; + PriorityColor?: string | null; + /** CallStates: 0 Active, 1 Closed, 2 Cancelled, 3 Unfounded, 4 Founded, 5 Minor */ + State: number; + LoggedOnUtc: string; + /** Formatted in the department's time zone */ + LoggedOn?: string | null; + ClosedOnUtc?: string | null; + CompletedNotes?: string | null; + Matches: LocationHistoryMatch[]; + DistanceMeters?: number | null; + /** Oldest first */ + Notes: LocationHistoryNoteData[]; +} + +/** + * GET /Calls/GetCallLocationHistory, /Contacts/GetContactCallHistory and /RecordOccupancies/CallHistory payload: previous + * calls at a location, found by contact link and by address compared parsed ("110 S Main St" = "110 South Main") or proximity. + */ +export interface LocationHistoryData { + /** False while the department's Advanced Data Protection is on: only contact links were searched. */ + AddressMatchingAvailable: boolean; + /** False while older calls are still being indexed; some may be missing. */ + IndexComplete: boolean; + /** More calls matched than were returned; the newest are returned. */ + HasMore: boolean; + /** How the address was understood ("110 S MAIN ST"); null when it did not parse. */ + InterpretedAddress?: string | null; + IsProtected: boolean; + ProtectedReason?: string | null; + /** Newest first */ + Calls: LocationHistoryCallData[]; +} + +export class LocationHistoryResult extends BaseV4Request { + public Data: LocationHistoryData | null = null; +} diff --git a/src/stores/calls/__tests__/location-history-store.test.ts b/src/stores/calls/__tests__/location-history-store.test.ts new file mode 100644 index 0000000..43b86b3 --- /dev/null +++ b/src/stores/calls/__tests__/location-history-store.test.ts @@ -0,0 +1,99 @@ +import { act } from '@testing-library/react-native'; + +import { getCallLocationHistory } from '@/api/calls/callLocationHistory'; +import { getContactCallHistory } from '@/api/contacts/contactCallHistory'; +import { type LocationHistoryData } from '@/models/v4/calls/locationHistoryResult'; + +import { locationHistoryKey, useLocationHistoryStore } from '../location-history-store'; + +jest.mock('@/api/calls/callLocationHistory', () => ({ getCallLocationHistory: jest.fn() })); +jest.mock('@/api/contacts/contactCallHistory', () => ({ getContactCallHistory: jest.fn() })); +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockCallHistory = getCallLocationHistory as jest.MockedFunction; +const mockContactHistory = getContactCallHistory as jest.MockedFunction; + +const history = (callIds: string[]): LocationHistoryData => ({ + AddressMatchingAvailable: true, + IndexComplete: true, + HasMore: false, + IsProtected: false, + Calls: callIds.map((id) => ({ CallId: id, Number: id, Name: 'Call ' + id, Priority: 0, State: 1, LoggedOnUtc: '2026-10-01T10:00:00Z', Matches: ['SameAddress'], Notes: [] })), +}); + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +}; + +describe('useLocationHistoryStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + useLocationHistoryStore.setState({ entries: {} }); + }); + + it('keys entries by source so a call and a contact panel do not overwrite each other', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: history(['1']) } as never); + mockContactHistory.mockResolvedValueOnce({ Data: history(['2', '3']) } as never); + + await act(async () => { + await useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + await useLocationHistoryStore.getState().fetchHistory({ kind: 'contact', id: 'c1' }); + }); + + const { entries } = useLocationHistoryStore.getState(); + expect(entries[locationHistoryKey({ kind: 'call', id: '42' })].history?.Calls).toHaveLength(1); + expect(entries[locationHistoryKey({ kind: 'contact', id: 'c1' })].history?.Calls).toHaveLength(2); + expect(mockCallHistory).toHaveBeenCalledWith('42'); + expect(mockContactHistory).toHaveBeenCalledWith('c1'); + }); + + it('drops a stale response when a newer request for the same source has started', async () => { + const first = deferred(); + mockCallHistory.mockReturnValueOnce(first.promise).mockResolvedValueOnce({ Data: history(['new']) } as never); + + let firstRequest!: Promise; + await act(async () => { + firstRequest = useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + await useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + }); + await act(async () => { + first.resolve({ Data: history(['old']) } as never); + await firstRequest; + }); + + expect(useLocationHistoryStore.getState().entries['call:42'].history?.Calls[0].CallId).toBe('new'); + }); + + it('records an error and clears the history when the request fails', async () => { + mockCallHistory.mockRejectedValueOnce(new Error('boom')); + + await act(async () => { + await useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + }); + + expect(useLocationHistoryStore.getState().entries['call:42']).toEqual({ history: null, isLoading: false, error: 'boom' }); + }); + + it('clear removes the entry and ignores a response still in flight', async () => { + const pending = deferred(); + mockCallHistory.mockReturnValueOnce(pending.promise); + + let request!: Promise; + act(() => { + request = useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + }); + act(() => { + useLocationHistoryStore.getState().clear({ kind: 'call', id: '42' }); + }); + await act(async () => { + pending.resolve({ Data: history(['late']) } as never); + await request; + }); + + expect(useLocationHistoryStore.getState().entries['call:42']).toBeUndefined(); + }); +}); diff --git a/src/stores/calls/location-history-store.ts b/src/stores/calls/location-history-store.ts new file mode 100644 index 0000000..e67542e --- /dev/null +++ b/src/stores/calls/location-history-store.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; + +import { getCallLocationHistory } from '@/api/calls/callLocationHistory'; +import { getContactCallHistory } from '@/api/contacts/contactCallHistory'; +import { logger } from '@/lib/logging'; +import { type LocationHistoryData } from '@/models/v4/calls/locationHistoryResult'; + +/** Whose history: the calls related to a call (same location or same contact) or to a contact (linked or at its occupancies). */ +export interface LocationHistorySource { + kind: 'call' | 'contact'; + id: string; +} + +export interface LocationHistoryEntry { + history: LocationHistoryData | null; + isLoading: boolean; + error: string | null; +} + +interface LocationHistoryState { + entries: Record; + + fetchHistory: (source: LocationHistorySource) => Promise; + clear: (source: LocationHistorySource) => void; +} + +export const locationHistoryKey = (source: LocationHistorySource) => `${source.kind}:${source.id}`; + +// Only the newest request for a key may write. Panels re-fetch the same source when the protected-data grant changes, so +// an answer from before a grant change (revealed or REDACTED) must never land after the newer one. +const latestRequests: Record = {}; +let sequence = 0; + +/** + * Location history state (call detail History tab, contact sheet Calls tab), keyed by source so a call panel and a + * contact panel on screen together never overwrite each other. + */ +export const useLocationHistoryStore = create((set) => ({ + entries: {}, + + fetchHistory: async (source: LocationHistorySource) => { + const key = locationHistoryKey(source); + const request = ++sequence; + latestRequests[key] = request; + set((state) => ({ entries: { ...state.entries, [key]: { history: state.entries[key]?.history ?? null, isLoading: true, error: null } } })); + + try { + const result = source.kind === 'call' ? await getCallLocationHistory(source.id) : await getContactCallHistory(source.id); + if (latestRequests[key] !== request) { + return; + } + set((state) => ({ entries: { ...state.entries, [key]: { history: result.Data ?? null, isLoading: false, error: null } } })); + } catch (error) { + if (latestRequests[key] !== request) { + return; + } + logger.error({ + message: 'Failed to fetch location history', + context: { error, kind: source.kind, id: source.id }, + }); + set((state) => ({ + entries: { + ...state.entries, + [key]: { history: null, isLoading: false, error: error instanceof Error ? error.message : 'Failed to fetch location history' }, + }, + })); + } + }, + + clear: (source: LocationHistorySource) => { + const key = locationHistoryKey(source); + latestRequests[key] = ++sequence; + set((state) => { + const entries = { ...state.entries }; + delete entries[key]; + return { entries }; + }); + }, +})); diff --git a/src/translations/ar.json b/src/translations/ar.json index c8d2463..3080736 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -268,6 +268,7 @@ "command": "القيادة", "contact": "جهة الاتصال", "dispatched": "المرسلة", + "history": "السجل", "info": "المعلومات", "protocols": "البروتوكولات", "site": "معلومات الموقع", @@ -961,6 +962,7 @@ "stateId": "معرف الولاية", "systemInformation": "معلومات النظام", "tabs": { + "calls": "المكالمات", "details": "التفاصيل", "files": "الملفات", "notes": "الملاحظات", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "تتم مشاركة موقعك مع إدارتك.", "tracking_notification_title": "تتبع الموقع" }, + "location_history": { + "address_matching_off": "الحماية المتقدمة للبيانات مفعّلة لهذه الإدارة، لذلك عناوين المكالمات مشفرة ولا تظهر إلا المكالمات المرتبطة عبر جهة اتصال.", + "closing_notes": "ملاحظات الإغلاق", + "empty": "لا توجد مكالمات ذات صلة", + "empty_description": "لم يتم العثور على مكالمات سابقة في هذا الموقع أو مع جهات الاتصال هذه.", + "has_more": "تظهر أحدث المكالمات فقط.", + "header_call": "المكالمات السابقة في هذا الموقع", + "header_contact": "المكالمات ذات الصلة", + "help_call": "المكالمات في العنوان نفسه أياً كانت طريقة كتابته، والمكالمات القريبة التي ليس لها عنوان شارع، والمكالمات المرتبطة بجهة الاتصال نفسها.", + "help_contact": "المكالمات المرتبطة بجهة الاتصال هذه، إضافة إلى المكالمات في أي مبنى ترتبط به جهة الاتصال هذه.", + "hide_notes": "إخفاء الملاحظات", + "index_incomplete": "لا تزال المكالمات الأقدم قيد الفهرسة، لذلك قد لا يظهر بعضها بعد.", + "load_failed": "تعذر تحميل سجل المكالمات.", + "loading": "جارٍ تحميل المكالمات...", + "match": { + "nearby": "قريبة", + "same_address": "العنوان نفسه", + "same_contact": "جهة الاتصال نفسها", + "similar_address": "عنوان مشابه" + }, + "matched_as": "تمت مطابقته على أنه {{address}}", + "meters_away": "على بعد {{distance}} م", + "show_notes": "الملاحظات ({{count}})", + "state": { + "0": "نشطة", + "1": "مغلقة", + "2": "ملغاة", + "3": "غير مؤكدة", + "4": "مؤكدة", + "5": "بسيطة" + }, + "unknown_priority": "غير معروفة", + "unknown_user": "مستخدم غير معروف" + }, "login": { "change_server_url": "تغيير عنوان URL للخادم", "errorModal": { diff --git a/src/translations/de.json b/src/translations/de.json index c53b003..739e6b2 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -268,6 +268,7 @@ "command": "Führung", "contact": "Kontakt", "dispatched": "Alarmiert", + "history": "Verlauf", "info": "Info", "protocols": "Protokolle", "site": "Objektinfo", @@ -961,6 +962,7 @@ "stateId": "Bundesland-ID", "systemInformation": "Systeminformationen", "tabs": { + "calls": "Einsätze", "details": "Details", "files": "Dateien", "notes": "Notizen", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Ihr Standort wird mit Ihrer Abteilung geteilt.", "tracking_notification_title": "Standortverfolgung" }, + "location_history": { + "address_matching_off": "Für diese Abteilung ist Advanced Data Protection aktiv. Einsatzadressen sind verschlüsselt, daher werden nur über Kontakte verknüpfte Einsätze angezeigt.", + "closing_notes": "Abschlussnotizen", + "empty": "Keine zugehörigen Einsätze", + "empty_description": "An diesem Ort oder mit diesen Kontakten wurden keine früheren Einsätze gefunden.", + "has_more": "Es werden nur die neuesten Einsätze angezeigt.", + "header_call": "Frühere Einsätze an diesem Ort", + "header_contact": "Zugehörige Einsätze", + "help_call": "Einsätze an derselben Adresse, egal wie sie geschrieben wurde, Einsätze in der Nähe ohne Straßenadresse und Einsätze mit demselben Kontakt.", + "help_contact": "Einsätze, die mit diesem Kontakt verknüpft sind, sowie Einsätze an allen Objekten, mit denen dieser Kontakt verknüpft ist.", + "hide_notes": "Notizen ausblenden", + "index_incomplete": "Ältere Einsätze werden noch indiziert und erscheinen möglicherweise noch nicht.", + "load_failed": "Die Einsatzhistorie konnte nicht geladen werden.", + "loading": "Einsätze werden geladen...", + "match": { + "nearby": "In der Nähe", + "same_address": "Gleiche Adresse", + "same_contact": "Gleicher Kontakt", + "similar_address": "Ähnliche Adresse" + }, + "matched_as": "Erkannt als {{address}}", + "meters_away": "{{distance}} m entfernt", + "show_notes": "Notizen ({{count}})", + "state": { + "0": "Aktiv", + "1": "Geschlossen", + "2": "Abgebrochen", + "3": "Unbegründet", + "4": "Begründet", + "5": "Geringfügig" + }, + "unknown_priority": "Unbekannt", + "unknown_user": "Unbekannter Benutzer" + }, "login": { "change_server_url": "Server-URL ändern", "errorModal": { diff --git a/src/translations/el.json b/src/translations/el.json index 2e54d42..4a0877d 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -268,6 +268,7 @@ "command": "Διοίκηση", "contact": "Επαφή", "dispatched": "Απεσταλμένες", + "history": "Ιστορικό", "info": "Πληροφορίες", "protocols": "Πρωτόκολλα", "site": "Πληροφορίες χώρου", @@ -961,6 +962,7 @@ "stateId": "Αναγνωριστικό Νομού", "systemInformation": "Πληροφορίες Συστήματος", "tabs": { + "calls": "Κλήσεις", "details": "Λεπτομέρειες", "files": "Αρχεία", "notes": "Σημειώσεις", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Κοινοποίηση της τοποθεσίας σας στο τμήμα σας.", "tracking_notification_title": "Παρακολούθηση τοποθεσίας" }, + "location_history": { + "address_matching_off": "Η Προηγμένη Προστασία Δεδομένων είναι ενεργή για αυτό το τμήμα, επομένως οι διευθύνσεις των κλήσεων είναι κρυπτογραφημένες και εμφανίζονται μόνο οι κλήσεις που συνδέονται μέσω επαφής.", + "closing_notes": "Σημειώσεις κλεισίματος", + "empty": "Δεν υπάρχουν σχετικές κλήσεις", + "empty_description": "Δεν βρέθηκαν προηγούμενες κλήσεις σε αυτή την τοποθεσία ή με αυτές τις επαφές.", + "has_more": "Εμφανίζονται μόνο οι πιο πρόσφατες κλήσεις.", + "header_call": "Προηγούμενες κλήσεις σε αυτή την τοποθεσία", + "header_contact": "Σχετικές κλήσεις", + "help_call": "Κλήσεις στην ίδια διεύθυνση, όπως κι αν γράφτηκε, κοντινές κλήσεις χωρίς διεύθυνση οδού και κλήσεις συνδεδεμένες με την ίδια επαφή.", + "help_contact": "Κλήσεις συνδεδεμένες με αυτή την επαφή, καθώς και κλήσεις σε κάθε κτίριο με το οποίο είναι συνδεδεμένη η επαφή.", + "hide_notes": "Απόκρυψη σημειώσεων", + "index_incomplete": "Οι παλαιότερες κλήσεις ευρετηριάζονται ακόμη, οπότε ορισμένες ίσως δεν εμφανίζονται ακόμα.", + "load_failed": "Δεν ήταν δυνατή η φόρτωση του ιστορικού κλήσεων.", + "loading": "Φόρτωση κλήσεων...", + "match": { + "nearby": "Κοντά", + "same_address": "Ίδια διεύθυνση", + "same_contact": "Ίδια επαφή", + "similar_address": "Παρόμοια διεύθυνση" + }, + "matched_as": "Αναγνωρίστηκε ως {{address}}", + "meters_away": "σε απόσταση {{distance}} μ.", + "show_notes": "Σημειώσεις ({{count}})", + "state": { + "0": "Ενεργή", + "1": "Κλειστή", + "2": "Ακυρωμένη", + "3": "Αβάσιμη", + "4": "Βάσιμη", + "5": "Μικρή" + }, + "unknown_priority": "Άγνωστη", + "unknown_user": "Άγνωστος χρήστης" + }, "login": { "change_server_url": "Αλλαγή Διεύθυνσης URL Διακομιστή", "errorModal": { diff --git a/src/translations/en.json b/src/translations/en.json index 3bf6ee5..f47a08f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -268,6 +268,7 @@ "command": "Command", "contact": "Contact", "dispatched": "Dispatched", + "history": "History", "info": "Info", "protocols": "Protocols", "site": "Site Info", @@ -961,6 +962,7 @@ "stateId": "State ID", "systemInformation": "System Information", "tabs": { + "calls": "Calls", "details": "Details", "files": "Files", "notes": "Notes", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Sharing your location with your department.", "tracking_notification_title": "Location Tracking" }, + "location_history": { + "address_matching_off": "Advanced Data Protection is on for this department, so call addresses are encrypted and only calls linked by contact are shown.", + "closing_notes": "Closing notes", + "empty": "No related calls", + "empty_description": "No earlier calls were found at this location or with these contacts.", + "has_more": "Only the most recent calls are shown.", + "header_call": "Previous calls at this location", + "header_contact": "Related calls", + "help_call": "Calls at the same address however it was typed, nearby calls without a street address, and calls linked to the same contact.", + "help_contact": "Calls linked to this contact, plus calls at any occupancy this contact is linked to.", + "hide_notes": "Hide notes", + "index_incomplete": "Older calls are still being indexed, so some may not appear yet.", + "load_failed": "The call history could not be loaded.", + "loading": "Loading calls...", + "match": { + "nearby": "Nearby", + "same_address": "Same address", + "same_contact": "Same contact", + "similar_address": "Similar address" + }, + "matched_as": "Matched as {{address}}", + "meters_away": "{{distance}} m away", + "show_notes": "Notes ({{count}})", + "state": { + "0": "Active", + "1": "Closed", + "2": "Cancelled", + "3": "Unfounded", + "4": "Founded", + "5": "Minor" + }, + "unknown_priority": "Unknown", + "unknown_user": "Unknown user" + }, "login": { "change_server_url": "Change Server URL", "errorModal": { diff --git a/src/translations/es.json b/src/translations/es.json index f0c804c..20d6994 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -268,6 +268,7 @@ "command": "Comando", "contact": "Contacto", "dispatched": "Despachadas", + "history": "Historial", "info": "Información", "protocols": "Protocolos", "site": "Info. del sitio", @@ -961,6 +962,7 @@ "stateId": "ID del estado", "systemInformation": "Información del sistema", "tabs": { + "calls": "Llamadas", "details": "Detalles", "files": "Archivos", "notes": "Notas", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Compartiendo su ubicación con su departamento.", "tracking_notification_title": "Seguimiento de ubicación" }, + "location_history": { + "address_matching_off": "La Protección Avanzada de Datos está activada para este departamento, por lo que las direcciones de las llamadas están cifradas y solo se muestran las llamadas vinculadas por contacto.", + "closing_notes": "Notas de cierre", + "empty": "No hay llamadas relacionadas", + "empty_description": "No se encontraron llamadas anteriores en esta ubicación ni con estos contactos.", + "has_more": "Solo se muestran las llamadas más recientes.", + "header_call": "Llamadas anteriores en esta ubicación", + "header_contact": "Llamadas relacionadas", + "help_call": "Llamadas en la misma dirección, sin importar cómo se escribió, llamadas cercanas sin dirección postal y llamadas vinculadas al mismo contacto.", + "help_contact": "Llamadas vinculadas a este contacto, más las llamadas en cualquier ocupación a la que esté vinculado este contacto.", + "hide_notes": "Ocultar notas", + "index_incomplete": "Las llamadas más antiguas aún se están indexando, por lo que es posible que algunas no aparezcan todavía.", + "load_failed": "No se pudo cargar el historial de llamadas.", + "loading": "Cargando llamadas...", + "match": { + "nearby": "Cercana", + "same_address": "Misma dirección", + "same_contact": "Mismo contacto", + "similar_address": "Dirección similar" + }, + "matched_as": "Interpretada como {{address}}", + "meters_away": "a {{distance}} m", + "show_notes": "Notas ({{count}})", + "state": { + "0": "Activa", + "1": "Cerrada", + "2": "Cancelada", + "3": "Infundada", + "4": "Fundada", + "5": "Menor" + }, + "unknown_priority": "Desconocida", + "unknown_user": "Usuario desconocido" + }, "login": { "change_server_url": "Cambiar URL del servidor", "errorModal": { diff --git a/src/translations/fr.json b/src/translations/fr.json index e4fc88e..d5cdc1c 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -268,6 +268,7 @@ "command": "Commandement", "contact": "Contact", "dispatched": "Dépêché", + "history": "Historique", "info": "Infos", "protocols": "Protocoles", "site": "Infos site", @@ -961,6 +962,7 @@ "stateId": "ID région", "systemInformation": "Informations système", "tabs": { + "calls": "Appels", "details": "Détails", "files": "Fichiers", "notes": "Notes", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Partage de votre position avec votre département.", "tracking_notification_title": "Suivi de la position" }, + "location_history": { + "address_matching_off": "La protection avancée des données est activée pour ce service : les adresses des appels sont chiffrées et seuls les appels liés par contact sont affichés.", + "closing_notes": "Notes de clôture", + "empty": "Aucun appel associé", + "empty_description": "Aucun appel antérieur n'a été trouvé à cet emplacement ou avec ces contacts.", + "has_more": "Seuls les appels les plus récents sont affichés.", + "header_call": "Appels précédents à cet emplacement", + "header_contact": "Appels associés", + "help_call": "Appels à la même adresse, quelle que soit sa saisie, appels à proximité sans adresse postale et appels liés au même contact.", + "help_contact": "Appels liés à ce contact, ainsi que les appels à toute occupation à laquelle ce contact est lié.", + "hide_notes": "Masquer les notes", + "index_incomplete": "Les appels plus anciens sont encore en cours d'indexation ; certains peuvent ne pas encore apparaître.", + "load_failed": "L'historique des appels n'a pas pu être chargé.", + "loading": "Chargement des appels...", + "match": { + "nearby": "À proximité", + "same_address": "Même adresse", + "same_contact": "Même contact", + "similar_address": "Adresse similaire" + }, + "matched_as": "Interprétée comme {{address}}", + "meters_away": "à {{distance}} m", + "show_notes": "Notes ({{count}})", + "state": { + "0": "Actif", + "1": "Clôturé", + "2": "Annulé", + "3": "Non fondé", + "4": "Fondé", + "5": "Mineur" + }, + "unknown_priority": "Inconnue", + "unknown_user": "Utilisateur inconnu" + }, "login": { "change_server_url": "Modifier l'URL du serveur", "errorModal": { diff --git a/src/translations/it.json b/src/translations/it.json index dc99bed..85df9cb 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -268,6 +268,7 @@ "command": "Comando", "contact": "Contatto", "dispatched": "Inviato", + "history": "Storico", "info": "Info", "protocols": "Protocolli", "site": "Info sito", @@ -961,6 +962,7 @@ "stateId": "ID regione", "systemInformation": "Informazioni di sistema", "tabs": { + "calls": "Chiamate", "details": "Dettagli", "files": "File", "notes": "Note", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Condivisione della tua posizione con il tuo dipartimento.", "tracking_notification_title": "Tracciamento della posizione" }, + "location_history": { + "address_matching_off": "La protezione avanzata dei dati è attiva per questo dipartimento: gli indirizzi delle chiamate sono cifrati e vengono mostrate solo le chiamate collegate tramite contatto.", + "closing_notes": "Note di chiusura", + "empty": "Nessuna chiamata correlata", + "empty_description": "Non sono state trovate chiamate precedenti in questo luogo o con questi contatti.", + "has_more": "Sono mostrate solo le chiamate più recenti.", + "header_call": "Chiamate precedenti in questo luogo", + "header_contact": "Chiamate correlate", + "help_call": "Chiamate allo stesso indirizzo, comunque sia stato scritto, chiamate vicine senza indirizzo stradale e chiamate collegate allo stesso contatto.", + "help_contact": "Chiamate collegate a questo contatto, più le chiamate presso qualsiasi occupazione a cui è collegato questo contatto.", + "hide_notes": "Nascondi note", + "index_incomplete": "Le chiamate meno recenti sono ancora in fase di indicizzazione, quindi alcune potrebbero non comparire ancora.", + "load_failed": "Impossibile caricare lo storico delle chiamate.", + "loading": "Caricamento chiamate...", + "match": { + "nearby": "Nelle vicinanze", + "same_address": "Stesso indirizzo", + "same_contact": "Stesso contatto", + "similar_address": "Indirizzo simile" + }, + "matched_as": "Interpretato come {{address}}", + "meters_away": "a {{distance}} m", + "show_notes": "Note ({{count}})", + "state": { + "0": "Attiva", + "1": "Chiusa", + "2": "Annullata", + "3": "Infondata", + "4": "Fondata", + "5": "Minore" + }, + "unknown_priority": "Sconosciuta", + "unknown_user": "Utente sconosciuto" + }, "login": { "change_server_url": "Modifica URL server", "errorModal": { diff --git a/src/translations/pl.json b/src/translations/pl.json index e8aea15..c83dc12 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -268,6 +268,7 @@ "command": "Dowodzenie", "contact": "Kontakt", "dispatched": "Zadysponowane", + "history": "Historia", "info": "Info", "protocols": "Protokoły", "site": "Info o obiekcie", @@ -961,6 +962,7 @@ "stateId": "Identyfikator województwa", "systemInformation": "Informacje systemowe", "tabs": { + "calls": "Zgłoszenia", "details": "Szczegóły", "files": "Pliki", "notes": "Notatki", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Udostępnianie Twojej lokalizacji jednostce organizacyjnej.", "tracking_notification_title": "Śledzenie lokalizacji" }, + "location_history": { + "address_matching_off": "W tym departamencie włączona jest zaawansowana ochrona danych, dlatego adresy zgłoszeń są zaszyfrowane i wyświetlane są tylko zgłoszenia powiązane przez kontakt.", + "closing_notes": "Notatki zamknięcia", + "empty": "Brak powiązanych zgłoszeń", + "empty_description": "Nie znaleziono wcześniejszych zgłoszeń w tej lokalizacji ani z tymi kontaktami.", + "has_more": "Wyświetlane są tylko najnowsze zgłoszenia.", + "header_call": "Wcześniejsze zgłoszenia w tej lokalizacji", + "header_contact": "Powiązane zgłoszenia", + "help_call": "Zgłoszenia pod tym samym adresem, niezależnie od zapisu, pobliskie zgłoszenia bez adresu ulicy oraz zgłoszenia powiązane z tym samym kontaktem.", + "help_contact": "Zgłoszenia powiązane z tym kontaktem oraz zgłoszenia w każdym obiekcie, z którym ten kontakt jest powiązany.", + "hide_notes": "Ukryj notatki", + "index_incomplete": "Starsze zgłoszenia są jeszcze indeksowane, więc niektóre mogą się jeszcze nie pojawiać.", + "load_failed": "Nie udało się wczytać historii zgłoszeń.", + "loading": "Wczytywanie zgłoszeń...", + "match": { + "nearby": "W pobliżu", + "same_address": "Ten sam adres", + "same_contact": "Ten sam kontakt", + "similar_address": "Podobny adres" + }, + "matched_as": "Rozpoznano jako {{address}}", + "meters_away": "{{distance}} m stąd", + "show_notes": "Notatki ({{count}})", + "state": { + "0": "Aktywne", + "1": "Zamknięte", + "2": "Anulowane", + "3": "Bezzasadne", + "4": "Zasadne", + "5": "Drobne" + }, + "unknown_priority": "Nieznany", + "unknown_user": "Nieznany użytkownik" + }, "login": { "change_server_url": "Zmień URL serwera", "errorModal": { diff --git a/src/translations/sv.json b/src/translations/sv.json index bee6684..59a03d4 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -268,6 +268,7 @@ "command": "Ledning", "contact": "Kontakt", "dispatched": "Dirigerade", + "history": "Historik", "info": "Info", "protocols": "Protokoll", "site": "Objektinfo", @@ -961,6 +962,7 @@ "stateId": "Region-ID", "systemInformation": "Systeminformation", "tabs": { + "calls": "Larm", "details": "Detaljer", "files": "Filer", "notes": "Anteckningar", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Delar din position med din avdelning.", "tracking_notification_title": "Platsspårning" }, + "location_history": { + "address_matching_off": "Avancerat dataskydd är aktiverat för den här avdelningen, så larmadresser är krypterade och endast larm kopplade via kontakt visas.", + "closing_notes": "Avslutsanteckningar", + "empty": "Inga relaterade larm", + "empty_description": "Inga tidigare larm hittades på den här platsen eller med dessa kontakter.", + "has_more": "Endast de senaste larmen visas.", + "header_call": "Tidigare larm på den här platsen", + "header_contact": "Relaterade larm", + "help_call": "Larm på samma adress oavsett hur den skrevs, närliggande larm utan gatuadress och larm kopplade till samma kontakt.", + "help_contact": "Larm kopplade till den här kontakten, samt larm vid alla objekt som kontakten är kopplad till.", + "hide_notes": "Dölj anteckningar", + "index_incomplete": "Äldre larm indexeras fortfarande, så vissa kanske inte visas ännu.", + "load_failed": "Larmhistoriken kunde inte läsas in.", + "loading": "Läser in larm...", + "match": { + "nearby": "I närheten", + "same_address": "Samma adress", + "same_contact": "Samma kontakt", + "similar_address": "Liknande adress" + }, + "matched_as": "Tolkad som {{address}}", + "meters_away": "{{distance}} m bort", + "show_notes": "Anteckningar ({{count}})", + "state": { + "0": "Aktivt", + "1": "Avslutat", + "2": "Avbrutet", + "3": "Ogrundat", + "4": "Grundat", + "5": "Mindre" + }, + "unknown_priority": "Okänd", + "unknown_user": "Okänd användare" + }, "login": { "change_server_url": "Ändra server-URL", "errorModal": { diff --git a/src/translations/uk.json b/src/translations/uk.json index 1e1d2f7..fd4db72 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -268,6 +268,7 @@ "command": "Командування", "contact": "Контакт", "dispatched": "Задіяні", + "history": "Історія", "info": "Інфо", "protocols": "Протоколи", "site": "Про об'єкт", @@ -961,6 +962,7 @@ "stateId": "Ідентифікатор області", "systemInformation": "Системна інформація", "tabs": { + "calls": "Виклики", "details": "Деталі", "files": "Файли", "notes": "Примітки", @@ -1173,6 +1175,40 @@ "tracking_notification_body": "Ваше місцезнаходження надається вашому підрозділу.", "tracking_notification_title": "Відстеження місцезнаходження" }, + "location_history": { + "address_matching_off": "Для цього підрозділу ввімкнено розширений захист даних, тому адреси викликів зашифровані й показано лише виклики, пов’язані через контакт.", + "closing_notes": "Нотатки закриття", + "empty": "Немає пов’язаних викликів", + "empty_description": "Попередніх викликів за цим місцем або з цими контактами не знайдено.", + "has_more": "Показано лише найновіші виклики.", + "header_call": "Попередні виклики за цим місцем", + "header_contact": "Пов’язані виклики", + "help_call": "Виклики за тією самою адресою, хоч би як її записали, виклики поблизу без адреси вулиці та виклики, пов’язані з тим самим контактом.", + "help_contact": "Виклики, пов’язані з цим контактом, а також виклики на будь-якому об’єкті, з яким пов’язаний цей контакт.", + "hide_notes": "Сховати нотатки", + "index_incomplete": "Старіші виклики ще індексуються, тому деякі можуть поки не відображатися.", + "load_failed": "Не вдалося завантажити історію викликів.", + "loading": "Завантаження викликів...", + "match": { + "nearby": "Поблизу", + "same_address": "Та сама адреса", + "same_contact": "Той самий контакт", + "similar_address": "Схожа адреса" + }, + "matched_as": "Розпізнано як {{address}}", + "meters_away": "за {{distance}} м", + "show_notes": "Нотатки ({{count}})", + "state": { + "0": "Активний", + "1": "Закритий", + "2": "Скасований", + "3": "Безпідставний", + "4": "Обґрунтований", + "5": "Незначний" + }, + "unknown_priority": "Невідомий", + "unknown_user": "Невідомий користувач" + }, "login": { "change_server_url": "Змінити URL сервера", "errorModal": { From 7feaa7511e44f912fedfd1b11ae42d9671dd7b9b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 4 Oct 2026 09:15:50 -0700 Subject: [PATCH 2/3] RG-T139 Web Push Support --- package.json | 1 + public/.well-known/apple-app-site-association | 7 + public/.well-known/assetlinks.json | 14 + public/service-worker.js | 113 ++++ src/api/devices/push.ts | 9 + src/app/(app)/_layout.tsx | 3 + src/lib/auth/__tests__/sign-out-hooks.test.ts | 57 ++ src/lib/auth/sign-out-hooks.ts | 46 ++ src/models/v4/configs/getConfigResultData.ts | 7 + .../v4/device/webPushUnRegistrationInput.ts | 5 + src/services/__tests__/service-worker.test.ts | 127 ++++ src/services/__tests__/web-push.web.test.ts | 306 ++++++++++ src/services/push-notification.ts | 51 +- src/services/web-push.ts | 5 + src/services/web-push.web.ts | 410 +++++++++++++ .../auth/__tests__/store-logout.test.ts | 26 + src/stores/auth/store.tsx | 12 + yarn.lock | 572 +++++++++++++++++- 18 files changed, 1754 insertions(+), 17 deletions(-) create mode 100644 public/.well-known/apple-app-site-association create mode 100644 public/.well-known/assetlinks.json create mode 100644 public/service-worker.js create mode 100644 src/lib/auth/__tests__/sign-out-hooks.test.ts create mode 100644 src/lib/auth/sign-out-hooks.ts create mode 100644 src/models/v4/device/webPushUnRegistrationInput.ts create mode 100644 src/services/__tests__/service-worker.test.ts create mode 100644 src/services/__tests__/web-push.web.test.ts create mode 100644 src/services/web-push.ts create mode 100644 src/services/web-push.web.ts diff --git a/package.json b/package.json index 56289e6..113f62a 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "expo-task-manager": "~57.0.20", "expo-video": "~57.0.5", "expo-web-browser": "~57.0.3", + "firebase": "^12.19.0", "geojson": "~0.5.0", "i18next": "~23.14.0", "livekit-client": "^2.20.1", diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association new file mode 100644 index 0000000..cd0b149 --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,7 @@ +{ + "webcredentials": { + "apps": [ + "QKQVAJMTCN.com.wavetech.Resgrid" + ] + } +} diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json new file mode 100644 index 0000000..93ec323 --- /dev/null +++ b/public/.well-known/assetlinks.json @@ -0,0 +1,14 @@ +[ + { + "relation": [ + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "wtdt.resgrid.andriod", + "sha256_cert_fingerprints": [ + "FF:56:FC:B4:6B:4F:07:41:20:92:00:08:AA:D0:46:E3:C8:7A:B8:95:F1:CF:A6:68:46:F0:D2:55:42:1C:77:2D" + ] + } + } +] diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000..7ba348a --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,113 @@ +/* eslint-disable no-undef */ +/** + * Service worker for Resgrid Responder web push. + * + * The page mints its FCM web token against this registration (services/web-push.web.ts), but + * Firebase is not loaded here: every push is shown below. Core sends the fields in the FCM webpush + * block (NovuProvider.SendNotification): data carries title, message, eventCode, type and category. + * + * The app does the routing. A click focuses an open window and posts NOTIFICATION_CLICK to it; with no + * window open, the new one is handed the push after it says CLIENT_READY. Every push is also posted as + * PUSH_RECEIVED so a window the person is looking at can show its in-app alert. + */ + +const DEFAULT_TITLE = 'Resgrid'; + +// Clicks waiting for the window they opened to finish loading, by client id. +const pendingClicks = new Map(); + +function readPush(event) { + let payload = {}; + if (event.data) { + try { + payload = event.data.json() || {}; + } catch (e) { + payload = { notification: { body: event.data.text() } }; + } + } + + const notification = payload.notification || {}; + const data = payload.data || {}; + + return { + title: data.title || notification.title || payload.title || DEFAULT_TITLE, + body: data.message || notification.body || data.body || payload.body || payload.message || '', + eventCode: data.eventCode || payload.eventCode || '', + type: data.type || '', + category: data.category || '', + }; +} + +function isCall(push) { + return push.category === 'calls' || /^c(?!t)/i.test(push.eventCode); +} + +self.addEventListener('push', (event) => { + const push = readPush(event); + + event.waitUntil( + Promise.all([ + self.registration.showNotification(push.title, { + body: push.body, + icon: '/favicon.ico', + tag: push.eventCode || undefined, + renotify: !!push.eventCode, + requireInteraction: isCall(push), + data: push, + }), + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => { + windows.forEach((client) => client.postMessage({ type: 'PUSH_RECEIVED', data: push })); + }), + ]) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const data = event.notification.data || {}; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => { + const client = windows.find((candidate) => 'focus' in candidate); + if (client) { + return client.focus().then((focused) => (focused || client).postMessage({ type: 'NOTIFICATION_CLICK', data })); + } + + if (!self.clients.openWindow) { + return undefined; + } + + return self.clients.openWindow('/').then((opened) => { + if (opened) { + pendingClicks.set(opened.id, data); + } + }); + }) + ); +}); + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', (event) => { + if (!event.data || !event.source) { + return; + } + + if (event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + return; + } + + // A window this worker opened has loaded: hand it the click that opened it. + if (event.data.type === 'CLIENT_READY' && pendingClicks.has(event.source.id)) { + const data = pendingClicks.get(event.source.id); + pendingClicks.delete(event.source.id); + event.source.postMessage({ type: 'NOTIFICATION_CLICK', data }); + } +}); diff --git a/src/api/devices/push.ts b/src/api/devices/push.ts index e54109d..489979b 100644 --- a/src/api/devices/push.ts +++ b/src/api/devices/push.ts @@ -1,11 +1,13 @@ import { type PushRegistrationInput } from '@/models/v4/device/pushRegistrationInput'; import { type PushRegistrationResult } from '@/models/v4/device/pushRegistrationResult'; import { type PushRegistrationUnitInput } from '@/models/v4/device/pushRegistrationUnitInput'; +import { type WebPushUnRegistrationInput } from '@/models/v4/device/webPushUnRegistrationInput'; import { createApiEndpoint } from '../common/client'; const registerUnitDeviceApi = createApiEndpoint('/Devices/RegisterUnitDevice'); const registerDeviceApi = createApiEndpoint('/Devices/RegisterDevice'); +const unRegisterWebPushApi = createApiEndpoint('/Devices/UnRegisterWebPush'); export const registerUnitDevice = async (data: PushRegistrationUnitInput) => { const response = await registerUnitDeviceApi.post({ @@ -20,3 +22,10 @@ export const registerDevice = async (data: PushRegistrationInput) => { }); return response.data; }; + +export const unRegisterWebPush = async (data: WebPushUnRegistrationInput) => { + const response = await unRegisterWebPushApi.post({ + ...data, + }); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index edb562a..a7c72ce 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -37,6 +37,7 @@ import { locationService } from '@/services/location'; import { offlineEventManager } from '@/services/offline-event-manager.service'; import { offlineQueueService } from '@/services/offline-queue.service'; import { usePushNotifications } from '@/services/push-notification'; +import { useWebPushRegistration } from '@/services/web-push'; import { useCoreStore } from '@/stores/app/core-store'; import { useCalendarStore } from '@/stores/calendar/store'; import { useCallsStore } from '@/stores/calls/store'; @@ -100,6 +101,8 @@ export default function TabLayout() { // Initialize push notifications usePushNotifications(); + // Browser push on the web edition (a no-op on the phone). + useWebPushRegistration(); const initializeApp = useCallback(async () => { if (isInitializing.current) { diff --git a/src/lib/auth/__tests__/sign-out-hooks.test.ts b/src/lib/auth/__tests__/sign-out-hooks.test.ts new file mode 100644 index 0000000..55a8890 --- /dev/null +++ b/src/lib/auth/__tests__/sign-out-hooks.test.ts @@ -0,0 +1,57 @@ +/** + * Sign-out hooks (lib/auth/sign-out-hooks.ts) run as the session ends, with its access token, and never hold sign-out up + * for longer than their timeout. + */ +import { _clearSignOutHooks, registerSignOutHook, runSignOutHooks, SIGN_OUT_HOOK_TIMEOUT_MS } from '../sign-out-hooks'; + +afterEach(() => { + _clearSignOutHooks(); + jest.useRealTimers(); +}); + +describe('runSignOutHooks', () => { + it('hands every hook the token and waits for them', async () => { + const calls: string[] = []; + registerSignOutHook(async (token) => { + calls.push(`a:${token}`); + }); + registerSignOutHook(async (token) => { + calls.push(`b:${token}`); + }); + + await runSignOutHooks('access-token'); + + expect(calls).toEqual(['a:access-token', 'b:access-token']); + }); + + it('carries on past a hook that fails', async () => { + const after = jest.fn(async () => undefined); + registerSignOutHook(async () => { + throw new Error('offline'); + }); + registerSignOutHook(after); + + await expect(runSignOutHooks('access-token')).resolves.toBeUndefined(); + expect(after).toHaveBeenCalled(); + }); + + it('stops waiting on a hook that never finishes', async () => { + jest.useFakeTimers(); + registerSignOutHook(() => new Promise(() => undefined)); + + const run = runSignOutHooks('access-token'); + jest.advanceTimersByTime(SIGN_OUT_HOOK_TIMEOUT_MS); + + await expect(run).resolves.toBeUndefined(); + }); + + it('no longer runs a hook once it is removed', async () => { + const hook = jest.fn(async () => undefined); + const remove = registerSignOutHook(hook); + remove(); + + await runSignOutHooks('access-token'); + + expect(hook).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/auth/sign-out-hooks.ts b/src/lib/auth/sign-out-hooks.ts new file mode 100644 index 0000000..d053382 --- /dev/null +++ b/src/lib/auth/sign-out-hooks.ts @@ -0,0 +1,46 @@ +/** + * Work that has to reach the server as the signed-out session, before logout clears its access token: + * taking this browser's or desktop's push token off the server, so the next person on the device never + * sees the previous one's calls. A leaf module (zero imports) for the same reason as session-cleanup: + * the hooks' owners import stores that import the auth store. + * + * A hook gets the token as it stood and must call the server directly, never through the api client: + * the client's 401 handling calls logout, which is already running and would wait on the hook forever. + */ + +export type SignOutHook = (accessToken: string | null) => Promise; + +/** Sign-out never waits longer than this on a slow or unreachable server. */ +export const SIGN_OUT_HOOK_TIMEOUT_MS = 3000; + +const hooks = new Set(); + +/** Returns a function that removes the hook. */ +export const registerSignOutHook = (hook: SignOutHook): (() => void) => { + hooks.add(hook); + return () => { + hooks.delete(hook); + }; +}; + +export const runSignOutHooks = async (accessToken: string | null): Promise => { + if (hooks.size === 0) { + return; + } + + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, SIGN_OUT_HOOK_TIMEOUT_MS); + }); + + try { + await Promise.race([Promise.allSettled([...hooks].map((hook) => hook(accessToken))), timeout]); + } finally { + clearTimeout(timer); + } +}; + +/** Test hook. */ +export const _clearSignOutHooks = (): void => { + hooks.clear(); +}; diff --git a/src/models/v4/configs/getConfigResultData.ts b/src/models/v4/configs/getConfigResultData.ts index 40cc0eb..2300e1b 100644 --- a/src/models/v4/configs/getConfigResultData.ts +++ b/src/models/v4/configs/getConfigResultData.ts @@ -16,6 +16,13 @@ export class GetConfigResultData { public NovuApplicationId: string = ''; public AnalyticsApiKey: string = ''; public AnalyticsHost: string = ''; + /** Firebase web app for browser push; absent or empty while Core has web push off. */ + public WebPushApiKey?: string; + public WebPushAuthDomain?: string; + public WebPushProjectId?: string; + public WebPushMessagingSenderId?: string; + public WebPushAppId?: string; + public WebPushVapidKey?: string; /** Department default map center latitude — every map opens here when it has nothing better. */ public MapCenterLatitude: number = 0; /** Department default map center longitude. */ diff --git a/src/models/v4/device/webPushUnRegistrationInput.ts b/src/models/v4/device/webPushUnRegistrationInput.ts new file mode 100644 index 0000000..78f1eb2 --- /dev/null +++ b/src/models/v4/device/webPushUnRegistrationInput.ts @@ -0,0 +1,5 @@ +/** Takes a browser or desktop push token off the web push channel it was registered on (Core v4 Devices/UnRegisterWebPush). */ +export class WebPushUnRegistrationInput { + public Token: string = ''; + public Prefix: string = ''; +} diff --git a/src/services/__tests__/service-worker.test.ts b/src/services/__tests__/service-worker.test.ts new file mode 100644 index 0000000..0d8e94e --- /dev/null +++ b/src/services/__tests__/service-worker.test.ts @@ -0,0 +1,127 @@ +/** + * The web push service worker (public/service-worker.js), run in a vm with a stand-in `self`. It shows every push from + * the fields Core's webpush block carries, tells open windows about it, and hands a click to an open window, or to the + * window it opens once that window says it is ready. + * + * @jest-environment node + */ +import fs from 'fs'; +import path from 'path'; +import vm from 'vm'; + +const source = fs.readFileSync(path.resolve(__dirname, '../../../public/service-worker.js'), 'utf8'); + +interface FakeClient { + id: string; + posted: unknown[]; + focus: () => Promise; + postMessage: (message: unknown) => void; +} + +function client(id: string): FakeClient { + const fake: FakeClient = { + id, + posted: [], + focus: async () => fake, + postMessage: (message) => fake.posted.push(message), + }; + return fake; +} + +function loadWorker(windows: FakeClient[] = []) { + const listeners: Record void> = {}; + const shown: { title: string; options: any }[] = []; + const opened: string[] = []; + const openedClient = client('new-window'); + + const self = { + addEventListener: (type: string, handler: (event: unknown) => void) => { + listeners[type] = handler; + }, + skipWaiting: () => Promise.resolve(), + registration: { + showNotification: async (title: string, options: unknown) => { + shown.push({ title, options }); + }, + }, + clients: { + claim: async () => undefined, + matchAll: async () => windows, + openWindow: async (url: string) => { + opened.push(url); + return openedClient; + }, + }, + }; + + vm.runInNewContext(source, { self, Map, console }); + + const dispatch = async (type: string, event: object) => { + let pending: Promise = Promise.resolve(); + listeners[type]({ ...event, waitUntil: (promise: Promise) => (pending = promise) }); + await pending; + }; + + return { + shown, + opened, + openedClient, + push: (payload: unknown) => dispatch('push', { data: payload === undefined ? null : { json: () => payload, text: () => JSON.stringify(payload) } }), + click: (data: unknown) => dispatch('notificationclick', { notification: { data, close: () => undefined } }), + message: (data: unknown, from: FakeClient) => listeners.message({ data, source: from }), + }; +} + +const fcmPush = (eventCode: string, category: string) => ({ + from: '343968022249', + notification: { title: 'Structure Fire', body: '123 Main St' }, + data: { title: 'Structure Fire', message: '123 Main St', eventCode, type: '3', category }, +}); + +describe('service worker', () => { + it('shows a call push that stays up until dealt with, and tells open windows', async () => { + const open = client('tab'); + const worker = loadWorker([open]); + + await worker.push(fcmPush('C1234', 'calls')); + + expect(worker.shown).toHaveLength(1); + expect(worker.shown[0].title).toBe('Structure Fire'); + expect(worker.shown[0].options).toMatchObject({ body: '123 Main St', tag: 'C1234', requireInteraction: true, renotify: true }); + expect(open.posted).toEqual([{ type: 'PUSH_RECEIVED', data: { title: 'Structure Fire', body: '123 Main St', eventCode: 'C1234', type: '3', category: 'calls' } }]); + }); + + it('lets other pushes close on their own, and never shows an empty one', async () => { + const worker = loadWorker(); + + await worker.push(fcmPush('M:5', 'messages')); + await worker.push(undefined); + + expect(worker.shown[0].options.requireInteraction).toBe(false); + expect(worker.shown[1].title).toBe('Resgrid'); + }); + + it('hands a click to an open window', async () => { + const open = client('tab'); + const worker = loadWorker([open]); + + await worker.click({ eventCode: 'C:9' }); + + expect(open.posted).toEqual([{ type: 'NOTIFICATION_CLICK', data: { eventCode: 'C:9' } }]); + expect(worker.opened).toEqual([]); + }); + + it('opens the app when no window is open, and hands it the click once it is ready', async () => { + const worker = loadWorker(); + + await worker.click({ eventCode: 'C:9' }); + expect(worker.opened).toEqual(['/']); + + worker.message({ type: 'CLIENT_READY' }, client('someone-else')); + expect(worker.openedClient.posted).toEqual([]); + + worker.message({ type: 'CLIENT_READY' }, worker.openedClient); + worker.message({ type: 'CLIENT_READY' }, worker.openedClient); + expect(worker.openedClient.posted).toEqual([{ type: 'NOTIFICATION_CLICK', data: { eventCode: 'C:9' } }]); + }); +}); diff --git a/src/services/__tests__/web-push.web.test.ts b/src/services/__tests__/web-push.web.test.ts new file mode 100644 index 0000000..19defd2 --- /dev/null +++ b/src/services/__tests__/web-push.web.test.ts @@ -0,0 +1,306 @@ +/** + * Browser push on the Responder web edition (services/web-push.web.ts): the browser mints an FCM web token and registers + * it on the person's user subscriber as Platform 3, only once they turn it on. A registration never outlives its session: + * sign-out unregisters and kills the token, and another person or department in the browser rotates it. + */ + +const mockRegisterDevice = jest.fn(async () => ({})); +const mockUnRegisterWebPush = jest.fn(async () => ({})); +const mockOpenNotification = jest.fn(async () => undefined); +const mockShowNotificationModal = jest.fn(async () => undefined); +const mockFirebaseGetToken = jest.fn(async () => 'browser-token'); +const mockFirebaseDeleteToken = jest.fn(async () => true); + +jest.mock('firebase/app', () => ({ getApps: () => [], initializeApp: (_config: unknown, name: string) => ({ name }) })); +jest.mock('firebase/messaging', () => ({ + isSupported: async () => true, + getMessaging: () => 'messaging', + getToken: (...args: unknown[]) => mockFirebaseGetToken(...(args as [])), + deleteToken: (...args: unknown[]) => mockFirebaseDeleteToken(...(args as [])), +})); +jest.mock('@/api/devices/push', () => ({ + registerDevice: (...args: unknown[]) => mockRegisterDevice(...(args as [])), + unRegisterWebPush: (...args: unknown[]) => mockUnRegisterWebPush(...(args as [])), +})); +jest.mock('@/services/push-notification', () => ({ pushNotificationService: { openNotification: (...args: unknown[]) => mockOpenNotification(...(args as [])) } })); +jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: () => 'https://api.test/api/v4', getDeviceUuid: () => 'device-uuid' })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() } })); +jest.mock('@/lib/mfa/client-app', () => ({ CLIENT_HEADER: 'X-Resgrid-Client', RESGRID_CLIENT: 'responder' })); +jest.mock('@/stores/push-notification/store', () => ({ usePushNotificationModalStore: { getState: () => ({ showNotificationModal: mockShowNotificationModal }) } })); + +const mockState = { + auth: { status: 'signedIn', accessToken: 'access-token', userId: 'user-1' } as { status: string; accessToken: string | null; userId: string | null }, + core: { config: null as Record | null }, + security: { rights: { DepartmentCode: 'DEPT' } as { DepartmentCode: string } | null }, +}; + +const mockStoreOf = (read: () => object) => Object.assign((selector: (value: object) => unknown) => selector(read()), { getState: read }); +jest.mock('@/stores/auth/store', () => ({ __esModule: true, default: mockStoreOf(() => mockState.auth) })); +jest.mock('@/stores/app/core-store', () => ({ useCoreStore: mockStoreOf(() => mockState.core) })); +jest.mock('@/stores/security/store', () => ({ securityStore: mockStoreOf(() => mockState.security) })); + +const firebaseConfig = { + WebPushApiKey: 'api-key', + WebPushAuthDomain: '', + WebPushProjectId: 'resgrid-web', + WebPushMessagingSenderId: '343968022249', + WebPushAppId: '1:343968022249:web:abc', + WebPushVapidKey: 'BVapid', +}; + +type Module = typeof import('../web-push.web'); +type Hooks = typeof import('@/lib/auth/sign-out-hooks'); + +const globals = globalThis as unknown as Record; +let storage: Map; +let worker: { scriptURL: string; postMessage: jest.Mock }; +let serviceWorkerListeners: ((event: { data: unknown }) => void)[]; +let permission: NotificationPermission; +let requestPermission: jest.Mock; +let clickListeners: Set<() => void>; + +/** A click anywhere on the page, as the person would make one. */ +const clickPage = () => [...clickListeners].forEach((listener) => listener()); + +function installBrowser() { + storage = new Map(); + worker = { scriptURL: 'https://responder.test/service-worker.js', postMessage: jest.fn() }; + serviceWorkerListeners = []; + permission = 'default'; + requestPermission = jest.fn(async () => permission); + + const registration = { active: worker, pushManager: { getSubscription: async () => null } }; + globals.window = globalThis; + globals.isSecureContext = true; + globals.PushManager = function PushManager() {}; + globals.Notification = { + get permission() { + return permission; + }, + requestPermission: () => requestPermission(), + }; + globals.localStorage = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }; + clickListeners = new Set(); + globals.document = { + visibilityState: 'visible', + hasFocus: () => true, + addEventListener: (type: string, listener: () => void) => type === 'click' && clickListeners.add(listener), + removeEventListener: (type: string, listener: () => void) => type === 'click' && clickListeners.delete(listener), + }; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + serviceWorker: { + controller: null, + ready: Promise.resolve(registration), + register: jest.fn(async () => registration), + getRegistrations: jest.fn(async () => [registration]), + addEventListener: (_type: string, listener: (event: { data: unknown }) => void) => serviceWorkerListeners.push(listener), + removeEventListener: jest.fn(), + }, + }, + }); +} + +function load(): { push: Module; hooks: Hooks } { + let loaded!: { push: Module; hooks: Hooks }; + jest.isolateModules(() => { + loaded = { push: require('../web-push.web'), hooks: require('@/lib/auth/sign-out-hooks') }; + }); + return loaded; +} + +beforeEach(() => { + // jest-setup fakes timers for every test; these wait on real microtask and immediate turns. + jest.useRealTimers(); + jest.clearAllMocks(); + mockState.auth = { status: 'signedIn', accessToken: 'access-token', userId: 'user-1' }; + mockState.core = { config: { ...firebaseConfig } }; + mockState.security = { rights: { DepartmentCode: 'DEPT' } }; + installBrowser(); + globals.fetch = jest.fn(async () => ({ ok: true })); +}); + +describe('browser', () => { + it('registers nothing, and never asks, while Core has no Firebase web app configured', async () => { + mockState.core.config = null; + permission = 'granted'; + const { push } = load(); + + await push.syncWebPush(); + push.askForPermissionOnce(); + clickPage(); + + expect(mockRegisterDevice).not.toHaveBeenCalled(); + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it("registers its FCM web token on the person's user subscriber as soon as the browser has permission, with no setting of its own", async () => { + permission = 'granted'; + const { push } = load(); + + await push.syncWebPush(); + + expect(requestPermission).not.toHaveBeenCalled(); + expect(mockFirebaseGetToken).toHaveBeenCalledWith('messaging', expect.objectContaining({ vapidKey: 'BVapid' })); + expect(mockRegisterDevice).toHaveBeenCalledWith({ UserId: 'user-1', Token: 'browser-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + }); + + it('asks for permission once, on the first click after sign-in, then registers', async () => { + requestPermission.mockImplementation(async () => (permission = 'granted')); + const { push } = load(); + + await push.syncWebPush(); + push.askForPermissionOnce(); + expect(mockRegisterDevice).not.toHaveBeenCalled(); + expect(requestPermission).not.toHaveBeenCalled(); + + clickPage(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(requestPermission).toHaveBeenCalledTimes(1); + expect(mockRegisterDevice).toHaveBeenCalledWith({ UserId: 'user-1', Token: 'browser-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + + clickPage(); + expect(requestPermission).toHaveBeenCalledTimes(1); + }); + + it('waits a week before asking again after the prompt is dismissed', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + const { push } = load(); + + push.askForPermissionOnce(); + clickPage(); + await new Promise((resolve) => setImmediate(resolve)); + expect(requestPermission).toHaveBeenCalledTimes(1); + + push.askForPermissionOnce(); + clickPage(); + expect(requestPermission).toHaveBeenCalledTimes(1); + + now.mockReturnValue(1_000_000 + 8 * 24 * 60 * 60 * 1000); + push.askForPermissionOnce(); + clickPage(); + expect(requestPermission).toHaveBeenCalledTimes(2); + expect(mockRegisterDevice).not.toHaveBeenCalled(); + now.mockRestore(); + }); + + it('neither asks nor registers once the person has blocked notifications', async () => { + permission = 'denied'; + const { push } = load(); + + await push.syncWebPush(); + push.askForPermissionOnce(); + clickPage(); + + expect(requestPermission).not.toHaveBeenCalled(); + expect(mockRegisterDevice).not.toHaveBeenCalled(); + }); + + it('re-registers an unchanged token only once a day', async () => { + permission = 'granted'; + const { push } = load(); + const now = jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + + await push.syncWebPush(); + await push.syncWebPush(); + expect(mockRegisterDevice).toHaveBeenCalledTimes(1); + + now.mockReturnValue(1_000_000 + 25 * 60 * 60 * 1000); + await push.syncWebPush(); + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + now.mockRestore(); + }); + + it('takes the device off the previous department and rotates the token before registering in the new one', async () => { + permission = 'granted'; + const { push } = load(); + await push.syncWebPush(); + mockFirebaseGetToken.mockResolvedValue('rotated-token'); + + mockState.security = { rights: { DepartmentCode: 'OTHER' } }; + await push.syncWebPush(); + + expect(mockUnRegisterWebPush).toHaveBeenCalledWith({ Token: 'browser-token', Prefix: 'DEPT' }); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(mockRegisterDevice).toHaveBeenLastCalledWith({ UserId: 'user-1', Token: 'rotated-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'OTHER' }); + mockFirebaseGetToken.mockResolvedValue('browser-token'); + }); + + it("kills the previous person's token and registers the next on a fresh one", async () => { + permission = 'granted'; + const { push } = load(); + await push.syncWebPush(); + mockFirebaseGetToken.mockResolvedValue('rotated-token'); + + mockState.auth = { status: 'signedIn', accessToken: 'second-token', userId: 'user-2' }; + await push.syncWebPush(); + + // This session can't speak for the previous person; deleting the token at FCM is what stops it. + expect(mockUnRegisterWebPush).not.toHaveBeenCalled(); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(mockRegisterDevice).toHaveBeenLastCalledWith({ UserId: 'user-2', Token: 'rotated-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + mockFirebaseGetToken.mockResolvedValue('browser-token'); + }); + + it('unregisters straight to the server and kills the token at sign-out, while the session still works', async () => { + permission = 'granted'; + const { push, hooks } = load(); + await push.syncWebPush(); + + await hooks.runSignOutHooks('access-token'); + + expect(globals.fetch).toHaveBeenCalledWith('https://api.test/api/v4/Devices/UnRegisterWebPush', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer access-token', 'X-Resgrid-Client': 'responder' }, + body: JSON.stringify({ Token: 'browser-token', Prefix: 'DEPT' }), + }); + expect(mockUnRegisterWebPush).not.toHaveBeenCalled(); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + }); + + it('still kills the token when the sign-out call fails', async () => { + permission = 'granted'; + const { push, hooks } = load(); + await push.syncWebPush(); + (globals.fetch as jest.Mock).mockRejectedValue(new Error('offline')); + + await hooks.runSignOutHooks('access-token'); + + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + }); + +}); + +describe('clicks and foreground pushes', () => { + it('opens a clicked push wherever a tapped one opens on the phone', async () => { + const { push } = load(); + + await push.openWebPush({ title: 'Fire', body: '123 Main St', eventCode: 'C:1234' }); + + expect(mockOpenNotification).toHaveBeenCalledWith({ eventCode: 'C:1234', title: 'Fire', body: '123 Main St', data: { title: 'Fire', body: '123 Main St', eventCode: 'C:1234' } }); + }); + + it('takes the service worker clicks and shows a push only to a page in front of the person', async () => { + const { push } = load(); + push.attachWebPushListeners(); + await new Promise((resolve) => setImmediate(resolve)); + expect(worker.postMessage).toHaveBeenCalledWith({ type: 'CLIENT_READY' }); + + serviceWorkerListeners.forEach((listener) => listener({ data: { type: 'NOTIFICATION_CLICK', data: { title: 'Fire', body: '', eventCode: 'C:9' } } })); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockOpenNotification).toHaveBeenCalledWith(expect.objectContaining({ eventCode: 'C:9' })); + + serviceWorkerListeners.forEach((listener) => listener({ data: { type: 'PUSH_RECEIVED', data: { title: 'Msg', body: '', eventCode: 'M:1' } } })); + expect(mockShowNotificationModal).toHaveBeenCalledTimes(1); + + globals.document = { visibilityState: 'hidden', hasFocus: () => false }; + serviceWorkerListeners.forEach((listener) => listener({ data: { type: 'PUSH_RECEIVED', data: { title: 'Msg', body: '', eventCode: 'M:2' } } })); + expect(mockShowNotificationModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 53b8aaf..d4a0b51 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -252,25 +252,27 @@ class PushNotificationService { // tap-response handlers so a tapped notification stays visible after the app opens. private presentNotificationModal = (request: Notifications.NotificationRequest): void => { const { eventCode, data } = extractPushNotificationData(request); - const content = request.content; - if (eventCode) { - const notificationData: PushNotificationData & { eventCode: string } = { - eventCode, - data, - }; + this.showNotificationModal({ eventCode, data, title: request.content.title ?? undefined, body: request.content.body ?? undefined }); + } + }; - if (content.title) { - notificationData.title = content.title; - } + private showNotificationModal = (notification: PushNotificationData & { eventCode: string }): void => { + const notificationData: PushNotificationData & { eventCode: string } = { + eventCode: notification.eventCode, + data: notification.data, + }; - if (content.body) { - notificationData.body = content.body; - } + if (notification.title) { + notificationData.title = notification.title; + } - // Show the notification modal using the store - usePushNotificationModalStore.getState().showNotificationModal(notificationData); + if (notification.body) { + notificationData.body = notification.body; } + + // Show the notification modal using the store + usePushNotificationModalStore.getState().showNotificationModal(notificationData); }; private handleNotificationReceived = (notification: Notifications.Notification): void => { @@ -300,6 +302,17 @@ class PushNotificationService { context: { eventCode }, }); + const content = response.notification.request.content; + await this.openNotification({ eventCode: eventCode ?? '', data, title: content.title ?? undefined, body: content.body ?? undefined }); + }; + + /** + * Opens what a notification points at. Shared by a tapped phone notification and a clicked browser one + * (services/web-push.web.ts), so both land in the same place. + */ + public openNotification = async (notification: PushNotificationData & { eventCode: string }): Promise => { + const { eventCode, data } = notification; + // Tapping a push deep-links straight to the relevant screen: weather alerts, chat // channels, calls, messages and work orders all navigate directly. Anything else — and any deep-link // that never lands, e.g. a cold start where the session never hydrates — falls back to @@ -338,7 +351,9 @@ class PushNotificationService { } } - this.presentNotificationModal(response.notification.request); + if (eventCode) { + this.showNotificationModal(notification); + } }; // Resolves true when the push landed on its route, false once the retry budget is spent so @@ -369,6 +384,12 @@ class PushNotificationService { }; public async registerForPushNotifications(userId: string, departmentCode: string): Promise { + // A browser registers through services/web-push.web.ts, and only once the person asks: asking for permission + // here, with no click behind it, is refused by most browsers and failed for want of a web push config anyway. + if (Platform.OS === 'web') { + return null; + } + if (!Device.isDevice) { logger.warn({ message: 'Push notifications are not available on simulator/emulator', diff --git a/src/services/web-push.ts b/src/services/web-push.ts new file mode 100644 index 0000000..8335544 --- /dev/null +++ b/src/services/web-push.ts @@ -0,0 +1,5 @@ +/** + * Browser push lives in web-push.web.ts. The phone apps register through push-notification.ts, so there is nothing + * to do here. + */ +export const useWebPushRegistration = (): void => undefined; diff --git a/src/services/web-push.web.ts b/src/services/web-push.web.ts new file mode 100644 index 0000000..ac69617 --- /dev/null +++ b/src/services/web-push.web.ts @@ -0,0 +1,410 @@ +/** + * Browser push for the Responder web edition. + * + * The browser mints an FCM web token with the Firebase JS SDK against /service-worker.js (which shows each push and + * hands clicks back here) and registers it on the person's user subscriber as Platform 3. Core keeps those on a web + * channel beside their other browsers, never replacing their phones. The Firebase web app comes from Core's config + * (WebPush* fields); without it there is no push here. + * + * Like the phone apps there is no setting for it: a browser with notification permission (asked for once, + * on the first click after sign-in, the way the phone asks at sign-in) is registered. + * Which pushes reach it is decided by the push preferences on the server, as for any device. + * + * Nothing registered may outlive its session: signing out takes the token off the server and kills it in this + * browser (a sign-out hook, run while the session's token still works), and a different person or department in + * this browser rotates the token before registering. + */ +import { getApps, initializeApp } from 'firebase/app'; +import { deleteToken, getMessaging, getToken, isSupported } from 'firebase/messaging'; +import { useEffect } from 'react'; + +import { registerDevice, unRegisterWebPush } from '@/api/devices/push'; +import { registerSignOutHook } from '@/lib/auth/sign-out-hooks'; +import { logger } from '@/lib/logging'; +import { CLIENT_HEADER, RESGRID_CLIENT } from '@/lib/mfa/client-app'; +import { getBaseApiUrl, getDeviceUuid } from '@/lib/storage/app'; +import { pushNotificationService } from '@/services/push-notification'; +import { useCoreStore } from '@/stores/app/core-store'; +import useAuthStore from '@/stores/auth/store'; +import { usePushNotificationModalStore } from '@/stores/push-notification/store'; +import { securityStore } from '@/stores/security/store'; + +/** One push as the service worker hands it over. */ +export interface WebPushPayload { + title: string; + body: string; + eventCode: string; + type?: string; + category?: string; +} + +export interface WebPushFirebaseConfig { + apiKey: string; + authDomain?: string; + projectId: string; + messagingSenderId: string; + appId: string; + vapidKey: string; +} + +interface UserIdentity { + userId: string; + prefix: string; +} + +interface StoredRegistration extends UserIdentity { + key: string; + token: string; + registeredAt: number; +} + +/** Platforms.Web on the server. */ +const WEB_PLATFORM = 3; +const SERVICE_WORKER_URL = '/service-worker.js'; +const REGISTRATION_KEY = 'rg.webPush.registration'; +/** When the permission prompt was last dismissed without an answer. */ +const ASKED_KEY = 'rg.webPush.asked'; +/** A dismissed prompt waits a week: browsers stop a site's prompts for a while after a few dismissals. */ +const ASK_AGAIN_AFTER_MS = 7 * 24 * 60 * 60 * 1000; +/** FCM rotates web tokens; re-registering daily keeps the server's copy current. */ +const REREGISTER_AFTER_MS = 24 * 60 * 60 * 1000; + +const listeners = new Set<() => void>(); + +function notify(): void { + listeners.forEach((listener) => listener()); +} + +/** Calls back whenever this device's push status may have changed. Returns the unsubscribe. */ +export function subscribeWebPush(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function readJson(key: string): T | null { + try { + const raw = window.localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : null; + } catch { + return null; + } +} + +function writeJson(key: string, value: unknown): void { + try { + if (value === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(value)); + } + } catch { + // storage unavailable: the registration just isn't remembered across reloads + } +} + +const readRegistration = (): StoredRegistration | null => readJson(REGISTRATION_KEY); +const writeRegistration = (registration: StoredRegistration | null): void => writeJson(REGISTRATION_KEY, registration); + +export function getWebPushConfig(): WebPushFirebaseConfig | null { + const config = useCoreStore.getState().config; + if (!config?.WebPushApiKey || !config.WebPushProjectId || !config.WebPushMessagingSenderId || !config.WebPushAppId || !config.WebPushVapidKey) { + return null; + } + + return { + apiKey: config.WebPushApiKey, + authDomain: config.WebPushAuthDomain || undefined, + projectId: config.WebPushProjectId, + messagingSenderId: config.WebPushMessagingSenderId, + appId: config.WebPushAppId, + vapidKey: config.WebPushVapidKey, + }; +} + +function browserCanPush(): boolean { + return typeof window !== 'undefined' && window.isSecureContext && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +function currentIdentity(): UserIdentity | null { + const auth = useAuthStore.getState(); + if (auth.status !== 'signedIn') { + return null; + } + + const prefix = securityStore.getState().rights?.DepartmentCode; + return auth.userId && prefix ? { userId: auth.userId, prefix } : null; +} + +const keyOf = (identity: UserIdentity): string => `user:${identity.userId}:${identity.prefix}`; + +// --- Browser (Firebase JS SDK) --- + +async function loadMessaging(config: WebPushFirebaseConfig) { + if (!(await isSupported())) { + return null; + } + + const appName = 'rg-web-push'; + const app = + getApps().find((candidate) => candidate.name === appName) ?? + initializeApp({ apiKey: config.apiKey, authDomain: config.authDomain, projectId: config.projectId, messagingSenderId: config.messagingSenderId, appId: config.appId }, appName); + + return getMessaging(app); +} + +async function findServiceWorker(): Promise { + const registrations = await navigator.serviceWorker.getRegistrations(); + return ( + registrations.find((registration) => { + const worker = registration.active ?? registration.waiting ?? registration.installing; + return !!worker && new URL(worker.scriptURL).pathname === SERVICE_WORKER_URL; + }) ?? null + ); +} + +async function mintBrowserToken(config: WebPushFirebaseConfig): Promise { + const messaging = await loadMessaging(config); + if (!messaging) { + return null; + } + + const serviceWorkerRegistration = await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' }); + if (!serviceWorkerRegistration.active) { + await navigator.serviceWorker.ready; + } + + return (await getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration })) || null; +} + +/** Kills the browser's token at FCM, so every channel still holding it stops delivering here. */ +async function deleteBrowserToken(config: WebPushFirebaseConfig | null): Promise { + const registration = await findServiceWorker().catch(() => null); + if (!registration) { + return; + } + + try { + const messaging = config ? await loadMessaging(config) : null; + if (messaging && config && Notification.permission === 'granted') { + // deleteToken acts on the worker getToken last named; with a token stored this getToken is a local read. + await getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration: registration }); + await deleteToken(messaging); + return; + } + } catch (error) { + logger.warn({ message: 'Web push: the FCM token could not be deleted', context: { error } }); + } + + // Without Firebase, dropping the push subscription still leaves FCM nowhere to deliver. + try { + const subscription = await registration.pushManager.getSubscription(); + await subscription?.unsubscribe(); + } catch { + // nothing more can be done from here + } +} + +async function deleteLocalToken(config: WebPushFirebaseConfig | null): Promise { + if (browserCanPush()) { + await deleteBrowserToken(config); + } +} + +// --- Registration --- + +async function unregisterQuietly(registration: StoredRegistration): Promise { + try { + await unRegisterWebPush({ Token: registration.token, Prefix: registration.prefix }); + } catch (error) { + logger.warn({ message: 'Web push: the previous registration could not be removed', context: { error } }); + } +} + +async function runSync(): Promise { + const identity = currentIdentity(); + const config = getWebPushConfig(); + if (!identity || !config) { + return; + } + + const stored = readRegistration(); + if (stored && stored.key !== keyOf(identity)) { + // Another person or department on this device: take the device off the old one (when this session may), + // then rotate the token so the old channel holds a dead one even if that call failed. + if (stored.userId === identity.userId) { + await unregisterQuietly(stored); + } + writeRegistration(null); + await deleteLocalToken(config); + } + + if (Notification.permission !== 'granted') { + notify(); + return; + } + + const token = await mintBrowserToken(config); + if (!token) { + notify(); + return; + } + + const current = readRegistration(); + if (current && current.key === keyOf(identity) && current.token === token && Date.now() - current.registeredAt < REREGISTER_AFTER_MS) { + return; + } + + if (current && current.token !== token) { + await unregisterQuietly(current); + } + + await registerDevice({ UserId: identity.userId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getDeviceUuid() || '', Prefix: identity.prefix }); + writeRegistration({ key: keyOf(identity), userId: identity.userId, prefix: identity.prefix, token, registeredAt: Date.now() }); + notify(); +} + +let syncQueue: Promise = Promise.resolve(); +let askArmed = false; + +/** Brings this device's registration in line with whoever is signed in. Calls run one at a time. */ +export function syncWebPush(): Promise { + const run = syncQueue.then(runSync, runSync); + syncQueue = run.catch(() => undefined); + return run; +} + +/** + * Asks for notification permission the way the phone app does after sign-in, once. Browsers only show the prompt + * from a click, so it waits for the first one. A granted permission registers this browser straight away. + */ +export function askForPermissionOnce(): void { + if (!getWebPushConfig() || !browserCanPush() || Notification.permission !== 'default' || askArmed) { + return; + } + + const lastAsked = readJson(ASKED_KEY); + if (lastAsked && Date.now() - lastAsked < ASK_AGAIN_AFTER_MS) { + return; + } + + askArmed = true; + const ask = () => { + document.removeEventListener('click', ask, true); + // Asked inside the click, while the browser still counts it as the person's. + void Notification.requestPermission() + .then((permission) => { + // Denied is remembered by the browser; a dismissed prompt waits a week before asking again. + writeJson(ASKED_KEY, permission === 'default' ? Date.now() : null); + notify(); + return permission === 'granted' ? syncWebPush() : undefined; + }) + .catch((error) => logger.warn({ message: 'Web push: the permission request failed', context: { error } })) + .finally(() => { + askArmed = false; + }); + }; + + document.addEventListener('click', ask, true); +} + +registerSignOutHook(async (accessToken) => { + const stored = readRegistration(); + writeRegistration(null); + + if (stored && accessToken) { + // Straight to the server, not through the api client: its 401 handling would re-enter logout. Awaited (sign-out + // waits on it), so no keepalive: some browsers refuse keepalive on a cross-origin call that needs a preflight. + try { + await fetch(`${getBaseApiUrl()}/Devices/UnRegisterWebPush`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, [CLIENT_HEADER]: RESGRID_CLIENT }, + body: JSON.stringify({ Token: stored.token, Prefix: stored.prefix }), + }); + } catch (error) { + logger.warn({ message: 'Web push: the token could not be unregistered at sign-out', context: { error } }); + } + } + + if (stored) { + await deleteLocalToken(getWebPushConfig()); + } + + notify(); +}); + +// --- Incoming pushes --- + +/** A clicked notification goes wherever a tapped one does on the phone (push-notification.ts). */ +export async function openWebPush(payload: WebPushPayload): Promise { + if (!payload?.eventCode) { + return; + } + + await pushNotificationService.openNotification({ eventCode: payload.eventCode, title: payload.title, body: payload.body, data: { ...payload } }); +} + +/** A push arriving while the person is looking at the app gets the in-app alert, as on the phone. */ +function onPushReceived(payload: WebPushPayload): void { + if (payload?.eventCode && typeof document !== 'undefined' && document.visibilityState === 'visible' && document.hasFocus()) { + usePushNotificationModalStore.getState().showNotificationModal({ eventCode: payload.eventCode, title: payload.title, body: payload.body, data: { ...payload } }); + } +} + +let detachListeners: (() => void) | null = null; + +/** Wires clicks and foreground pushes to the app. Safe to call repeatedly. */ +export function attachWebPushListeners(): void { + if (detachListeners || typeof window === 'undefined' || !('serviceWorker' in navigator)) { + return; + } + + const onMessage = (event: MessageEvent) => { + const message = event.data as { type?: string; data?: WebPushPayload } | undefined; + if (message?.type === 'NOTIFICATION_CLICK' && message.data) { + void openWebPush(message.data); + } else if (message?.type === 'PUSH_RECEIVED' && message.data) { + onPushReceived(message.data); + } + }; + + navigator.serviceWorker.addEventListener('message', onMessage); + detachListeners = () => navigator.serviceWorker.removeEventListener('message', onMessage); + + // If the worker opened this window for a click, it hands the click over once told the page is ready. + void findServiceWorker() + .then((registration) => (navigator.serviceWorker.controller ?? registration?.active)?.postMessage({ type: 'CLIENT_READY' })) + .catch(() => undefined); +} + +/** Test hook. */ +export function _resetWebPushForTests(): void { + detachListeners?.(); + detachListeners = null; + syncQueue = Promise.resolve(); + askArmed = false; + listeners.clear(); +} + +/** Keeps this browser registered for whoever is signed in. Called from the signed-in layout. */ +export const useWebPushRegistration = (): void => { + const authStatus = useAuthStore((state) => state.status); + const userId = useAuthStore((state) => state.userId); + const hasConfig = useCoreStore((state) => !!state.config?.WebPushProjectId); + const departmentCode = securityStore((state) => state.rights?.DepartmentCode); + + useEffect(() => { + attachWebPushListeners(); + }, []); + + useEffect(() => { + // Registering before auth settles would send a token the server is about to refuse. + if (authStatus !== 'signedIn' || !useAuthStore.getState().accessToken || !userId || !departmentCode || !hasConfig) { + return; + } + + syncWebPush().catch((error) => logger.error({ message: 'Web push registration failed', context: { error } })); + askForPermissionOnce(); + }, [authStatus, userId, departmentCode, hasConfig]); +}; diff --git a/src/stores/auth/__tests__/store-logout.test.ts b/src/stores/auth/__tests__/store-logout.test.ts index 85cf23d..806f478 100644 --- a/src/stores/auth/__tests__/store-logout.test.ts +++ b/src/stores/auth/__tests__/store-logout.test.ts @@ -54,6 +54,7 @@ jest.mock('@/lib/storage/clear-all-data', () => ({ import * as SecureStore from 'expo-secure-store'; +import { _clearSignOutHooks, registerSignOutHook } from '@/lib/auth/sign-out-hooks'; import { clearAllAppData } from '@/lib/storage/clear-all-data'; import useAuthStore from '../store'; @@ -108,6 +109,31 @@ describe('Auth Store - Logout Functionality', () => { expect(state.userId).toBeNull(); }); + it('runs the sign-out hooks first, with the session still signed in and its token, before any data is cleared', async () => { + const seen: { token: string | null; status: string; cleared: boolean }[] = []; + registerSignOutHook(async (token) => { + seen.push({ token, status: useAuthStore.getState().status, cleared: mockedClearAllAppData.mock.calls.length > 0 }); + }); + + await useAuthStore.getState().logout(); + + expect(seen).toEqual([{ token: 'test-token', status: 'signedIn', cleared: false }]); + expect(useAuthStore.getState().accessToken).toBeNull(); + _clearSignOutHooks(); + }); + + it('still signs out when a sign-out hook fails', async () => { + registerSignOutHook(async () => { + throw new Error('server unreachable'); + }); + + await useAuthStore.getState().logout(); + + expect(useAuthStore.getState().status).toBe('signedOut'); + expect(mockedClearAllAppData).toHaveBeenCalled(); + _clearSignOutHooks(); + }); + it('should log warning if removeItem fails but still reset auth state', async () => { const mockError = new Error('Storage error'); mockedRemoveItem.mockRejectedValueOnce(mockError); diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 28c6922..6ac43c9 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -12,6 +12,7 @@ import type { ApprovalRequestData, MfaChallenge, TotpSetupData } from '@/lib/mfa import { clearAllAppData, LOGOUT_PRESERVED_STORAGE_KEYS } from '@/lib/storage/clear-all-data'; import { externalTokenRequest, loginRequest, refreshTokenRequest } from '../../lib/auth/api'; +import { runSignOutHooks } from '../../lib/auth/sign-out-hooks'; import type { AuthResponse, AuthStatus, ExternalTokenCredentials, LoginCredentials } from '../../lib/auth/types'; import { type ProfileModel } from '../../lib/auth/types'; import { getAuth } from '../../lib/auth/utils'; @@ -433,6 +434,17 @@ const useAuthStore = create()( }); } + // Whatever must still reach the server as this session (this browser's push token) goes first: the + // token is cleared below. Bounded, so a dead server never holds sign-out up. + try { + await runSignOutHooks(currentState.accessToken); + } catch (error) { + logger.warn({ + message: 'A sign-out hook failed', + context: { error, reason }, + }); + } + // Clear persisted authResponse to prevent re-hydration of signed-in session try { await removeItem('authResponse'); diff --git a/yarn.lock b/yarn.lock index 63bf5c8..1c47d8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1801,6 +1801,400 @@ chalk "^4.1.0" js-yaml "^4.1.0" +"@firebase/ai@2.16.0": + version "2.16.0" + resolved "https://registry.yarnpkg.com/@firebase/ai/-/ai-2.16.0.tgz#2e06e8861b6b23329a3a995aec892a70a3bc73e3" + integrity sha512-WRVxl58B61Orwf7st7949ZIW2L4huV80Gpaum1z8v2eH+m2xaxWtC8mOjQyVAOirr6O/yf89cBjlPvoyBpBfTg== + dependencies: + "@firebase/app-check-interop-types" "0.3.5" + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/analytics-compat@0.2.31": + version "0.2.31" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.31.tgz#ab6096c511b8edb051dc9f9e65316f2762885839" + integrity sha512-msTlG9REujk5rjtOHNLngiuPmvaWGQte8ugL4/F6ZCMb+92Ue7SgjSEpNxINkM8ZtTrbGS83j5SZkt0r+M1Tjw== + dependencies: + "@firebase/analytics" "0.10.25" + "@firebase/analytics-types" "0.8.5" + "@firebase/component" "0.7.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/analytics-types@0.8.5": + version "0.8.5" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.5.tgz#52717f737bc42323868176d5d5908cff3a9f1222" + integrity sha512-kdnooE7Bis2jEnsqcerRwn/UQpH5D3uvHpku7OdUM9TJN3omlu6iYtbyAQ6XkAJRgJtO0aNf9OOkW8J6D59oCA== + +"@firebase/analytics@0.10.25": + version "0.10.25" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.25.tgz#03a816e7b913703108384f28a6f3a9b4f9fd44de" + integrity sha512-oDagV3PHeNXBdxemksZCdj+GmUT19eCnTOcLdQePIIovFXlm3zWy3Gjeaokt9Nqx24kc4ljK8lU0XGcGmmKZ1A== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/installations" "0.6.24" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/app-check-compat@0.4.7": + version "0.4.7" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.4.7.tgz#5358fb2e3444e09b029c924ef5da80d98939da6c" + integrity sha512-fBb/xSMyIKv1nDmccfzz3IAGBzx/cQOxmZai66rJzifb1hMMkztf824Xx7LhpTUe7HNUbXwY90IvJ66fi8q6yg== + dependencies: + "@firebase/app-check" "0.13.1" + "@firebase/app-check-types" "0.5.5" + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/app-check-interop-types@0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.5.tgz#b017c5181c8a890c8b8b97981279777c7cabddd2" + integrity sha512-qId34pVZ2CXTmtu4ofW5leiI93DvnOvIcr/5GT7MZPw5WXAi6nZoU+g9cNRgl7K90UJSbK0cXxten4meYMPyZg== + +"@firebase/app-check-types@0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.5.tgz#e7fabc29e60187f6c9fac0586b1f1807795e2758" + integrity sha512-+DF4gzFrlwGFyky38o4T/YN/r51l70yCtJ2HTkwmRj8FCMwPjm4hLH4fQbj6BUvzkiFqliBkitFsRpf1/YZkWA== + +"@firebase/app-check@0.13.1": + version "0.13.1" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.13.1.tgz#297720a1b8dfdbf29bc025b5508eb3cc8760f770" + integrity sha512-l8y3dmnhodXks/APAwx4tWqRl3tk8b9874KF1FJyKg1DIU+kMD0l52iz7S9/MW+eK8k6cjJg0G0iJ5KQAsQpow== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/app-compat@0.5.18": + version "0.5.18" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.5.18.tgz#a04aeb47a9c8a367bc639e0d22e3202a378dcf6f" + integrity sha512-77H57WuGEsgrv59HdtuhHG+eQdP8di6myz7O93yW6yTZlR/tLSmsCxCq5vHp6AWWDwSV0EVtKUvizXQiQZVBWA== + dependencies: + "@firebase/app" "0.16.2" + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/app-types@0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.6.tgz#22fd1a59bd2c2888ffb569139132f14b4f1bfe50" + integrity sha512-yPLahy7Esfu2w/yme3msVK4xTkDXQqq6szfQn8yVOQpCKiT5GVFjqNpLbuz6NkX0WuTwUidkmPewW3r4xkvpeg== + dependencies: + "@firebase/logger" "0.5.2" + +"@firebase/app@0.16.2": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.16.2.tgz#0466614f627fb42739bce4cd832f8a00b60485fd" + integrity sha512-fyCLPahl3r0Zb7Wzm+JLWcOOH3SaDgAyJzJ8EWIDiv51aNiv+2Pjbpa6myEuP7iXxjmJqrcbVm0GUF7h229T1Q== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/auth-compat@0.6.11": + version "0.6.11" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.6.11.tgz#2b4494b7871e86aa90b9314afbfe1bb41d43ef39" + integrity sha512-L+xp3DTJQrBV08Vt0UhcL/ofUOSjXom7JOJfbT6oas4o7gCrQnTYFKs8wfiuKLmHoo8BxMr5LdgjAs7o6aAsqQ== + dependencies: + "@firebase/auth" "1.13.6" + "@firebase/auth-types" "0.13.2" + "@firebase/component" "0.7.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/auth-interop-types@0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.6.tgz#6754d1ee8f1c5976d37849ff09d9e0b681dabb08" + integrity sha512-FgwZqDrBqgK0BHI70QTv1v/5wmuDF9f8fsJFvKSxoRlK2PPfSQtZAk2jhXu4pe9BZzFi/e4UYusU/bEQHdf6Qg== + +"@firebase/auth-types@0.13.2": + version "0.13.2" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.13.2.tgz#9dbf431cf1dd0302da36592e8c744b878101ad02" + integrity sha512-OU+miuoSxIWYN7GT291d2ylVJBL3k/OePo7/JDSoQtkFQoEiGBc4AHuMjj/seqFIsHrqnjrAfRCk4pfufoJolQ== + +"@firebase/auth@1.13.6": + version "1.13.6" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-1.13.6.tgz#bfa69319d7b7922af32959a6854c3e139b65646a" + integrity sha512-MKRLvF72HJ/irNn2fJR0Gv5FOZNtqv1IBeJHRwK8l8pZ/wi9vuUw8ecyCMvQDDNxfS6uW0MWACL5KC4Y8Pp+fA== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/component@0.7.5": + version "0.7.5" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.7.5.tgz#07c90c38f7c227df469278664e50b375a3c9f371" + integrity sha512-vuFDcL91Q+2ZuBJkyOh86T4q0B4ffNTDjc/A38tybO56odQABxRTFLTIowCWqAKeIcgo37GowWMVgFF73gD8Qw== + dependencies: + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/data-connect@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@firebase/data-connect/-/data-connect-0.7.4.tgz#4a5b3f32ee922a07b07c6645eb47607005cc7e02" + integrity sha512-su1aGWlzhxb+xtggCUSsufJn1FDa06SBDK71y+fpQ+g2zMhMExik6FUv/odkKzOF/xfWVjabCbWBcjJY3MceDA== + dependencies: + "@firebase/auth-interop-types" "0.2.6" + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/database-compat@2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-2.1.7.tgz#ef4bdae0a368cfa4b7fa48599223bbdfba4386fc" + integrity sha512-lBq9sJm8MnJINKJnkAKSOj2MbC66xGoSVCcGkPtstl+lkoKEBtD46qHLbuDgW/WbLPoKVm17RIN8BxHj+Z2F2w== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/database" "1.1.5" + "@firebase/database-types" "1.0.22" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/database-types@1.0.22": + version "1.0.22" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-1.0.22.tgz#191314740bff07a90df09254aaab61638e15564b" + integrity sha512-YAZNXsjY9EQQ+pKw/3ax8n5FgolHC7Qew7EY5RceYdl0R2ZP+kCv1O0DkKlcceue8uQCOreHCNN7PWVFpY1Nug== + dependencies: + "@firebase/app-types" "0.9.6" + "@firebase/util" "1.15.3" + +"@firebase/database@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-1.1.5.tgz#84984ee38dd1640ef211380b8373e910cb9ceb34" + integrity sha512-/JGpvszLoNXNgzilRXocigGfFF4hbcPA9wN1i1kjJx6oKkXgkHteZYl3lQs1lJX3ETDf6bv7zI15lPMVUp4sAQ== + dependencies: + "@firebase/app-check-interop-types" "0.3.5" + "@firebase/auth-interop-types" "0.2.6" + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.4.14": + version "0.4.14" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.4.14.tgz#7f4b42fbc923c2a6e7dd1d48791380a7f4db7dfa" + integrity sha512-HJ2HBNgNPrRsWIJ4E+Kc0u0h1U7HlF7If8AtYjie9HZXlMUmwYSJaXBj9VFm6gkSj5dlKZNcOQ0Ou25CVVG6rA== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/firestore" "4.17.2" + "@firebase/firestore-types" "3.0.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/firestore-types@3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.5.tgz#bcec2047e246e31f6af475feb7e1cda6f413416b" + integrity sha512-dbdMAQkMd5dwWc48eupz/Y6/E9ruat3+gY5lhVKscvvT/HnDBEEMzJW38zdKhgdnglZHGk2vUsJMOHAphMHGMA== + +"@firebase/firestore@4.17.2": + version "4.17.2" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.17.2.tgz#02f571b31cc58833d434197ec0a7ddc4ec8e5b38" + integrity sha512-SiyQEK1dYPOjItFVA+kZGdvOoRAtMebDBvUaSxJPNZMkKcRwYYK2kzWo5UXTZZj523uMxJnkr+KKVySEMme3kQ== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + "@firebase/webchannel-wrapper" "1.0.7" + "@grpc/grpc-js" "~1.9.0" + "@grpc/proto-loader" "^0.7.8" + re2js "^2.8.3" + tslib "^2.1.0" + +"@firebase/functions-compat@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.5.0.tgz#765885e563e748afe397999ef49bb2a6f2013099" + integrity sha512-T3BDIToESZUHt7438wyKYMkRjKg3m1xAIux5fVpNvTRQlDOFLukWniQWBDhJ2znpU9kxMTR6+e31TVo8P15PZw== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/functions" "0.14.0" + "@firebase/functions-types" "0.6.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/functions-types@0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.5.tgz#bfbc7e385ec448465043e8d748d971a3be0b3e1d" + integrity sha512-Zc0pURjthHXzSj54ZivCkzKDSV1r/wIpnmHdhq82q2yFFPVoncG/ZJjnVMiANvQfeww/QnElhzODpfwUucVwdA== + +"@firebase/functions@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.14.0.tgz#2e96bb04edbfedb8eba25c29ba6d354ea0aece88" + integrity sha512-DhuYFr0eMhp+s/PNEk6SiMsYkc00+XVOUeKbrl8MOzQNZI3SKQpqoDR1d+keNDAutwmHRWTUAdXBoVPD+xxVUw== + dependencies: + "@firebase/app-check-interop-types" "0.3.5" + "@firebase/auth-interop-types" "0.2.6" + "@firebase/component" "0.7.5" + "@firebase/messaging-interop-types" "0.2.6" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/installations-compat@0.2.24": + version "0.2.24" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.24.tgz#f7073cd7123b47152292430954e6614867fb60e1" + integrity sha512-8M5nlcWwYt881x83COP2odq5vgf+NgwJh+RMd4LRSv8JI1pxwDfgrDJOERrjTgfC9J5Z0vnQX4b1pdN914e0Zw== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/installations" "0.6.24" + "@firebase/installations-types" "0.5.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/installations-types@0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.5.tgz#92738c67b72aec95b1216d625f7904907a3c5d91" + integrity sha512-e9UYcju3puDl1vdrcKIi5dExzHLameOT/Tc61Q48PYwxtsM1NzZh/ikGbdBQTsbRgg0EMZqdPr0/m5ODUBobrg== + +"@firebase/installations@0.6.24": + version "0.6.24" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.24.tgz#790ac5cae6ef83541b19a71bf14e1047c1b1af7a" + integrity sha512-Ui52ey8wHoWqkBbXRKJEKYWylI0JZogZmoLS+o8Anh1bxWtK27ZYjLla1UJAAdC5DCKLJ2qAp0VpJOjg8VOX1g== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/util" "1.15.3" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/logger@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.5.2.tgz#27053c3c80897cb134f39242f50e34f5d0b1cbf4" + integrity sha512-J2VO4NFTc0xQFrxV1B/lm5balicm9cwuX2acR9Yn41fN8KgUeQFo+VJV222IqW2FPSXKDu9uo5WdQFWf9TPbYg== + dependencies: + tslib "^2.1.0" + +"@firebase/messaging-compat@0.2.30": + version "0.2.30" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.30.tgz#a1d51d5143fb9edb44f828b7f9eb227d58b77309" + integrity sha512-m1DPT2ET5x0qYqXLoHwKMAyf1mgZZOhuoE6H6/H4OPsp8zSxMIDEFPaW7MaffPJsCA5J7kHzo+BHVNXK4ZoV+w== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/messaging" "0.13.3" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/messaging-interop-types@0.2.6": + version "0.2.6" + resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.6.tgz#c98f1658467749b8a31e4069bf67b1e7b949c68f" + integrity sha512-MVzvkKe2V4H2dHu5oOxRfeKQcfTwWmCgnzsC4V1q3ixun5iiL8riCZ2qI35rDhCL4glPGiu0jHxDbpVNuTKfow== + +"@firebase/messaging@0.13.3": + version "0.13.3" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.13.3.tgz#725142b6fd3f0fe6405526d6e020fe04b744a83d" + integrity sha512-nRXPAp+z0kA3KziJtRY9kktHECvRwaaONzHvislFgllhCwJMscW7icWNQA5IjyP8Uc2+GS/4px8Uz6rx6ftTag== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/installations" "0.6.24" + "@firebase/messaging-interop-types" "0.2.6" + "@firebase/util" "1.15.3" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/performance-compat@0.2.27": + version "0.2.27" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.27.tgz#e8fdc5749eac6728f66b92a32da362faa46886b5" + integrity sha512-O/ozTf/EbChN94Pk7bd1eUC0PAC36726AwsaiJyC0bZzSBWfLGSWASGPH5pebUWXQPJtGxIJYRkkbX5l0Qa+Hw== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/performance" "0.7.14" + "@firebase/performance-types" "0.2.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/performance-types@0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.5.tgz#8b43ddc73a072b5c78c14d593cc3351b67c26c5e" + integrity sha512-PRzOgB+/M+6AKlEkY8a9xy5Ff5SfZPIh4iShXhn2WAcL/euSbK7bdLqWysHduunNJhGFsXa22Ag3ixqL6rQtYg== + +"@firebase/performance@0.7.14": + version "0.7.14" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.7.14.tgz#34b2f12d8922b6c2557f2030140295c0433ada4b" + integrity sha512-9PH1XEZVHErxGdbXluvGz4Uyjw4W955H8v7Mv9rHIybRrg5F2Ac2tvf5+mSf5EtnxWNmxrD2WLnvMFaZP4sMrQ== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/installations" "0.6.24" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + web-vitals "^4.2.4" + +"@firebase/remote-config-compat@0.2.29": + version "0.2.29" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.29.tgz#263d73a64b576797099ac58ceebe9425153c2b0f" + integrity sha512-mY7JtTISK6F4g4T0x3kVepr5cp0NXL7f5yjIUXXGaTrg4qUlFBWRbENaHaqZ78dwmNcLm24h/yPsOVRlDXEXhA== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/logger" "0.5.2" + "@firebase/remote-config" "0.9.2" + "@firebase/remote-config-types" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/remote-config-types@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.5.2.tgz#6c1af391603b0dd1180ca0fc367a6ffbb69ce41e" + integrity sha512-i8k1omVfoAnaT1ZPv2FFjxMZrATYsO/GnFgHEj5+7fAJLzi8wLnGGv1WK06oEAD6ltrWXSO1HzeNyajn/NjMWw== + +"@firebase/remote-config@0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.9.2.tgz#c54fd1bdf709d00e42577733e2776db94b0a3150" + integrity sha512-Rii93DkXjM+RE/ytHdYa7EIiQLJbdBr+W8EEiqd/vbLCOC+1FdkDuRiumJBp6t3yewHGbdqbpjB3fk3z0cZ+8g== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/installations" "0.6.24" + "@firebase/logger" "0.5.2" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/storage-compat@0.4.5": + version "0.4.5" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.4.5.tgz#6f9a34e81f088107653907767c457ade1af47a09" + integrity sha512-vO0tFPxXbKDKdlTu8tYT08S9t9ezUTJYEdLCULpWxWq2aq6zojwN1QmAB+1a50AI/g47GlWUJn1XPncm/PDZsw== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/storage" "0.14.5" + "@firebase/storage-types" "0.8.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/storage-types@0.8.5": + version "0.8.5" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.5.tgz#fcfac15c7ad60e655419891912ce9a649bc4eee5" + integrity sha512-GEDs5P+rNUfNcS+wxIdOLAHficife2YXtvTJnyi3ssrX11AAtBg+nDUdbYh8vuBzWehVQZPmztGUUnud4L+4yg== + +"@firebase/storage@0.14.5": + version "0.14.5" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.14.5.tgz#d74baa4596f699c3f999740d914990f86a72958f" + integrity sha512-r2tozN/BlEewLi70tJNUQPzWwbex9GM7NgXZsamHKQrjKEfcR0i4jGgHBKAKA4hbwiPE9eNMb3hcxWnDpeJZwg== + dependencies: + "@firebase/component" "0.7.5" + "@firebase/util" "1.15.3" + tslib "^2.1.0" + +"@firebase/util@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.15.3.tgz#396def73437c95ca9373945e6137caf86286c58a" + integrity sha512-c/z/gaIlaaLZEuGbE6sLUuJ61tskg1JghvhcNQzW948ASBinbVBBRnZTC4b4yt4LaEtJQkYlyzqLHcutFwEIvA== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.7.tgz#45a201ad457323f8fda59c17cc4be11c8908cd4a" + integrity sha512-phBFwieDLvkZGYN9CE9ZFNEIoBVksprzsnCzQejCmCHtgwCXReeuRpoEGN9C4EbhONztv8NRV1tau6Rb9pONwQ== + "@floating-ui/core@^1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.2.tgz#3d1c35263950b314b6d5a72c8bfb9e3c1551aefd" @@ -1952,6 +2346,24 @@ dependencies: nanoid "^3.3.1" +"@grpc/grpc-js@~1.9.0": + version "1.9.16" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.16.tgz#614f85036ac8e3c957374c1bd1ebb05934a79a1c" + integrity sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ== + dependencies: + "@grpc/proto-loader" "^0.7.8" + "@types/node" ">=12.12.47" + +"@grpc/proto-loader@^0.7.8": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + "@hookform/resolvers@~3.9.0": version "3.9.1" resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.1.tgz#a23883c40bfd449cb6c6ab5a0fa0729184c950ff" @@ -2888,6 +3300,53 @@ "@pnpm/network.ca-file" "^1.0.1" config-chain "^1.1.11" +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + +"@protobufjs/eventemitter@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz#d512cb26c0ae026091ee2c1167f1be6faf5c842a" + integrity sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg== + +"@protobufjs/fetch@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.1.tgz#4d6fc00c8fb64016a5c81b469d549046350f1065" + integrity sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.2.tgz#78d476333d85d5b1c792e257bca74ba080da49a4" + integrity sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug== + "@radix-ui/primitive@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba" @@ -4373,6 +4832,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708" integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g== +"@types/node@>=12.12.47", "@types/node@>=13.7.0": + version "26.6.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.4.tgz#5aaac1a7337945f7f9a2de4e0d14e7107157e813" + integrity sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg== + dependencies: + undici-types "~8.9.0" + "@types/normalize-package-data@^2.4.0", "@types/normalize-package-data@^2.4.3": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" @@ -7718,6 +8184,13 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +faye-websocket@0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + fb-dotslash@0.5.8: version "0.5.8" resolved "https://registry.yarnpkg.com/fb-dotslash/-/fb-dotslash-0.5.8.tgz#c5ef3dacd75e1ddb2197c367052464ddde0115f5" @@ -7885,6 +8358,40 @@ find-yarn-workspace-root@^2.0.0: dependencies: micromatch "^4.0.2" +firebase@^12.19.0: + version "12.19.0" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-12.19.0.tgz#08cefc7c12cdc0dad129e68a6d938be3bccf036f" + integrity sha512-kwXCLcI0ly2lkwygkbuRx6SsX3DSO96355YrWh9SWZ5ncsxK/y5cirn3sY6nZVSiBVE++2nL6Hchzu1EB8uohQ== + dependencies: + "@firebase/ai" "2.16.0" + "@firebase/analytics" "0.10.25" + "@firebase/analytics-compat" "0.2.31" + "@firebase/app" "0.16.2" + "@firebase/app-check" "0.13.1" + "@firebase/app-check-compat" "0.4.7" + "@firebase/app-compat" "0.5.18" + "@firebase/app-types" "0.9.6" + "@firebase/auth" "1.13.6" + "@firebase/auth-compat" "0.6.11" + "@firebase/data-connect" "0.7.4" + "@firebase/database" "1.1.5" + "@firebase/database-compat" "2.1.7" + "@firebase/firestore" "4.17.2" + "@firebase/firestore-compat" "0.4.14" + "@firebase/functions" "0.14.0" + "@firebase/functions-compat" "0.5.0" + "@firebase/installations" "0.6.24" + "@firebase/installations-compat" "0.2.24" + "@firebase/messaging" "0.13.3" + "@firebase/messaging-compat" "0.2.30" + "@firebase/performance" "0.7.14" + "@firebase/performance-compat" "0.2.27" + "@firebase/remote-config" "0.9.2" + "@firebase/remote-config-compat" "0.2.29" + "@firebase/storage" "0.14.5" + "@firebase/storage-compat" "0.4.5" + "@firebase/util" "1.15.3" + flat-cache@^3.0.4: version "3.2.0" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" @@ -8438,6 +8945,11 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http-parser-js@>=0.5.1: + version "0.5.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" + integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -8499,6 +9011,11 @@ iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +idb@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" + integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== + ieee754@^1.1.12, ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -10280,6 +10797,11 @@ loglevel@^1.8.0, loglevel@^1.9.2: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== +long@^5.0.0, long@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -11930,6 +12452,23 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== +protobufjs@^7.2.5: + version "7.6.6" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.6.6.tgz#7a3923e8e32b0ee2ff689f8eed6e455c090ac67e" + integrity sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.1" + "@protobufjs/fetch" "^1.1.1" + "@protobufjs/float" "^1.0.2" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.3.2" + protocol-buffers-schema@^3.3.1: version "3.6.1" resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz#fd9a58a5c4e96385b964808f3ddd58f9ef18c3c8" @@ -12018,6 +12557,11 @@ rc@1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +re2js@^2.8.3: + version "2.8.6" + resolved "https://registry.yarnpkg.com/re2js/-/re2js-2.8.6.tgz#acbd17a1ad0e98c1f2ee0a2adc17ba0903d9ca95" + integrity sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg== + react-aria-components@1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/react-aria-components/-/react-aria-components-1.19.0.tgz#202394140728e2ef0ff9652276e9e12a81cccda6" @@ -12837,7 +13381,7 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@5.2.1, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -14116,6 +14660,11 @@ undici-types@~7.8.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294" integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw== +undici-types@~8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" + integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== + undici@^6.22.0: version "6.28.0" resolved "https://registry.yarnpkg.com/undici/-/undici-6.28.0.tgz#9f0e385744fef5021d6596c5bccd783f61193c1c" @@ -14393,6 +14942,11 @@ web-streams-polyfill@^4.3.0: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.3.0.tgz#2f5fe004e2c53f09f7e03aef25b892953cab99a6" integrity sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw== +web-vitals@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" + integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -14415,6 +14969,20 @@ webrtc-adapter@9.0.6: dependencies: sdp "^3.2.0" +websocket-driver@>=0.5.1: + version "0.7.5" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.5.tgz#569d22764ab21f2de20af0e74b411e8ae5a0fa46" + integrity sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + well-known-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/well-known-symbols/-/well-known-symbols-4.1.0.tgz#7f748817d7bfdd4a95395127a056ce5205910624" @@ -14745,7 +15313,7 @@ yargs@^17.0.0, yargs@^17.6.2: y18n "^5.0.5" yargs-parser "^21.1.1" -yargs@^17.3.1: +yargs@^17.3.1, yargs@^17.7.2: version "17.7.3" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== From b47ca2207728a182d77a1e2a916e493f4ce6123c Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 4 Oct 2026 10:15:35 -0700 Subject: [PATCH 3/3] RG-T139 PR #154 fixes --- public/service-worker.js | 2 +- .../__tests__/location-history-panel.test.tsx | 11 +++++++ .../calls/location-history-panel.tsx | 17 +++++----- src/services/__tests__/web-push.web.test.ts | 31 +++++++++++++++++-- src/services/web-push.web.ts | 19 +++++++++--- .../__tests__/location-history-store.test.ts | 22 +++++++++++++ .../calls/__tests__/site-info-store.test.ts | 18 +++++++++++ src/stores/calls/location-history-store.ts | 6 ++-- src/stores/calls/site-info-store.ts | 4 ++- .../contacts/__tests__/preplan-store.test.ts | 29 +++++++++++++++++ src/stores/contacts/preplan-store.ts | 13 ++++++-- 11 files changed, 153 insertions(+), 19 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index 7ba348a..953f3e2 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -21,7 +21,7 @@ function readPush(event) { if (event.data) { try { payload = event.data.json() || {}; - } catch (e) { + } catch { payload = { notification: { body: event.data.text() } }; } } diff --git a/src/components/calls/__tests__/location-history-panel.test.tsx b/src/components/calls/__tests__/location-history-panel.test.tsx index 650b5a2..2ece92d 100644 --- a/src/components/calls/__tests__/location-history-panel.test.tsx +++ b/src/components/calls/__tests__/location-history-panel.test.tsx @@ -113,6 +113,17 @@ describe('LocationHistoryPanel', () => { expect(mockTrackEvent).toHaveBeenCalledWith('location_history_viewed', expect.objectContaining({ kind: 'call', id: '42', callCount: 2 })); }); + it('counts the closing note in the collapsed notes label', async () => { + const closingOnly = { ...baseHistory.Calls[0], CallId: '7000', Notes: [] }; + mockCallHistory.mockResolvedValueOnce({ Data: { ...baseHistory, Calls: [closingOnly, baseHistory.Calls[0]] } } as never); + + render(); + + await waitFor(() => expect(screen.getByTestId('location-history-notes-toggle-7000')).toBeTruthy()); + expect(screen.getByText('location_history.show_notes:{"count":1}')).toBeTruthy(); + expect(screen.getByText('location_history.show_notes:{"count":2}')).toBeTruthy(); + }); + it('shows a lock instead of the REDACTED sentinel for protected call text', async () => { mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); diff --git a/src/components/calls/location-history-panel.tsx b/src/components/calls/location-history-panel.tsx index 166489f..6f4abd6 100644 --- a/src/components/calls/location-history-panel.tsx +++ b/src/components/calls/location-history-panel.tsx @@ -47,15 +47,18 @@ interface HistoryCallCardProps { onOpenCall: (callId: string) => void; } -const HistoryCallCard: React.FC = ({ call, onOpenCall }) => { +const HistoryCallCard: React.FC = React.memo(({ call, onOpenCall }) => { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); - const hasNotes = call.Notes.length > 0 || !!call.CompletedNotes; + // The closing note counts as one, so a call with only a closing note never reads "Notes (0)". + const noteCount = call.Notes.length + (call.CompletedNotes ? 1 : 0); const loggedOn = call.LoggedOn || formatTimestamp(call.LoggedOnUtc); + const handleOpen = useCallback(() => onOpenCall(call.CallId), [onOpenCall, call.CallId]); + const toggleNotes = useCallback(() => setExpanded((value) => !value), []); return ( - onOpenCall(call.CallId)} className="p-3" testID={`location-history-open-${call.CallId}`}> + @@ -95,12 +98,12 @@ const HistoryCallCard: React.FC = ({ call, onOpenCall }) = ) : null} - {hasNotes ? ( + {noteCount > 0 ? ( - setExpanded((value) => !value)} className="px-3 py-2" testID={`location-history-notes-toggle-${call.CallId}`}> + {expanded ? : } - {expanded ? t('location_history.hide_notes') : t('location_history.show_notes', { count: call.Notes.length })} + {expanded ? t('location_history.hide_notes') : t('location_history.show_notes', { count: noteCount })} {expanded ? ( @@ -125,7 +128,7 @@ const HistoryCallCard: React.FC = ({ call, onOpenCall }) = ) : null} ); -}; +}); /** * Previous calls at a location: on the call detail screen, other calls at the same address (however it was typed), nearby diff --git a/src/services/__tests__/web-push.web.test.ts b/src/services/__tests__/web-push.web.test.ts index 19defd2..3c807eb 100644 --- a/src/services/__tests__/web-push.web.test.ts +++ b/src/services/__tests__/web-push.web.test.ts @@ -4,12 +4,13 @@ * sign-out unregisters and kills the token, and another person or department in the browser rotates it. */ -const mockRegisterDevice = jest.fn(async () => ({})); +const mockRegisterDevice = jest.fn(async () => ({ Status: 'queued' })); const mockUnRegisterWebPush = jest.fn(async () => ({})); const mockOpenNotification = jest.fn(async () => undefined); const mockShowNotificationModal = jest.fn(async () => undefined); const mockFirebaseGetToken = jest.fn(async () => 'browser-token'); const mockFirebaseDeleteToken = jest.fn(async () => true); +const mockLoggerWarn = jest.fn(); jest.mock('firebase/app', () => ({ getApps: () => [], initializeApp: (_config: unknown, name: string) => ({ name }) })); jest.mock('firebase/messaging', () => ({ @@ -24,7 +25,7 @@ jest.mock('@/api/devices/push', () => ({ })); jest.mock('@/services/push-notification', () => ({ pushNotificationService: { openNotification: (...args: unknown[]) => mockOpenNotification(...(args as [])) } })); jest.mock('@/lib/storage/app', () => ({ getBaseApiUrl: () => 'https://api.test/api/v4', getDeviceUuid: () => 'device-uuid' })); -jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() } })); +jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: (...args: unknown[]) => mockLoggerWarn(...(args as [])), error: jest.fn(), debug: jest.fn() } })); jest.mock('@/lib/mfa/client-app', () => ({ CLIENT_HEADER: 'X-Resgrid-Client', RESGRID_CLIENT: 'responder' })); jest.mock('@/stores/push-notification/store', () => ({ usePushNotificationModalStore: { getState: () => ({ showNotificationModal: mockShowNotificationModal }) } })); @@ -217,6 +218,20 @@ describe('browser', () => { now.mockRestore(); }); + it('retries a registration the server refused instead of remembering it for a day', async () => { + permission = 'granted'; + const { push } = load(); + mockRegisterDevice.mockResolvedValueOnce({ Status: 'failure' }); + + await push.syncWebPush(); + expect(storage.has('rg.webPush.registration')).toBe(false); + expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ context: { status: 'failure' } })); + + await push.syncWebPush(); + expect(mockRegisterDevice).toHaveBeenCalledTimes(2); + expect(storage.has('rg.webPush.registration')).toBe(true); + }); + it('takes the device off the previous department and rotates the token before registering in the new one', async () => { permission = 'granted'; const { push } = load(); @@ -275,6 +290,18 @@ describe('browser', () => { expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); }); + it('reports a sign-out unregister the server refused and still kills the token', async () => { + permission = 'granted'; + const { push, hooks } = load(); + await push.syncWebPush(); + (globals.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 401 }); + + await hooks.runSignOutHooks('access-token'); + + expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ context: { status: 401 } })); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + }); + }); describe('clicks and foreground pushes', () => { diff --git a/src/services/web-push.web.ts b/src/services/web-push.web.ts index ac69617..f14459a 100644 --- a/src/services/web-push.web.ts +++ b/src/services/web-push.web.ts @@ -68,6 +68,8 @@ const ASKED_KEY = 'rg.webPush.asked'; const ASK_AGAIN_AFTER_MS = 7 * 24 * 60 * 60 * 1000; /** FCM rotates web tokens; re-registering daily keeps the server's copy current. */ const REREGISTER_AFTER_MS = 24 * 60 * 60 * 1000; +/** RegisterDevice answers 200 either way; only these statuses mean the registration was taken (it is queued). */ +const REGISTERED_STATUSES = ['queued', 'success']; const listeners = new Set<() => void>(); @@ -260,8 +262,13 @@ async function runSync(): Promise { await unregisterQuietly(current); } - await registerDevice({ UserId: identity.userId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getDeviceUuid() || '', Prefix: identity.prefix }); - writeRegistration({ key: keyOf(identity), userId: identity.userId, prefix: identity.prefix, token, registeredAt: Date.now() }); + const result = await registerDevice({ UserId: identity.userId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getDeviceUuid() || '', Prefix: identity.prefix }); + // Remembered only once the server took it: a refused registration left cached would skip the retry for a day. + if (REGISTERED_STATUSES.includes(result?.Status?.toLowerCase() ?? '')) { + writeRegistration({ key: keyOf(identity), userId: identity.userId, prefix: identity.prefix, token, registeredAt: Date.now() }); + } else { + logger.warn({ message: 'Web push: the server did not take the registration', context: { status: result?.Status } }); + } notify(); } @@ -316,12 +323,16 @@ registerSignOutHook(async (accessToken) => { if (stored && accessToken) { // Straight to the server, not through the api client: its 401 handling would re-enter logout. Awaited (sign-out // waits on it), so no keepalive: some browsers refuse keepalive on a cross-origin call that needs a preflight. + // fetch resolves on a 4xx/5xx; the token deleted below still stops delivery, but the refusal is worth seeing. try { - await fetch(`${getBaseApiUrl()}/Devices/UnRegisterWebPush`, { + const response = await fetch(`${getBaseApiUrl()}/Devices/UnRegisterWebPush`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, [CLIENT_HEADER]: RESGRID_CLIENT }, body: JSON.stringify({ Token: stored.token, Prefix: stored.prefix }), }); + if (!response.ok) { + logger.warn({ message: 'Web push: the server refused to unregister the token at sign-out', context: { status: response.status } }); + } } catch (error) { logger.warn({ message: 'Web push: the token could not be unregistered at sign-out', context: { error } }); } @@ -363,7 +374,7 @@ export function attachWebPushListeners(): void { const onMessage = (event: MessageEvent) => { const message = event.data as { type?: string; data?: WebPushPayload } | undefined; if (message?.type === 'NOTIFICATION_CLICK' && message.data) { - void openWebPush(message.data); + openWebPush(message.data).catch((error) => logger.warn({ message: 'Web push: the clicked notification could not be opened', context: { error } })); } else if (message?.type === 'PUSH_RECEIVED' && message.data) { onPushReceived(message.data); } diff --git a/src/stores/calls/__tests__/location-history-store.test.ts b/src/stores/calls/__tests__/location-history-store.test.ts index 43b86b3..7d0977e 100644 --- a/src/stores/calls/__tests__/location-history-store.test.ts +++ b/src/stores/calls/__tests__/location-history-store.test.ts @@ -68,6 +68,28 @@ describe('useLocationHistoryStore', () => { expect(useLocationHistoryStore.getState().entries['call:42'].history?.Calls[0].CallId).toBe('new'); }); + it('drops the history it replaces as soon as a re-fetch starts, so a withdrawn grant reveals nothing meanwhile', async () => { + const pending = deferred(); + mockCallHistory.mockResolvedValueOnce({ Data: history(['revealed']) } as never).mockReturnValueOnce(pending.promise); + + await act(async () => { + await useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + }); + expect(useLocationHistoryStore.getState().entries['call:42'].history?.Calls).toHaveLength(1); + + let refetch!: Promise; + act(() => { + refetch = useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + }); + expect(useLocationHistoryStore.getState().entries['call:42']).toEqual({ history: null, isLoading: true, error: null }); + + await act(async () => { + pending.resolve({ Data: history(['redacted']) } as never); + await refetch; + }); + expect(useLocationHistoryStore.getState().entries['call:42'].history?.Calls[0].CallId).toBe('redacted'); + }); + it('records an error and clears the history when the request fails', async () => { mockCallHistory.mockRejectedValueOnce(new Error('boom')); diff --git a/src/stores/calls/__tests__/site-info-store.test.ts b/src/stores/calls/__tests__/site-info-store.test.ts index 4c38085..01eee15 100644 --- a/src/stores/calls/__tests__/site-info-store.test.ts +++ b/src/stores/calls/__tests__/site-info-store.test.ts @@ -133,6 +133,24 @@ describe('useSiteInfoStore', () => { expect(useSiteInfoStore.getState().siteInfo).toBe(concealed); }); + it('drops the site info it replaces as soon as a re-fetch starts, so a withdrawn grant reveals nothing meanwhile', async () => { + const pending = deferred(); + mockGetCallSiteInfo.mockResolvedValueOnce({ Data: makeSiteInfo('42') } as any); + mockGetCallSiteInfo.mockImplementationOnce(() => pending.promise); + + await useSiteInfoStore.getState().fetchSiteInfo('42'); + expect(useSiteInfoStore.getState().siteInfo?.CallId).toBe('42'); + + const refetch = useSiteInfoStore.getState().fetchSiteInfo('42'); + expect(useSiteInfoStore.getState().siteInfo).toBeNull(); + expect(useSiteInfoStore.getState().isLoading).toBe(true); + + const concealed = { ...makeSiteInfo('42'), IsProtected: true }; + pending.resolve({ Data: concealed }); + await refetch; + expect(useSiteInfoStore.getState().siteInfo).toBe(concealed); + }); + it('drops a response that arrives after reset', async () => { const pending = deferred(); mockGetCallSiteInfo.mockImplementationOnce(() => pending.promise); diff --git a/src/stores/calls/location-history-store.ts b/src/stores/calls/location-history-store.ts index e67542e..c300ce5 100644 --- a/src/stores/calls/location-history-store.ts +++ b/src/stores/calls/location-history-store.ts @@ -27,7 +27,9 @@ interface LocationHistoryState { export const locationHistoryKey = (source: LocationHistorySource) => `${source.kind}:${source.id}`; // Only the newest request for a key may write. Panels re-fetch the same source when the protected-data grant changes, so -// an answer from before a grant change (revealed or REDACTED) must never land after the newer one. +// an answer from before a grant change (revealed or REDACTED) must never land after the newer one. For the same reason a +// re-fetch drops the history it replaces: what was revealed under a grant since withdrawn must not stay on screen while +// (or if never) the newer answer arrives. const latestRequests: Record = {}; let sequence = 0; @@ -42,7 +44,7 @@ export const useLocationHistoryStore = create((set) => ({ const key = locationHistoryKey(source); const request = ++sequence; latestRequests[key] = request; - set((state) => ({ entries: { ...state.entries, [key]: { history: state.entries[key]?.history ?? null, isLoading: true, error: null } } })); + set((state) => ({ entries: { ...state.entries, [key]: { history: null, isLoading: true, error: null } } })); try { const result = source.kind === 'call' ? await getCallLocationHistory(source.id) : await getContactCallHistory(source.id); diff --git a/src/stores/calls/site-info-store.ts b/src/stores/calls/site-info-store.ts index d10241a..f4814f8 100644 --- a/src/stores/calls/site-info-store.ts +++ b/src/stores/calls/site-info-store.ts @@ -16,6 +16,8 @@ interface SiteInfoState { // Only the newest request may write. The tab re-fetches the same call when the grant changes, so a // response for an older request (revealed before a grant loss, redacted before an unlock) is dropped. +// For the same reason a fetch drops the site info it replaces: what was revealed under a grant since +// withdrawn must not stay on screen while (or if never) the newer answer arrives. let latestRequest = 0; /** @@ -31,7 +33,7 @@ export const useSiteInfoStore = create((set) => ({ fetchSiteInfo: async (callId: string) => { const request = ++latestRequest; - set({ isLoading: true, error: null, callId }); + set({ siteInfo: null, isLoading: true, error: null, callId }); try { const result = await getCallSiteInfo(callId); if (request !== latestRequest) { diff --git a/src/stores/contacts/__tests__/preplan-store.test.ts b/src/stores/contacts/__tests__/preplan-store.test.ts index 939ea5a..d8ed3fc 100644 --- a/src/stores/contacts/__tests__/preplan-store.test.ts +++ b/src/stores/contacts/__tests__/preplan-store.test.ts @@ -97,6 +97,35 @@ describe('useContactPreplanStore', () => { expect(state.loadingFiles.c1).toBeFalsy(); }); + it('drops the cached pre-plan and files as soon as a forced re-fetch starts, so a withdrawn grant reveals nothing meanwhile', async () => { + mockGetContactPreplan.mockResolvedValueOnce({ Data: preplan('North door') } as never); + mockGetContactFiles.mockResolvedValueOnce({ Data: files('floor-plan') } as never); + await useContactPreplanStore.getState().fetchPreplan('c1'); + await useContactPreplanStore.getState().fetchFiles('c1'); + + const preplanRequest = deferred<{ Data: ContactPreplanData }>(); + const filesRequest = deferred<{ Data: ContactFileResultData[] }>(); + mockGetContactPreplan.mockImplementationOnce(() => preplanRequest.promise as never); + mockGetContactFiles.mockImplementationOnce(() => filesRequest.promise as never); + const preplanFetch = useContactPreplanStore.getState().fetchPreplan('c1', true); + const filesFetch = useContactPreplanStore.getState().fetchFiles('c1', true); + + let state = useContactPreplanStore.getState(); + expect(Object.prototype.hasOwnProperty.call(state.preplans, 'c1')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(state.files, 'c1')).toBe(false); + expect(state.loadingPreplan.c1).toBe(true); + expect(state.loadingFiles.c1).toBe(true); + + preplanRequest.resolve({ Data: preplan('REDACTED') }); + filesRequest.reject(new Error('offline')); + await Promise.all([preplanFetch, filesFetch]); + + state = useContactPreplanStore.getState(); + expect(state.preplans.c1).toEqual(preplan('REDACTED')); + // A failed re-fetch leaves nothing behind rather than the files revealed before it. + expect(Object.prototype.hasOwnProperty.call(state.files, 'c1')).toBe(false); + }); + it('serves the cached pre-plan without refetching unless forced', async () => { mockGetContactPreplan.mockResolvedValue({ Data: preplan('North door') } as never); diff --git a/src/stores/contacts/preplan-store.ts b/src/stores/contacts/preplan-store.ts index 94f8fe1..05eaac8 100644 --- a/src/stores/contacts/preplan-store.ts +++ b/src/stores/contacts/preplan-store.ts @@ -22,10 +22,19 @@ interface ContactPreplanState { // Only the newest request per contact may write. A forced re-fetch after a grant change races the one // before it, and the older answer (revealed before a grant loss, redacted before an unlock) must lose. +// For the same reason a re-fetch drops the cached entry it replaces: what was revealed under a grant +// since withdrawn must not stay on screen while (or if never) the newer answer arrives. let requestCounter = 0; const latestPreplanRequest = new Map(); const latestFilesRequest = new Map(); +/** The record without this contact's entry; removed rather than nulled, since a null pre-plan means "has none". */ +const without = (record: Record, contactId: string): Record => { + const rest = { ...record }; + delete rest[contactId]; + return rest; +}; + /** * Pre-plan and site-file cache for the contact details sheet (Contacts plan Phase A). Cached per contact * for the life of the sheet; `force` re-fetches after a step-up so REDACTED values are replaced. @@ -43,7 +52,7 @@ export const useContactPreplanStore = create((set, get) => const request = ++requestCounter; latestPreplanRequest.set(contactId, request); - set((state) => ({ loadingPreplan: { ...state.loadingPreplan, [contactId]: true }, error: null })); + set((state) => ({ preplans: without(state.preplans, contactId), loadingPreplan: { ...state.loadingPreplan, [contactId]: true }, error: null })); try { const result = await getContactPreplan(contactId); if (latestPreplanRequest.get(contactId) !== request) return; @@ -67,7 +76,7 @@ export const useContactPreplanStore = create((set, get) => const request = ++requestCounter; latestFilesRequest.set(contactId, request); - set((state) => ({ loadingFiles: { ...state.loadingFiles, [contactId]: true }, error: null })); + set((state) => ({ files: without(state.files, contactId), loadingFiles: { ...state.loadingFiles, [contactId]: true }, error: null })); try { const result = await getContactFiles(contactId, false); if (latestFilesRequest.get(contactId) !== request) return;