From 857e92501803de5d712dd064cc9f3319efcbc1f9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 3 Oct 2026 22:45:20 -0700 Subject: [PATCH 1/4] RU-T53 Fix for Call location history --- .../__tests__/callLocationHistory.test.ts | 42 +++ src/api/calls/callLocationHistory.ts | 15 ++ .../__tests__/contactCallHistory.test.ts | 33 +++ src/api/contacts/contactCallHistory.ts | 14 + src/app/call/[id].tsx | 10 + src/app/call/__tests__/[id].security.test.tsx | 1 + src/app/call/__tests__/[id].test.tsx | 1 + .../__tests__/location-history-panel.test.tsx | 160 ++++++++++++ .../calls/location-history-panel.tsx | 245 ++++++++++++++++++ .../contacts/contact-details-sheet.tsx | 18 +- 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 +++ 23 files changed, 1137 insertions(+), 1 deletion(-) 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].tsx b/src/app/call/[id].tsx index dc971d0..99a5152 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -4,6 +4,7 @@ import { ClipboardListIcon, ClockIcon, FileTextIcon, + HistoryIcon, ImageIcon, InfoIcon, LoaderIcon, @@ -23,6 +24,7 @@ import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 're import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-tab-content'; import { ActivityLinkMarker } from '@/components/calls/activity-link-marker'; import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel'; +import { LocationHistoryPanel } from '@/components/calls/location-history-panel'; import { CheckInTabContent } from '@/components/check-in-timers/check-in-tab-content'; import { HeaderBackButton } from '@/components/common/header-back-button'; import { Loading } from '@/components/common/loading'; @@ -565,6 +567,14 @@ export default function CallDetail() { content: , }); + // History tab: previous calls at this location (address however it was typed) or with the same contacts. + tabs.push({ + key: 'history', + title: t('call_detail.tabs.history'), + icon: , + content: , + }); + // Conditionally add check-in tab if (call?.CheckInTimersEnabled) { const checkInBadge = getUnitTypeCheckInBadge(timerStatuses, { diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx index bb595b0..2f7d4e3 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/app/call/__tests__/[id].security.test.tsx @@ -222,6 +222,7 @@ jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ jest.mock('@/components/calls/call-site-info-tab-panel', () => ({ CallSiteInfoTabPanel: () => null, })); +jest.mock('@/components/calls/location-history-panel', () => ({ LocationHistoryPanel: () => null })); jest.mock('@/stores/check-in-timers/store', () => ({ useCheckInTimerStore: jest.fn((selector: any) => diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index 1c567b2..518c92c 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -327,6 +327,7 @@ jest.mock('@/components/call-video-feeds/video-feed-tab-content', () => ({ jest.mock('@/components/calls/call-site-info-tab-panel', () => ({ CallSiteInfoTabPanel: () => null, })); +jest.mock('@/components/calls/location-history-panel', () => ({ LocationHistoryPanel: () => null })); jest.mock('@/stores/check-in-timers/store', () => ({ useCheckInTimerStore: jest.fn((selector: any) => 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 dac43b4..9dfe799 100644 --- a/src/components/contacts/contact-details-sheet.tsx +++ b/src/components/contacts/contact-details-sheet.tsx @@ -1,7 +1,9 @@ +import { router } from 'expo-router'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Linking, Platform, ScrollView, useWindowDimensions, View } from 'react-native'; +import { LocationHistoryPanel } from '@/components/calls/location-history-panel'; import { Avatar, AvatarImage } from '@/components/ui/avatar'; import { BuildingIcon, @@ -185,7 +187,14 @@ export const ContactDetailsSheet: React.FC = () => { const isDetailsOpen = useContactsStore((s) => s.isDetailsOpen); const closeDetails = useContactsStore((s) => s.closeDetails); const selectedContactDetails = useContactsStore((s) => s.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; @@ -328,6 +337,11 @@ export const ContactDetailsSheet: React.FC = () => { {t('contacts.tabs.files')} + setActiveTab('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 */} @@ -438,6 +452,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 dd91213..5eb3c29 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -205,6 +205,7 @@ "check_in": "تسجيل الحضور", "contact": "جهة الاتصال", "dispatched": "المرسلة", + "history": "السجل", "info": "المعلومات", "protocols": "البروتوكولات", "site": "معلومات الموقع", @@ -831,6 +832,7 @@ "stateId": "معرف الولاية", "systemInformation": "معلومات النظام", "tabs": { + "calls": "المكالمات", "details": "التفاصيل", "files": "الملفات", "notes": "الملاحظات", @@ -1044,6 +1046,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": { "errorModal": { "confirmButton": "موافق", diff --git a/src/translations/de.json b/src/translations/de.json index 6afa1ba..df9f9ba 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -205,6 +205,7 @@ "check_in": "Check-In", "contact": "Kontakt", "dispatched": "Entsendet", + "history": "Verlauf", "info": "Info", "protocols": "Protokolle", "site": "Objektinfo", @@ -831,6 +832,7 @@ "stateId": "Bundesland-ID", "systemInformation": "Systeminformationen", "tabs": { + "calls": "Einsätze", "details": "Details", "files": "Dateien", "notes": "Notizen", @@ -1044,6 +1046,40 @@ "tracking_notification_body": "Der Standort dieser Einheit 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": { "errorModal": { "confirmButton": "OK", diff --git a/src/translations/el.json b/src/translations/el.json index 74addd4..33fa5a6 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -205,6 +205,7 @@ "check_in": "Παρουσία", "contact": "Επαφή", "dispatched": "Απεσταλμένες", + "history": "Ιστορικό", "info": "Πληροφορίες", "protocols": "Πρωτόκολλα", "site": "Πληροφορίες χώρου", @@ -831,6 +832,7 @@ "stateId": "Αναγνωριστικό Νομού", "systemInformation": "Πληροφορίες Συστήματος", "tabs": { + "calls": "Κλήσεις", "details": "Λεπτομέρειες", "files": "Αρχεία", "notes": "Σημειώσεις", @@ -1044,6 +1046,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": { "errorModal": { "confirmButton": "Εντάξει", diff --git a/src/translations/en.json b/src/translations/en.json index 8caf93f..0285089 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -205,6 +205,7 @@ "check_in": "Check-In", "contact": "Contact", "dispatched": "Dispatched", + "history": "History", "info": "Info", "protocols": "Protocols", "site": "Site Info", @@ -831,6 +832,7 @@ "stateId": "State ID", "systemInformation": "System Information", "tabs": { + "calls": "Calls", "details": "Details", "files": "Files", "notes": "Notes", @@ -1044,6 +1046,40 @@ "tracking_notification_body": "Sharing this unit's 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": { "errorModal": { "confirmButton": "OK", diff --git a/src/translations/es.json b/src/translations/es.json index 2fabf8e..fc334d9 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -205,6 +205,7 @@ "check_in": "Registro", "contact": "Contacto", "dispatched": "Despachadas", + "history": "Historial", "info": "Información", "protocols": "Protocolos", "site": "Info. del sitio", @@ -831,6 +832,7 @@ "stateId": "ID del estado", "systemInformation": "Información del sistema", "tabs": { + "calls": "Llamadas", "details": "Detalles", "files": "Archivos", "notes": "Notas", @@ -1044,6 +1046,40 @@ "tracking_notification_body": "Compartiendo la ubicación de esta unidad 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": { "errorModal": { "confirmButton": "Aceptar", diff --git a/src/translations/fr.json b/src/translations/fr.json index 5a809b7..29d6127 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -205,6 +205,7 @@ "check_in": "Pointage", "contact": "Contact", "dispatched": "Envoyé", + "history": "Historique", "info": "Info", "protocols": "Protocoles", "site": "Infos site", @@ -831,6 +832,7 @@ "stateId": "ID de l'État", "systemInformation": "Informations système", "tabs": { + "calls": "Appels", "details": "Détails", "files": "Fichiers", "notes": "Notes", @@ -1044,6 +1046,40 @@ "tracking_notification_body": "Partage de la position de cette unité 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": { "errorModal": { "confirmButton": "OK", diff --git a/src/translations/it.json b/src/translations/it.json index b4f07c2..0231eb3 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -205,6 +205,7 @@ "check_in": "Check-In", "contact": "Contatto", "dispatched": "Inviato", + "history": "Storico", "info": "Info", "protocols": "Protocolli", "site": "Info sito", @@ -831,6 +832,7 @@ "stateId": "ID provincia", "systemInformation": "Informazioni sistema", "tabs": { + "calls": "Chiamate", "details": "Dettagli", "files": "File", "notes": "Note", @@ -1044,6 +1046,40 @@ "tracking_notification_body": "Condivisione della posizione di questa unità 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": { "errorModal": { "confirmButton": "OK", diff --git a/src/translations/pl.json b/src/translations/pl.json index fa3554c..7cb1f62 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -205,6 +205,7 @@ "check_in": "Meldunek", "contact": "Kontakt", "dispatched": "Wysłane", + "history": "Historia", "info": "Info", "protocols": "Protokoły", "site": "Info o obiekcie", @@ -831,6 +832,7 @@ "stateId": "ID województwa", "systemInformation": "Informacje systemowe", "tabs": { + "calls": "Zgłoszenia", "details": "Szczegóły", "files": "Pliki", "notes": "Notatki", @@ -1044,6 +1046,40 @@ "tracking_notification_body": "Udostępnianie lokalizacji tej jednostki 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": { "errorModal": { "confirmButton": "OK", diff --git a/src/translations/sv.json b/src/translations/sv.json index 5207ad4..0ff426a 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -205,6 +205,7 @@ "check_in": "Incheckning", "contact": "Kontakt", "dispatched": "Utskickad", + "history": "Historik", "info": "Info", "protocols": "Protokoll", "site": "Objektinfo", @@ -831,6 +832,7 @@ "stateId": "Stats-ID", "systemInformation": "Systeminformation", "tabs": { + "calls": "Larm", "details": "Detaljer", "files": "Filer", "notes": "Anteckningar", @@ -1044,6 +1046,40 @@ "tracking_notification_body": "Delar den här enhetens 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": { "errorModal": { "confirmButton": "OK", diff --git a/src/translations/uk.json b/src/translations/uk.json index 629b7b1..c0c3901 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -205,6 +205,7 @@ "check_in": "Реєстрація", "contact": "Контакт", "dispatched": "Відправлено", + "history": "Історія", "info": "Інфо", "protocols": "Протоколи", "site": "Про об'єкт", @@ -831,6 +832,7 @@ "stateId": "ID області", "systemInformation": "Системна інформація", "tabs": { + "calls": "Виклики", "details": "Деталі", "files": "Файли", "notes": "Примітки", @@ -1044,6 +1046,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": { "errorModal": { "confirmButton": "OK", From c2bfe877bf6eca5133bce843970bda58db09e344 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 4 Oct 2026 09:15:23 -0700 Subject: [PATCH 2/4] RG-T139 Web Push Support --- electron/__tests__/main-links.test.ts | 6 + electron/__tests__/push-receiver.test.ts | 254 ++++++ electron/main.js | 56 ++ electron/preload.js | 15 + electron/push-receiver.js | 225 ++++++ nginx.conf | 21 + package.json | 2 + public/.well-known/apple-app-site-association | 7 + public/.well-known/assetlinks.json | 14 + public/service-worker.js | 180 ++--- src/api/devices/push.ts | 9 + 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 | 6 + .../__tests__/push-notification.web.test.ts | 371 +++++++++ src/services/__tests__/service-worker.test.ts | 127 +++ src/services/push-notification.web.ts | 759 +++++++++++------- .../auth/__tests__/store-cold-start.test.ts | 29 + src/stores/auth/store.tsx | 12 + yarn.lock | 673 +++++++++++++++- 21 files changed, 2486 insertions(+), 390 deletions(-) create mode 100644 electron/__tests__/push-receiver.test.ts create mode 100644 electron/push-receiver.js create mode 100644 public/.well-known/apple-app-site-association create mode 100644 public/.well-known/assetlinks.json 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__/push-notification.web.test.ts create mode 100644 src/services/__tests__/service-worker.test.ts diff --git a/electron/__tests__/main-links.test.ts b/electron/__tests__/main-links.test.ts index 3a0de90..0dbc731 100644 --- a/electron/__tests__/main-links.test.ts +++ b/electron/__tests__/main-links.test.ts @@ -19,6 +19,10 @@ const mockApp = { quit: jest.fn(), isPackaged: true, name: 'Resgrid Unit', + // Desktop push keeps its receiver state here (push-receiver.js); nothing is written in this test. + getPath: jest.fn(() => '/nonexistent/resgrid-unit'), + isReady: jest.fn(() => false), + setAppUserModelId: jest.fn(), }; const mockShell = { openExternal: jest.fn(async () => undefined) }; const mockWindow = { @@ -73,6 +77,8 @@ describe('main process: the app scheme reaches the waiting legacy sign-in', () = expect(mockApp.requestSingleInstanceLock).toHaveBeenCalled(); expect(mockApp.quit).not.toHaveBeenCalled(); expect(Object.keys(mockIpcHandlers)).toEqual(expect.arrayContaining(['legacy-sso:oidc', 'legacy-sso:saml', 'legacy-sso:cancel'])); + // Desktop push is wired up beside it. + expect(Object.keys(mockIpcHandlers)).toEqual(expect.arrayContaining(['push:start', 'push:stop', 'push:take-pending-click'])); }); it("takes the return from macOS's open-url", async () => { diff --git a/electron/__tests__/push-receiver.test.ts b/electron/__tests__/push-receiver.test.ts new file mode 100644 index 0000000..d84c0cb --- /dev/null +++ b/electron/__tests__/push-receiver.test.ts @@ -0,0 +1,254 @@ +/** + * Desktop push (electron/push-receiver.js): the main process registers with FCM, keeps its credentials across restarts, + * hands a push to a window the person is looking at and raises a native notification otherwise, routes that + * notification's click back to the page, and forgets its credentials on sign-out so the old token dies with them. + * + * @jest-environment node + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { registerPushReceiver, toPayload } = require('../push-receiver'); + +const firebase = { apiKey: 'api-key', projectId: 'resgrid-web', messagingSenderId: '343968022249', appId: '1:343968022249:web:abc', vapidKey: 'BVapid' }; + +type Handler = (event: unknown, ...args: unknown[]) => unknown; + +class FakeIpcMain { + handlers = new Map(); + + handle(channel: string, handler: Handler) { + this.handlers.set(channel, handler); + } + + invoke(channel: string, ...args: unknown[]) { + return Promise.resolve(this.handlers.get(channel)!({}, ...args)); + } +} + +class FakeReceiver { + static created: FakeReceiver[] = []; + credentials: { fcm: { token: string } } | undefined; + notificationListener: ((envelope: unknown) => void) | null = null; + credentialsListener: ((change: { newCredentials: unknown }) => void) | null = null; + destroyed = false; + connected = false; + + constructor(public config: { credentials?: { fcm: { token: string } }; persistentIds: string[]; vapidKey: string; firebase: unknown }) { + this.credentials = config.credentials; + FakeReceiver.created.push(this); + } + + get fcmToken() { + return this.credentials?.fcm.token; + } + + onCredentialsChanged(listener: (change: { newCredentials: unknown }) => void) { + this.credentialsListener = listener; + return () => undefined; + } + + onNotification(listener: (envelope: unknown) => void) { + this.notificationListener = listener; + return () => undefined; + } + + async registerIfNeeded() { + if (!this.credentials) { + this.credentials = { fcm: { token: `token-${FakeReceiver.created.length}` } }; + this.credentialsListener?.({ newCredentials: this.credentials }); + } + return this.credentials; + } + + async connect() { + this.connected = true; + } + + destroy() { + this.destroyed = true; + } +} + +function setup(overrides: Record = {}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-push-')); + const storePath = path.join(directory, 'push-receiver.json'); + const ipcMain = new FakeIpcMain(); + const notify = jest.fn((_payload: unknown, _onClick: () => void) => true); + const send = jest.fn((_channel: string, _payload: unknown) => true); + const focus = jest.fn(); + let focused = false; + + const receiver = registerPushReceiver(ipcMain, { + storePath, + appName: 'Resgrid Unit', + platform: 'darwin', + createReceiver: (config: ConstructorParameters[0]) => new FakeReceiver(config), + notify, + send, + focus, + isWindowFocused: () => focused, + log: { warn: jest.fn() }, + ...overrides, + }); + + return { ipcMain, storePath, notify, send, focus, receiver, setFocused: (value: boolean) => (focused = value) }; +} + +const fcmMessage = (eventCode: string, category = 'calls') => ({ + message: { + notification: { title: 'Structure Fire', body: '123 Main St' }, + data: { title: 'Structure Fire', message: '123 Main St', eventCode, type: '3', category }, + }, + persistentId: `0:${eventCode}`, +}); + +beforeEach(() => { + FakeReceiver.created = []; +}); + +describe('push:start', () => { + it('registers with FCM, hands back the token and keeps the credentials for the next launch', async () => { + const { ipcMain, storePath } = setup(); + + await expect(ipcMain.invoke('push:start', firebase)).resolves.toEqual({ token: 'token-1' }); + + const created = FakeReceiver.created[0]; + expect(created.config.vapidKey).toBe('BVapid'); + expect(created.config.firebase).toEqual({ apiKey: 'api-key', appId: firebase.appId, projectId: 'resgrid-web', messagingSenderId: '343968022249' }); + expect(created.connected).toBe(true); + + const saved = JSON.parse(fs.readFileSync(storePath, 'utf8')); + expect(saved.credentials).toEqual({ fcm: { token: 'token-1' } }); + expect(fs.statSync(storePath).mode & 0o777).toBe(0o600); + + // A restart reuses the saved credentials, so the token the server holds stays valid. + const restarted = setup({ storePath }); + await expect(restarted.ipcMain.invoke('push:start', firebase)).resolves.toEqual({ token: 'token-1' }); + }); + + it('answers repeat calls with the running receiver', async () => { + const { ipcMain } = setup(); + + await ipcMain.invoke('push:start', firebase); + await ipcMain.invoke('push:start', firebase); + + expect(FakeReceiver.created).toHaveLength(1); + }); + + it('starts over with new credentials for a different Firebase app', async () => { + const { ipcMain } = setup(); + + await ipcMain.invoke('push:start', firebase); + await expect(ipcMain.invoke('push:start', { ...firebase, appId: 'other-app' })).resolves.toEqual({ token: 'token-2' }); + + expect(FakeReceiver.created[0].destroyed).toBe(true); + expect(FakeReceiver.created[1].config.credentials).toBeUndefined(); + }); + + it('refuses an incomplete Firebase config without creating a receiver', async () => { + const { ipcMain } = setup(); + + await expect(ipcMain.invoke('push:start', { ...firebase, vapidKey: '' })).resolves.toEqual({ error: 'invalid-config' }); + expect(FakeReceiver.created).toHaveLength(0); + }); + + it('reports a registration that FCM refused', async () => { + const failing = setup({ + createReceiver: (config: ConstructorParameters[0]) => { + const receiver = new FakeReceiver(config); + receiver.registerIfNeeded = async () => { + throw new Error('PHONE_REGISTRATION_ERROR'); + }; + return receiver; + }, + }); + + await expect(failing.ipcMain.invoke('push:start', firebase)).resolves.toEqual({ error: 'PHONE_REGISTRATION_ERROR' }); + }); +}); + +describe('incoming pushes', () => { + it('gives a push to the window the person is looking at, not the OS', async () => { + const { ipcMain, notify, send, setFocused } = setup(); + await ipcMain.invoke('push:start', firebase); + setFocused(true); + + FakeReceiver.created[0].notificationListener!(fcmMessage('C1234')); + + expect(send).toHaveBeenCalledWith('push:received', { title: 'Structure Fire', body: '123 Main St', eventCode: 'C1234', type: '3', category: 'calls' }); + expect(notify).not.toHaveBeenCalled(); + }); + + it('raises a native notification otherwise, and its click brings the window forward with the push', async () => { + const { ipcMain, notify, send, focus } = setup(); + await ipcMain.invoke('push:start', firebase); + + FakeReceiver.created[0].notificationListener!(fcmMessage('C1234')); + + expect(notify).toHaveBeenCalledTimes(1); + expect(send).not.toHaveBeenCalled(); + + notify.mock.calls[0][1](); + expect(focus).toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith('push:notification-click', expect.objectContaining({ eventCode: 'C1234' })); + }); + + it('keeps a click for a page that is not ready, until it asks', async () => { + const { ipcMain, notify, send } = setup(); + send.mockReturnValue(false); + await ipcMain.invoke('push:start', firebase); + + FakeReceiver.created[0].notificationListener!(fcmMessage('M:77', 'messages')); + notify.mock.calls[0][1](); + + await expect(ipcMain.invoke('push:take-pending-click')).resolves.toEqual(expect.objectContaining({ eventCode: 'M:77' })); + await expect(ipcMain.invoke('push:take-pending-click')).resolves.toBeNull(); + }); + + it('remembers delivered pushes so a restart does not show them again', async () => { + const { ipcMain, storePath } = setup(); + await ipcMain.invoke('push:start', firebase); + + FakeReceiver.created[0].notificationListener!(fcmMessage('C1')); + FakeReceiver.created[0].notificationListener!(fcmMessage('C2')); + + expect(JSON.parse(fs.readFileSync(storePath, 'utf8')).persistentIds).toEqual(['0:C1', '0:C2']); + + const restarted = setup({ storePath }); + await restarted.ipcMain.invoke('push:start', firebase); + expect(FakeReceiver.created[1].config.persistentIds).toEqual(['0:C1', '0:C2']); + }); +}); + +describe('push:stop', () => { + it('forgets the credentials on sign-out, so the next start mints a new token', async () => { + const { ipcMain, storePath } = setup(); + await ipcMain.invoke('push:start', firebase); + + await ipcMain.invoke('push:stop', true); + + expect(FakeReceiver.created[0].destroyed).toBe(true); + expect(fs.existsSync(storePath)).toBe(false); + await expect(ipcMain.invoke('push:start', firebase)).resolves.toEqual({ token: 'token-2' }); + }); + + it('keeps the credentials when only stopping', async () => { + const { ipcMain, storePath, receiver } = setup(); + await ipcMain.invoke('push:start', firebase); + + receiver.stop(); + + expect(fs.existsSync(storePath)).toBe(true); + }); +}); + +describe('toPayload', () => { + it('prefers the data Core sends, falls back to the notification block, then the app name', () => { + expect(toPayload({ notification: { title: 'N title', body: 'N body' } }, 'Resgrid Unit')).toEqual({ title: 'N title', body: 'N body', eventCode: '', type: '', category: '' }); + expect(toPayload(undefined, 'Resgrid Unit')).toEqual({ title: 'Resgrid Unit', body: '', eventCode: '', type: '', category: '' }); + expect(toPayload({ data: { title: 7, eventCode: ['x'] } }, 'Resgrid Unit').title).toBe('Resgrid Unit'); + }); +}); diff --git a/electron/main.js b/electron/main.js index 4b9d23e..976895b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2,6 +2,7 @@ const { app, BrowserWindow, ipcMain, Notification, nativeTheme, Menu, protocol, net, shell } = require('electron'); const { registerSsoLoopback } = require('./sso-loopback'); const { registerLegacySso } = require('./legacy-sso'); +const { registerPushReceiver } = require('./push-receiver'); const path = require('path'); const fs = require('fs'); const { pathToFileURL } = require('url'); @@ -27,6 +28,11 @@ if (require('electron-squirrel-startup')) { app.quit(); } +// Windows shows a toast only for the AppUserModelID the installer registered, which electron-builder sets to the appId. +if (process.platform === 'win32') { + app.setAppUserModelId('com.resgrid.unit'); +} + let mainWindow = null; const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; @@ -224,6 +230,56 @@ ipcMain.handle('show-notification', async (event, { title, body, data }) => { return true; }); +// Desktop push (push-receiver.js): this process receives the unit's pushes and shows them natively. +// Notifications are held until clicked or closed: one that is garbage collected loses its click handler. +const activePushNotifications = new Set(); + +const pushReceiver = registerPushReceiver(ipcMain, { + storePath: path.join(app.getPath('userData'), 'push-receiver.json'), + appName: 'Resgrid Unit', + notify: (payload, onClick) => { + if (!Notification.isSupported()) { + return false; + } + + const isCall = payload.category === 'calls'; + const notification = new Notification({ + title: payload.title, + body: payload.body, + silent: false, + icon: path.join(__dirname, '../assets/icon.png'), + urgency: isCall ? 'critical' : 'normal', + timeoutType: isCall ? 'never' : 'default', + }); + const release = () => activePushNotifications.delete(notification); + activePushNotifications.add(notification); + notification.on('click', () => { + release(); + onClick(); + }); + notification.on('close', release); + notification.show(); + return true; + }, + isWindowFocused: () => !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused(), + send: (channel, payload) => { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoading()) { + return false; + } + mainWindow.webContents.send(channel, payload); + return true; + }, + focus: () => { + if (mainWindow) { + focusMainWindow(); + } else if (app.isReady()) { + createWindow(); + } + }, +}); + +app.on('before-quit', () => pushReceiver.stop()); + // Brokered SSO returns to a one-time loopback listener; the provider opens in the member's own browser. registerSsoLoopback(ipcMain, { openExternal: (url) => shell.openExternal(url), diff --git a/electron/preload.js b/electron/preload.js index ac2490e..0f2d897 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -21,6 +21,21 @@ contextBridge.exposeInMainWorld('electronAPI', { // Platform queries getPlatform: () => ipcRenderer.invoke('get-platform'), + // Desktop push (push-receiver.js): the main process holds the FCM connection; the page registers its token. + pushStart: (firebaseConfig) => ipcRenderer.invoke('push:start', firebaseConfig), + pushStop: (forget) => ipcRenderer.invoke('push:stop', forget), + pushTakePendingClick: () => ipcRenderer.invoke('push:take-pending-click'), + onPushReceived: (callback) => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on('push:received', listener); + return () => ipcRenderer.removeListener('push:received', listener); + }, + onPushNotificationClick: (callback) => { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on('push:notification-click', listener); + return () => ipcRenderer.removeListener('push:notification-click', listener); + }, + // Brokered SSO: a one-time loopback listener receives the broker's return (see sso-loopback.js) ssoListen: () => ipcRenderer.invoke('sso:listen'), ssoOpen: (id, authorizeUrl) => ipcRenderer.invoke('sso:open', id, authorizeUrl), diff --git a/electron/push-receiver.js b/electron/push-receiver.js new file mode 100644 index 0000000..343eb82 --- /dev/null +++ b/electron/push-receiver.js @@ -0,0 +1,225 @@ +/* eslint-disable no-undef */ +/** + * Desktop push. Electron's Chromium has no browser push service, so the web edition's service worker can't + * receive anything here. Instead this main process holds its own FCM connection (@eneris/push-receiver + * registers the way Chrome does, against the same Firebase web app the browsers use), and the page + * registers the token it hands back with Core exactly like a browser token (Platform 3). + * + * Pushes arrive while the app is running, including with its window hidden or minimized. A push for a + * window the person is looking at goes to the page for its in-app alert; any other becomes a native + * notification, and clicking that brings the window forward and hands the push to the page. + * + * The receiver's credentials (its FCM token and the keys that decrypt pushes) are kept in the user data + * folder so the token survives restarts. Forgetting them is how sign-out kills the token on this device. + */ +const fs = require('fs'); +const path = require('path'); + +/** FCM persistent ids already delivered; sent back at login so the server doesn't deliver them again. */ +const MAX_PERSISTENT_IDS = 100; + +const FIREBASE_FIELDS = ['apiKey', 'projectId', 'messagingSenderId', 'appId', 'vapidKey']; + +function isFirebaseConfig(value) { + return !!value && typeof value === 'object' && FIREBASE_FIELDS.every((field) => typeof value[field] === 'string' && value[field].length > 0); +} + +function configKey(firebase) { + return [firebase.projectId, firebase.appId, firebase.messagingSenderId, firebase.vapidKey].join('|'); +} + +/** What a page or notification needs from one FCM message (the fields Core's webpush block carries). */ +function toPayload(message, defaultTitle) { + const notification = (message && message.notification) || {}; + const data = (message && message.data) || {}; + const text = (value) => (typeof value === 'string' ? value : ''); + + return { + title: text(data.title) || text(notification.title) || defaultTitle, + body: text(data.message) || text(notification.body) || text(data.body), + eventCode: text(data.eventCode), + type: text(data.type), + category: text(data.category), + }; +} + +function chromePlatform(platform) { + if (platform === 'win32') return 1; + if (platform === 'darwin') return 2; + return 3; +} + +/** + * @param {Electron.IpcMain} ipcMain + * @param {{ + * storePath: string, + * appName: string, + * platform?: string, + * createReceiver?: (config: object) => any, + * notify: (payload: object, onClick: () => void) => boolean, + * isWindowFocused: () => boolean, + * send: (channel: string, payload: object) => boolean, + * focus: () => void, + * log?: { warn: Function }, + * }} options + */ +function registerPushReceiver(ipcMain, options) { + const createReceiver = + options.createReceiver || + ((config) => { + const { PushReceiver } = require('@eneris/push-receiver'); + return new PushReceiver(config); + }); + const log = options.log || console; + + let receiver = null; + let receiverKey = null; + let starting = null; + let pendingClick = null; + + function load() { + try { + return JSON.parse(fs.readFileSync(options.storePath, 'utf8')) || {}; + } catch { + return {}; + } + } + + function save(state) { + try { + fs.mkdirSync(path.dirname(options.storePath), { recursive: true }); + // The credentials decrypt this device's pushes: readable by this user only. + fs.writeFileSync(options.storePath, JSON.stringify(state), { mode: 0o600 }); + } catch (error) { + log.warn('Desktop push: could not save the receiver state', error); + } + } + + function forget() { + try { + fs.rmSync(options.storePath, { force: true }); + } catch (error) { + log.warn('Desktop push: could not remove the receiver state', error); + } + } + + function rememberPersistentId(persistentId) { + if (!persistentId) return; + const state = load(); + const ids = Array.isArray(state.persistentIds) ? state.persistentIds : []; + if (!ids.includes(persistentId)) { + ids.push(persistentId); + } + save({ ...state, persistentIds: ids.slice(-MAX_PERSISTENT_IDS) }); + } + + function deliverClick(payload) { + options.focus(); + if (!options.send('push:notification-click', payload)) { + // No page to take it yet (the window was closed, or is still loading): it asks when it starts. + pendingClick = payload; + } + } + + function handleMessage(envelope) { + rememberPersistentId(envelope && envelope.persistentId); + const payload = toPayload(envelope && envelope.message, options.appName); + + if (options.isWindowFocused() && options.send('push:received', payload)) { + return; + } + + options.notify(payload, () => deliverClick(payload)); + } + + function stop(shouldForget) { + if (receiver) { + try { + receiver.destroy(); + } catch (error) { + log.warn('Desktop push: the receiver did not stop cleanly', error); + } + } + + receiver = null; + receiverKey = null; + starting = null; + + if (shouldForget) { + forget(); + } + } + + async function start(firebase) { + const key = configKey(firebase); + if (receiver && receiverKey === key && receiver.fcmToken) { + return receiver.fcmToken; + } + + stop(false); + + const state = load(); + // Credentials minted for another Firebase app or key can't receive this one's pushes. + const credentials = state.configKey === key ? state.credentials : undefined; + + const instance = createReceiver({ + firebase: { apiKey: firebase.apiKey, appId: firebase.appId, projectId: firebase.projectId, messagingSenderId: firebase.messagingSenderId }, + vapidKey: firebase.vapidKey, + credentials, + persistentIds: Array.isArray(state.persistentIds) ? state.persistentIds : [], + chromePlatform: chromePlatform(options.platform || process.platform), + }); + + instance.onCredentialsChanged(({ newCredentials }) => { + save({ ...load(), configKey: key, credentials: newCredentials, persistentIds: [] }); + }); + instance.onNotification(handleMessage); + + receiver = instance; + receiverKey = key; + + // The token exists once registration is done; the connection that delivers pushes can come up after + // (and keeps retrying on its own), so the page can register without waiting on it. + await instance.registerIfNeeded(); + instance.connect().catch((error) => log.warn('Desktop push: connection failed', error)); + + return instance.fcmToken; + } + + ipcMain.handle('push:start', async (_event, firebase) => { + if (!isFirebaseConfig(firebase)) { + return { error: 'invalid-config' }; + } + + if (!starting) { + starting = start(firebase).finally(() => { + starting = null; + }); + } + + try { + const token = await starting; + return token ? { token } : { error: 'no-token' }; + } catch (error) { + stop(false); + log.warn('Desktop push: could not register with FCM', error); + return { error: (error && error.message) || 'register-failed' }; + } + }); + + ipcMain.handle('push:stop', async (_event, shouldForget) => { + stop(!!shouldForget); + }); + + ipcMain.handle('push:take-pending-click', () => { + const payload = pendingClick; + pendingClick = null; + return payload; + }); + + return { + stop: () => stop(false), + }; +} + +module.exports = { registerPushReceiver, toPayload, isFirebaseConfig }; diff --git a/nginx.conf b/nginx.conf index 8e14342..0ca7b10 100644 --- a/nginx.conf +++ b/nginx.conf @@ -52,6 +52,27 @@ http { try_files $uri =404; } + # Passkey association files (passkey workbook section 5.1): this host's app, served as JSON. An exact match wins over + # the SPA fallback and the hidden-path rule, so a missing file is a 404, never index.html. + location = /.well-known/apple-app-site-association { + default_type application/json; + try_files $uri =404; + } + + location = /.well-known/assetlinks.json { + default_type application/json; + try_files $uri =404; + } + + # Push service worker (services/push-notification.web.ts). Browsers check it for updates, so it can't be cached as an + # immutable asset like the hashed bundles above; an exact match wins over that rule. + location = /service-worker.js { + add_header Cache-Control "no-cache" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data: https:; connect-src 'self' https: wss:; worker-src 'self' blob:;" always; + try_files $uri =404; + } + # SPA fallback - serve index.html for client-side routing location / { try_files $uri $uri/ /index.html; diff --git a/package.json b/package.json index bba5a11..9e834b6 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@config-plugins/react-native-callkeep": "14.0.1", "@config-plugins/react-native-webrtc": "15.0.1", "@dev-plugins/react-query": "0.4.0", + "@eneris/push-receiver": "^4.4.0", "@expo/html-elements": "0.10.1", "@expo/metro-runtime": "~57.0.16", "@gluestack-ui/core": "^5.0.15", @@ -116,6 +117,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..277beff --- /dev/null +++ b/public/.well-known/apple-app-site-association @@ -0,0 +1,7 @@ +{ + "webcredentials": { + "apps": [ + "QKQVAJMTCN.com.resgrid.unit" + ] + } +} diff --git a/public/.well-known/assetlinks.json b/public/.well-known/assetlinks.json new file mode 100644 index 0000000..6f34f47 --- /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": "com.resgrid.unit", + "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 index 0a650ff..4ef336c 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,143 +1,113 @@ /* eslint-disable no-undef */ -/* eslint-disable no-unused-vars */ /** - * Service Worker for Resgrid Unit Web Push Notifications - * This file handles background push notifications when the app is not in focus + * Service worker for Resgrid Unit web push. + * + * The page mints its FCM web token against this registration (services/push-notification.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. */ -// Cache name for offline support (optional) -const CACHE_NAME = 'resgrid-unit-v1'; +const DEFAULT_TITLE = 'Resgrid Unit'; -// Store pending notification data for newly opened windows -const pendingNotifications = new Map(); +// Clicks waiting for the window they opened to finish loading, by client id. +const pendingClicks = new Map(); -// Handle push events -self.addEventListener('push', function (event) { - console.log('[Service Worker] Push received:', event); - - let data = {}; +function readPush(event) { + let payload = {}; if (event.data) { try { - data = event.data.json(); + payload = event.data.json() || {}; } catch (e) { - data = { - title: 'New Notification', - body: event.data.text(), - }; + payload = { notification: { body: event.data.text() } }; } } - const title = data.title || 'Resgrid Unit'; - const options = { - body: data.body || data.message || 'You have a new notification', - icon: '/icon-192.png', - badge: '/badge-72.png', - vibrate: [100, 50, 100], - data: data, - requireInteraction: true, - tag: data.eventCode || `notification-${Date.now()}`, - actions: [ - { - action: 'open', - title: 'Open', - }, - { - action: 'dismiss', - title: 'Dismiss', - }, - ], - }; + const notification = payload.notification || {}; + const data = payload.data || {}; - event.waitUntil(self.registration.showNotification(title, options)); -}); + 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 || '', + }; +} -// Handle notification click -self.addEventListener('notificationclick', function (event) { - console.log('[Service Worker] Notification clicked:', event); +function isCall(push) { + return push.category === 'calls' || /^c(?!t)/i.test(push.eventCode); +} - event.notification.close(); +self.addEventListener('push', (event) => { + const push = readPush(event); - const action = event.action; - const data = event.notification.data; + 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 })); + }), + ]) + ); +}); - // Handle dismiss action - if (action === 'dismiss') { - return; - } +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const data = event.notification.data || {}; - // Open the app and send message to main thread event.waitUntil( - clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { - // Try to focus an existing window - for (const client of clientList) { - if ('focus' in client) { - client.focus(); - client.postMessage({ - type: 'NOTIFICATION_CLICK', - data: data, - }); - return; - } + 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 })); } - // Open new window if no existing window found - if (clients.openWindow) { - return clients.openWindow('/').then(function (client) { - // Store notification data for handshake with the new window - if (client) { - pendingNotifications.set(client.id, data); - console.log('[Service Worker] Stored pending notification for client:', client.id); - } - }); + if (!self.clients.openWindow) { + return undefined; } + + return self.clients.openWindow('/').then((opened) => { + if (opened) { + pendingClicks.set(opened.id, data); + } + }); }) ); }); -// Handle notification close -self.addEventListener('notificationclose', function (event) { - console.log('[Service Worker] Notification closed:', event); -}); - -// Handle service worker installation -self.addEventListener('install', function (event) { - console.log('[Service Worker] Installing...'); - // Skip waiting to activate immediately +self.addEventListener('install', () => { self.skipWaiting(); }); -// Handle service worker activation -self.addEventListener('activate', function (event) { - console.log('[Service Worker] Activating...'); - // Take control of all pages immediately - event.waitUntil(clients.claim()); +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); }); -// Handle messages from the main thread -self.addEventListener('message', function (event) { - console.log('[Service Worker] Message received:', event.data); +self.addEventListener('message', (event) => { + if (!event.data || !event.source) { + return; + } - // Handle skip waiting message - if (event.data && event.data.type === 'SKIP_WAITING') { + if (event.data.type === 'SKIP_WAITING') { self.skipWaiting(); return; } - // Handle client ready handshake - if (event.data && event.data.type === 'CLIENT_READY') { - const clientId = event.source.id; - console.log('[Service Worker] Client ready handshake received:', clientId); - - // Check if there's a pending notification for this client - if (pendingNotifications.has(clientId)) { - const notificationData = pendingNotifications.get(clientId); - pendingNotifications.delete(clientId); - - console.log('[Service Worker] Sending pending notification to client:', clientId); - event.source.postMessage({ - type: 'NOTIFICATION_CLICK', - data: notificationData, - }); - } + // 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 7be8a32..28db024 100644 --- a/src/api/devices/push.ts +++ b/src/api/devices/push.ts @@ -1,9 +1,11 @@ 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 unRegisterWebPushApi = createApiEndpoint('/Devices/UnRegisterWebPush'); export const registerUnitDevice = async (data: PushRegistrationUnitInput) => { const response = await registerUnitDeviceApi.post({ @@ -11,3 +13,10 @@ export const registerUnitDevice = async (data: PushRegistrationUnitInput) => { }); return response.data; }; + +export const unRegisterWebPush = async (data: WebPushUnRegistrationInput) => { + const response = await unRegisterWebPushApi.post({ + ...data, + }); + return response.data; +}; 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 ab5e458..7b51e18 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 and desktop 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..b79feae --- /dev/null +++ b/src/models/v4/device/webPushUnRegistrationInput.ts @@ -0,0 +1,6 @@ +/** 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 = ''; + public UnitId: string = ''; +} diff --git a/src/services/__tests__/push-notification.web.test.ts b/src/services/__tests__/push-notification.web.test.ts new file mode 100644 index 0000000..1904270 --- /dev/null +++ b/src/services/__tests__/push-notification.web.test.ts @@ -0,0 +1,371 @@ +/** + * Push on the web and desktop editions (services/push-notification.web.ts): a browser mints an FCM web token, a desktop + * gets one from its main process, and either is registered on the active unit as Platform 3. A registration never + * outlives its session: sign-out unregisters and kills the token, and another unit on the device rotates it first. + */ + +const mockRegisterUnitDevice = jest.fn(async () => ({})); +const mockUnRegisterWebPush = jest.fn(async () => ({})); +const mockRouterPushWithRetry = 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', () => ({ + registerUnitDevice: (...args: unknown[]) => mockRegisterUnitDevice(...(args as [])), + unRegisterWebPush: (...args: unknown[]) => mockUnRegisterWebPush(...(args as [])), +})); +jest.mock('@/lib/navigation', () => ({ routerPushWithRetry: (...args: unknown[]) => mockRouterPushWithRetry(...(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: 'unit' })); +jest.mock('@/stores/push-notification/store', () => { + const actual = jest.requireActual('@/stores/push-notification/store'); + return { ...actual, usePushNotificationModalStore: { getState: () => ({ showNotificationModal: mockShowNotificationModal }) } }; +}); +jest.mock('@/services/notification-sound.service', () => ({ notificationSoundService: { playNotificationSound: jest.fn() } })); + +const mockState = { + auth: { status: 'signedIn', accessToken: 'access-token' } as { status: string; accessToken: string | null }, + core: { activeUnitId: '12', 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('../push-notification.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://unit.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), + }; + globals.electronAPI = undefined; + 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('../push-notification.web'), hooks: require('@/lib/auth/sign-out-hooks') }; + }); + return loaded; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockState.auth = { status: 'signedIn', accessToken: 'access-token' }; + mockState.core = { activeUnitId: '12', 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(mockRegisterUnitDevice).not.toHaveBeenCalled(); + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it('registers its FCM web token on the active unit 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(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', 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(mockRegisterUnitDevice).not.toHaveBeenCalled(); + expect(requestPermission).not.toHaveBeenCalled(); + + clickPage(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(requestPermission).toHaveBeenCalledTimes(1); + expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', 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(mockRegisterUnitDevice).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(mockRegisterUnitDevice).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(mockRegisterUnitDevice).toHaveBeenCalledTimes(1); + + now.mockReturnValue(1_000_000 + 25 * 60 * 60 * 1000); + await push.syncWebPush(); + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(2); + now.mockRestore(); + }); + + it('takes the device off the previous unit and rotates the token before registering another', async () => { + permission = 'granted'; + const { push } = load(); + await push.syncWebPush(); + mockFirebaseGetToken.mockResolvedValue('rotated-token'); + + mockState.core.activeUnitId = '15'; + await push.syncWebPush(); + + expect(mockUnRegisterWebPush).toHaveBeenCalledWith({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(mockRegisterUnitDevice).toHaveBeenLastCalledWith({ UnitId: '15', 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': 'unit' }, + body: JSON.stringify({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }), + }); + 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('desktop', () => { + let bridge: Record; + + beforeEach(() => { + bridge = { + pushStart: jest.fn(async () => ({ token: 'desktop-token' })), + pushStop: jest.fn(async () => undefined), + pushTakePendingClick: jest.fn(async () => null), + onPushReceived: jest.fn(() => () => undefined), + onPushNotificationClick: jest.fn(() => () => undefined), + }; + globals.electronAPI = bridge; + }); + + it('registers the token its main process hands back, without asking (the OS handles permission)', async () => { + const { push } = load(); + + await push.syncWebPush(); + + expect(bridge.pushStart).toHaveBeenCalledWith({ apiKey: 'api-key', authDomain: undefined, projectId: 'resgrid-web', messagingSenderId: '343968022249', appId: '1:343968022249:web:abc', vapidKey: 'BVapid' }); + expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', Token: 'desktop-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + push.askForPermissionOnce(); + clickPage(); + expect(requestPermission).not.toHaveBeenCalled(); + }); + + it('forgets its credentials at sign-out', async () => { + const { push, hooks } = load(); + await push.syncWebPush(); + + await hooks.runSignOutHooks('access-token'); + + expect(bridge.pushStop).toHaveBeenCalledWith(true); + }); + + it('registers nothing when the main process could not start', async () => { + bridge.pushStart.mockResolvedValue({ error: 'PHONE_REGISTRATION_ERROR' }); + const { push } = load(); + + await push.syncWebPush(); + + expect(mockRegisterUnitDevice).not.toHaveBeenCalled(); + }); + + it('routes a notification click the main process kept for the page', async () => { + bridge.pushTakePendingClick.mockResolvedValue({ title: 'Fire', body: '', eventCode: 'C:42' }); + const { push } = load(); + + push.attachWebPushListeners(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockRouterPushWithRetry).toHaveBeenCalledWith({ pathname: '/call/[id]', params: { id: '42' } }, expect.anything()); + }); +}); + +describe('clicks and foreground pushes', () => { + it('opens a call or a chat like a tap on the phone, and anything else as the in-app alert', async () => { + const { push } = load(); + + await push.openWebPush({ title: 'Fire', body: '', eventCode: 'C1234' }); + expect(mockRouterPushWithRetry).toHaveBeenLastCalledWith({ pathname: '/call/[id]', params: { id: '1234' } }, expect.anything()); + + await push.openWebPush({ title: 'Chat', body: '', eventCode: 't:abc-123' }); + expect(mockRouterPushWithRetry).toHaveBeenLastCalledWith({ pathname: '/chat/[channelId]', params: { channelId: 'abc-123' } }, expect.anything()); + + await push.openWebPush({ title: 'Message', body: 'Hello', eventCode: 'M:5' }); + expect(mockShowNotificationModal).toHaveBeenCalledWith(expect.objectContaining({ eventCode: 'M:5', title: 'Message', body: 'Hello' })); + }); + + it('refuses an id that would steer the router elsewhere', async () => { + const { push } = load(); + + await push.openWebPush({ title: 'Fire', body: '', eventCode: 'C:../admin' }); + + expect(mockRouterPushWithRetry).not.toHaveBeenCalled(); + expect(mockShowNotificationModal).toHaveBeenCalled(); + }); + + it('falls back to the in-app alert when the route never lands', async () => { + mockRouterPushWithRetry.mockRejectedValueOnce(new Error('not ready')); + const { push } = load(); + + await push.openWebPush({ title: 'Fire', body: '', eventCode: 'C:7' }); + + expect(mockShowNotificationModal).toHaveBeenCalledWith(expect.objectContaining({ eventCode: 'C:7' })); + }); + + 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(mockRouterPushWithRetry).toHaveBeenCalledWith({ pathname: '/call/[id]', params: { id: '9' } }, expect.anything()); + + 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/__tests__/service-worker.test.ts b/src/services/__tests__/service-worker.test.ts new file mode 100644 index 0000000..c175ac8 --- /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 Unit'); + }); + + 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/push-notification.web.ts b/src/services/push-notification.web.ts index 13b0a59..fe2c785 100644 --- a/src/services/push-notification.web.ts +++ b/src/services/push-notification.web.ts @@ -1,333 +1,524 @@ /** - * Web Push Notification Service - * Handles push notifications for web browsers using the Web Push API + * Push on the web and desktop (Electron) editions. + * + * Both end with an FCM token registered on the active unit's subscriber as Platform 3 (Core keeps those on + * a web channel beside the unit's other browsers, never replacing its phones). They differ in where the + * token comes from: + * - a browser mints it with the Firebase JS SDK against /service-worker.js, which shows each push and + * hands clicks back here; + * - Electron has no browser push service, so its main process holds an FCM connection of its own + * (electron/push-receiver.js) and shows the notifications natively. + * 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, and a desktop always is. + * 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 + * on this device (a sign-out hook, run while the session's token still works), and a different unit or + * department on this device rotates the token before registering. */ +import { type Href } from 'expo-router'; +import { getApps, initializeApp } from 'firebase/app'; +import { deleteToken, getMessaging, getToken, isSupported } from 'firebase/messaging'; +import { useEffect, useState } from 'react'; + +import { registerUnitDevice, unRegisterWebPush } from '@/api/devices/push'; +import { registerSignOutHook } from '@/lib/auth/sign-out-hooks'; import { logger } from '@/lib/logging'; -import { usePushNotificationModalStore } from '@/stores/push-notification/store'; +import { CLIENT_HEADER, RESGRID_CLIENT } from '@/lib/mfa/client-app'; +import { routerPushWithRetry } from '@/lib/navigation'; +import { getBaseApiUrl, getDeviceUuid } from '@/lib/storage/app'; +import { useCoreStore } from '@/stores/app/core-store'; +import useAuthStore from '@/stores/auth/store'; +import { isSafeRouteId, parseNotificationData, usePushNotificationModalStore } from '@/stores/push-notification/store'; +import { securityStore } from '@/stores/security/store'; + +/** One push as the service worker or the Electron main process hands it over. */ +export interface WebPushPayload { + title: string; + body: string; + eventCode: string; + type?: string; + category?: string; +} -class WebPushNotificationService { - private static instance: WebPushNotificationService; - private registration: ServiceWorkerRegistration | null = null; - private pushSubscription: PushSubscription | null = null; - private isInitialized = false; +export interface WebPushFirebaseConfig { + apiKey: string; + authDomain?: string; + projectId: string; + messagingSenderId: string; + appId: string; + vapidKey: string; +} - static getInstance(): WebPushNotificationService { - if (!WebPushNotificationService.instance) { - WebPushNotificationService.instance = new WebPushNotificationService(); - } - return WebPushNotificationService.instance; - } +/** What electron/preload.js exposes for push. */ +interface DesktopPushBridge { + pushStart: (config: WebPushFirebaseConfig) => Promise<{ token?: string; error?: string }>; + pushStop: (forget: boolean) => Promise; + pushTakePendingClick: () => Promise; + onPushReceived: (callback: (payload: WebPushPayload) => void) => () => void; + onPushNotificationClick: (callback: (payload: WebPushPayload) => void) => () => void; +} - /** - * Initialize the web push notification service - */ - async initialize(): Promise { - if (this.isInitialized) { - return; - } +interface UnitIdentity { + unitId: string; + prefix: string; +} - // Check if push notifications are supported - if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - logger.warn({ - message: 'Web Push notifications are not supported in this browser', - }); - return; - } +interface StoredRegistration extends UnitIdentity { + key: string; + token: string; + registeredAt: number; +} - try { - // Register service worker - this.registration = await navigator.serviceWorker.register('/service-worker.js'); - logger.info({ - message: 'Service worker registered for push notifications', - context: { scope: this.registration.scope }, - }); +/** 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 DEEP_LINK_RETRY = { + maxAttempts: 40, + retryDelayMs: 250, + waitUntil: () => useAuthStore.getState().status === 'signedIn', +}; - // Listen for messages from service worker - navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerMessage); - - // Wait for service worker to be ready, then send CLIENT_READY handshake - await navigator.serviceWorker.ready; - this.sendClientReadyHandshake(); - - // Additionally add a one-time 'controllerchange' listener to handle cases where controller is null initially (first-install) - const onControllerChange = () => { - if (navigator.serviceWorker.controller) { - logger.info({ - message: 'Service worker controller changed, sending CLIENT_READY', - }); - navigator.serviceWorker.controller.postMessage({ - type: 'CLIENT_READY', - }); - navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); - } - }; - navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); - - this.isInitialized = true; - } catch (error) { - logger.error({ - message: 'Failed to register service worker', - context: { error }, - }); - } +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; } +} - /** - * Send CLIENT_READY handshake to service worker - * This signals that the client is ready to receive notification messages - */ - private sendClientReadyHandshake(): void { - if (navigator.serviceWorker.controller) { - navigator.serviceWorker.controller.postMessage({ - type: 'CLIENT_READY', - }); - logger.info({ - message: 'Sent CLIENT_READY handshake to service worker controller', - }); - } else if (this.registration?.active) { - this.registration.active.postMessage({ - type: 'CLIENT_READY', - }); - logger.info({ - message: 'Sent CLIENT_READY handshake to active service worker (registration.active fallback)', - }); +function writeJson(key: string, value: unknown): void { + try { + if (value === null) { + window.localStorage.removeItem(key); } else { - logger.info({ - message: 'Silently skipping send CLIENT_READY: no controller or active registration available', - }); + window.localStorage.setItem(key, JSON.stringify(value)); } + } catch { + // storage unavailable: the registration just isn't remembered across reloads } +} - /** - * Handle messages from the service worker - */ - private handleServiceWorkerMessage = (event: MessageEvent): void => { - if (event.data?.type === 'NOTIFICATION_CLICK') { - const data = event.data?.data ?? undefined; - - // Only proceed if data is an object and has the expected fields - if (data && typeof data === 'object' && data.eventCode) { - logger.info({ - message: 'Notification clicked from service worker', - context: { data }, - }); - - // Show the notification modal - usePushNotificationModalStore.getState().showNotificationModal({ - eventCode: data.eventCode, - title: data.title, - body: data.body || data.message, - data, - }); - } else { - logger.warn({ - message: 'Notification click received with missing or invalid data', - context: { data: event.data }, - }); - } - } +const readRegistration = (): StoredRegistration | null => readJson(REGISTRATION_KEY); +const writeRegistration = (registration: StoredRegistration | null): void => writeJson(REGISTRATION_KEY, registration); + +function getDesktopBridge(): DesktopPushBridge | null { + const api = typeof window !== 'undefined' ? (window.electronAPI as unknown as Partial | undefined) : undefined; + return api && typeof api.pushStart === 'function' ? (api as DesktopPushBridge) : null; +} + +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, }; +} - /** - * Request permission and subscribe to push notifications - */ - async requestPermission(): Promise { - if (!('Notification' in window)) { - logger.warn({ - message: 'Notifications not supported in this browser', - }); - return 'denied'; - } +function browserCanPush(): boolean { + return typeof window !== 'undefined' && window.isSecureContext && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +function currentIdentity(): UnitIdentity | null { + if (useAuthStore.getState().status !== 'signedIn') { + return null; + } + + const unitId = useCoreStore.getState().activeUnitId; + const prefix = securityStore.getState().rights?.DepartmentCode; + return unitId && prefix ? { unitId, prefix } : null; +} + +const keyOf = (identity: UnitIdentity): string => `unit:${identity.unitId}:${identity.prefix}`; - const permission = await Notification.requestPermission(); - logger.info({ - message: 'Notification permission result', - context: { permission }, - }); +// --- Browser (Firebase JS SDK) --- - return permission; +async function loadMessaging(config: WebPushFirebaseConfig) { + if (!(await isSupported())) { + return null; } - /** - * Subscribe to push notifications with VAPID key - */ - async subscribe(vapidPublicKey: string): Promise { - if (!this.registration) { - await this.initialize(); - } + 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); - if (!this.registration) { - logger.error({ - message: 'Cannot subscribe: service worker not registered', - }); - return null; - } + return getMessaging(app); +} - try { - // Check permission first - const permission = await this.requestPermission(); - if (permission !== 'granted') { - logger.warn({ - message: 'Notification permission not granted', - context: { permission }, - }); - return null; - } - - // Subscribe to push manager - this.pushSubscription = await this.registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: this.urlBase64ToArrayBuffer(vapidPublicKey), - }); +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 + ); +} - logger.info({ - message: 'Successfully subscribed to push notifications', - context: { - endpoint: this.pushSubscription.endpoint, - }, - }); +async function mintBrowserToken(config: WebPushFirebaseConfig): Promise { + const messaging = await loadMessaging(config); + if (!messaging) { + return null; + } - return this.pushSubscription; - } catch (error) { - logger.error({ - message: 'Failed to subscribe to push notifications', - context: { error }, - }); - 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 + } +} + +// --- Desktop (Electron main process) --- + +async function startDesktop(bridge: DesktopPushBridge, config: WebPushFirebaseConfig): Promise { + const result = await bridge.pushStart(config); + if (!result?.token) { + logger.warn({ message: 'Desktop push could not start', context: { error: result?.error } }); + return null; + } + + return result.token; +} + +async function deleteLocalToken(config: WebPushFirebaseConfig | null): Promise { + const desktop = getDesktopBridge(); + if (desktop) { + // Forgetting the credentials leaves nothing on this device able to receive with the old token. + await desktop.pushStop(true).catch(() => undefined); + return; } - /** - * Unsubscribe from push notifications - */ - async unsubscribe(): Promise { + if (browserCanPush()) { + await deleteBrowserToken(config); + } +} + +// --- Registration --- + +async function unregisterQuietly(registration: StoredRegistration): Promise { + try { + await unRegisterWebPush({ Token: registration.token, Prefix: registration.prefix, UnitId: registration.unitId }); + } 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 unit or department on this device: take the device off the old one, then rotate the token so + // the old channel holds a dead one even if that call failed. + await unregisterQuietly(stored); + writeRegistration(null); + await deleteLocalToken(config); + } + + const desktop = getDesktopBridge(); + if (!desktop && Notification.permission !== 'granted') { + notify(); + return; + } + + const token = desktop ? await startDesktop(desktop, config) : 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 registerUnitDevice({ UnitId: identity.unitId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getDeviceUuid() || '', Prefix: identity.prefix }); + writeRegistration({ key: keyOf(identity), unitId: identity.unitId, prefix: identity.prefix, token, registeredAt: Date.now() }); + notify(); +} + +let syncQueue: Promise = Promise.resolve(); +let askArmed = false; + +/** Brings this device's registration in line with the signed-in unit. 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 (getDesktopBridge() || !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 { - // If pushSubscription is null, try to retrieve it from the push manager as a fallback - if (!this.pushSubscription && this.registration) { - try { - this.pushSubscription = await this.registration.pushManager.getSubscription(); - } catch (error) { - logger.error({ - message: 'Failed to retrieve active push subscription during unsubscribe', - context: { error }, - }); - } - } - - if (!this.pushSubscription) { - logger.info({ - message: 'No active push subscription found to unsubscribe', - }); - return true; - } - - const success = await this.pushSubscription.unsubscribe(); - - if (success) { - this.pushSubscription = null; - - // Clear any potential persisted client-side records - // Explicitly clearing any typical local storage keys as a safety measure - try { - localStorage.removeItem('push_subscription'); - localStorage.removeItem('push_endpoint'); - } catch (storageError) { - // Ignore errors from localStorage if it's not available - } - - logger.info({ - message: 'Successfully unsubscribed from push notifications', - }); - } else { - logger.warn({ - message: 'Push subscription unsubscribe returned false', - }); - } - - return success; - } catch (error) { - logger.error({ - message: 'Failed to unsubscribe from push notifications', - context: { error }, + 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, UnitId: stored.unitId }), }); - return false; + } catch (error) { + logger.warn({ message: 'Web push: the token could not be unregistered at sign-out', context: { error } }); } } - /** - * Get the current push subscription - */ - getSubscription(): PushSubscription | null { - return this.pushSubscription; + if (stored || getDesktopBridge()) { + await deleteLocalToken(getWebPushConfig()); } - /** - * Show a local notification (for testing or immediate notifications) - */ - async showLocalNotification(title: string, body: string, data?: any): Promise { - const permission = await this.requestPermission(); - if (permission !== 'granted') { + notify(); +}); + +// --- Incoming pushes --- + +function showInApp(payload: WebPushPayload): void { + if (!payload?.eventCode) { + return; + } + + void usePushNotificationModalStore.getState().showNotificationModal({ + eventCode: payload.eventCode, + title: payload.title, + body: payload.body, + data: { ...payload }, + }); +} + +async function deepLink(href: Href, eventCode: string): Promise { + try { + await routerPushWithRetry(href, DEEP_LINK_RETRY); + return true; + } catch (error) { + logger.error({ message: 'Failed to deep-link from a web push', context: { error, eventCode } }); + return false; + } +} + +/** A clicked notification goes where a tapped one does on the phone: chat or call, else the in-app alert. */ +export async function openWebPush(payload: WebPushPayload): Promise { + const eventCode = payload?.eventCode; + if (!eventCode) { + return; + } + + const chat = /^([tg]):(.+)$/i.exec(eventCode); + if (chat && isSafeRouteId(chat[2])) { + if (await deepLink({ pathname: '/chat/[channelId]', params: { channelId: chat[2] } }, eventCode)) { + return; + } + } else { + const parsed = parseNotificationData({ eventCode }); + if (parsed.type === 'call' && isSafeRouteId(parsed.id) && (await deepLink({ pathname: '/call/[id]', params: { id: parsed.id } }, eventCode))) { return; } + } - // Use the Notification API directly - const notification = new Notification(title, { - body, - icon: '/icon-192.png', - badge: '/badge-72.png', - data, - requireInteraction: true, - tag: data?.eventCode || 'notification', - }); - - notification.onclick = () => { - window.focus(); - notification.close(); - - if (data?.eventCode) { - usePushNotificationModalStore.getState().showNotificationModal({ - eventCode: data.eventCode, - title, - body, - data, - }); - } - }; + showInApp(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 (typeof document !== 'undefined' && document.visibilityState === 'visible' && document.hasFocus()) { + showInApp(payload); } +} - /** - * Convert VAPID key from base64url to ArrayBuffer. - * - * @returns An ArrayBuffer containing the decoded key bytes. - */ - private urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const rawData = window.atob(base64); - const buffer = new ArrayBuffer(rawData.length); - const outputArray = new Uint8Array(buffer); - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return buffer; +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') { + return; + } + + const desktop = getDesktopBridge(); + if (desktop) { + const offReceived = desktop.onPushReceived(showInApp); + const offClick = desktop.onPushNotificationClick((payload) => void openWebPush(payload)); + detachListeners = () => { + offReceived(); + offClick(); + }; + + // A click that started (or re-opened) the window arrived before this page could listen. + void desktop + .pushTakePendingClick() + .then((payload) => payload && openWebPush(payload)) + .catch(() => undefined); + return; } - /** - * Check if push notifications are supported - */ - static isSupported(): boolean { - return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; + if (!('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); } -export const webPushNotificationService = WebPushNotificationService.getInstance(); +/** Test hook. */ +export function _resetWebPushForTests(): void { + detachListeners?.(); + detachListeners = null; + syncQueue = Promise.resolve(); + askArmed = false; + listeners.clear(); +} + +// --- The app's push API, as the native module offers it --- -// Alias for cross-platform compatibility -export const pushNotificationService = webPushNotificationService; +export const pushNotificationService = { + initialize: async (): Promise => attachWebPushListeners(), + getPushToken: (): string | null => readRegistration()?.token ?? null, + // The sign-out hook already took the token off the server before the session ended. + unregisterFromPushNotifications: async (): Promise => undefined, + refreshAndroidNotificationChannels: async (): Promise => undefined, +}; -// React hook for component usage (web stub) +/** Keeps this device registered for the active unit while signed in. */ export const usePushNotifications = () => { - return { - pushToken: null, - }; + const authStatus = useAuthStore((state) => state.status); + const activeUnitId = useCoreStore((state) => state.activeUnitId); + const hasConfig = useCoreStore((state) => !!state.config?.WebPushProjectId); + const departmentCode = securityStore((state) => state.rights?.DepartmentCode); + const [pushToken, setPushToken] = useState(() => readRegistration()?.token ?? null); + + useEffect(() => { + attachWebPushListeners(); + return subscribeWebPush(() => setPushToken(readRegistration()?.token ?? null)); + }, []); + + useEffect(() => { + // Registering before auth settles would send a stale token (see the native hook). + if (authStatus !== 'signedIn' || !useAuthStore.getState().accessToken || !activeUnitId || !departmentCode || !hasConfig) { + return; + } + + syncWebPush().catch((error) => logger.error({ message: 'Web push registration failed', context: { error } })); + askForPermissionOnce(); + }, [authStatus, activeUnitId, departmentCode, hasConfig]); + + return { pushToken }; }; diff --git a/src/stores/auth/__tests__/store-cold-start.test.ts b/src/stores/auth/__tests__/store-cold-start.test.ts index 606d23d..bb4c8fc 100644 --- a/src/stores/auth/__tests__/store-cold-start.test.ts +++ b/src/stores/auth/__tests__/store-cold-start.test.ts @@ -45,6 +45,7 @@ jest.mock('@/lib/cache/cache-scope', () => ({ })); import { registerSessionCleanupHandler } from '@/lib/auth/session-cleanup'; +import { _clearSignOutHooks, registerSignOutHook } from '@/lib/auth/sign-out-hooks'; import { resetInFlightRefresh } from '../../../lib/auth/refresh-lock'; import useAuthStore, { restoreSession } from '../store'; @@ -313,6 +314,34 @@ describe('auth store cold start', () => { expect(sessionCleanup).toHaveBeenCalledTimes(1); }); + it('runs the sign-out hooks once, with the session still signed in, before anything is cleared', async () => { + const seen: { token: string | null; status: string }[] = []; + registerSignOutHook(async (token) => { + seen.push({ token, status: useAuthStore.getState().status }); + }); + useAuthStore.setState({ accessToken: 'live-access-token', refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + await Promise.all([useAuthStore.getState().logout(), useAuthStore.getState().logout()]); + + expect(seen).toEqual([{ token: 'live-access-token', status: 'signedIn' }]); + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(sessionCleanup).toHaveBeenCalledTimes(1); + _clearSignOutHooks(); + }); + + it('still signs out when a sign-out hook fails', async () => { + registerSignOutHook(async () => { + throw new Error('server unreachable'); + }); + useAuthStore.setState({ accessToken: 'live-access-token', refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + await useAuthStore.getState().logout(); + + expect(useAuthStore.getState().status).toBe('signedOut'); + expect(sessionCleanup).toHaveBeenCalledTimes(1); + _clearSignOutHooks(); + }); + it('still performs a later logout after the guard has settled', async () => { useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); await useAuthStore.getState().logout(); diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 14c1c2e..666f87b 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -10,6 +10,7 @@ import { loginRequest, ssoExternalTokenRequest } from '../../lib/auth/api'; import { decodeJwtPayload, getJwtExpiryMs } from '../../lib/auth/jwt'; import { refreshTokenSingleFlight } from '../../lib/auth/refresh-lock'; import { runSessionCleanup } from '../../lib/auth/session-cleanup'; +import { runSignOutHooks } from '../../lib/auth/sign-out-hooks'; import { isRefreshCredentialRejection, isSharedSessionLockedRefresh } from '../../lib/auth/token-refresh'; import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types'; import { type ProfileModel } from '../../lib/auth/types'; @@ -292,6 +293,17 @@ const useAuthStore = create()( } logoutInFlight = (async () => { + // Whatever must still reach the server as this session (this device's web push token) goes + // first: the token is cleared just below. Bounded, so a dead server never holds sign-out up. + try { + await runSignOutHooks(get().accessToken); + } catch (error) { + logger.warn({ + message: 'A sign-out hook failed', + context: { error }, + }); + } + // Clear any pending refresh timer to prevent stacked timeouts const existingTimeoutId = get().refreshTimeoutId; if (existingTimeoutId !== null) { diff --git a/yarn.lock b/yarn.lock index 387a6ce..5c4539c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1481,6 +1481,16 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== +"@eneris/push-receiver@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eneris/push-receiver/-/push-receiver-4.4.0.tgz#85b01d85b32500f8032acac822f47f4e27a29bb0" + integrity sha512-53ZYUV92Glt1+igZH1HjJI5ATHA96EmFd6U9XW9dNnzS5gv/DKoNbU0MAJ+FXnTNTKdaqXwAxyFBeOMdKEk+Pg== + dependencies: + http_ece "^1.2.1" + jsonwebtoken "^9.0.2" + long "^5.2.3" + protobufjs "^7.4.0" + "@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.2.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" @@ -1975,6 +1985,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.3": version "1.7.3" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7" @@ -2126,6 +2530,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" + "@hapi/address@^5.1.1": version "5.1.1" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-5.1.1.tgz#e9925fc1b65f5cc3fbea821f2b980e4652e84cb6" @@ -3218,6 +3640,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" @@ -4836,6 +5305,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/node@^24.9.0": version "24.10.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.9.tgz#1aeb5142e4a92957489cac12b07f9c7fe26057d0" @@ -6119,6 +6595,11 @@ buffer-crc32@~0.2.3: resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-equal@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b" @@ -7395,6 +7876,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -8726,6 +9214,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" @@ -8909,6 +9404,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" @@ -9589,6 +10118,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" @@ -9614,6 +10148,11 @@ http2-wrapper@^1.0.0-beta.5.2: quick-lru "^5.1.1" resolve-alpn "^1.0.0" +http_ece@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.2.1.tgz#60c324404c477bea388e18bdd51819e988eba07e" + integrity sha512-+tzLoMYgXvicu60sVFoswTiu6BiQ6EX3DORRJQ3W2dNpNWCyZ3tcmRFZZ3jgVyw8ziWUCeUARKCkYDY6JgFx+w== + https-proxy-agent@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -9681,6 +10220,11 @@ iconv-lite@^0.7.0: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +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.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -11113,6 +11657,22 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== +jsonwebtoken@^9.0.2: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + "jsx-ast-utils@^2.4.1 || ^3.0.0": version "3.3.5" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" @@ -11123,6 +11683,23 @@ jsonparse@^1.2.0: object.assign "^4.1.4" object.values "^1.1.6" +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + kdbush@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39" @@ -11418,11 +11995,36 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + lodash.kebabcase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" @@ -11443,6 +12045,11 @@ lodash.mergewith@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash.snakecase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" @@ -11543,6 +12150,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.2.3, 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" @@ -13430,6 +14042,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, protobufjs@^7.4.0: + 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.0" resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" @@ -13536,6 +14165,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" @@ -14308,7 +14942,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.0.1, 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== @@ -15778,6 +16412,11 @@ undici-types@~7.16.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== +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" @@ -16093,6 +16732,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" @@ -16115,6 +16759,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" @@ -16482,6 +17140,19 @@ yargs@17.7.2, yargs@^17.0.0, yargs@^17.0.1, yargs@^17.3.1, yargs@^17.6.2: y18n "^5.0.5" yargs-parser "^21.1.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== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" From fe602aa9afbee5c632c1c48ecc0b4e70d005ef45 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 4 Oct 2026 10:16:15 -0700 Subject: [PATCH 3/4] RG-T139 PR #290 fixes --- electron/__tests__/push-receiver.test.ts | 48 ++++++++++++- electron/push-receiver.js | 43 +++++++++-- public/service-worker.js | 5 +- .../__tests__/location-history-panel.test.tsx | 57 ++++++++++++++- .../calls/location-history-panel.tsx | 37 ++++++---- .../__tests__/push-notification.web.test.ts | 72 +++++++++++++++++++ src/services/__tests__/service-worker.test.ts | 15 ++++ src/services/push-notification.web.ts | 62 ++++++++++------ src/stores/auth/store.tsx | 1 + .../__tests__/location-history-store.test.ts | 19 +++++ src/stores/calls/location-history-store.ts | 11 ++- 11 files changed, 323 insertions(+), 47 deletions(-) diff --git a/electron/__tests__/push-receiver.test.ts b/electron/__tests__/push-receiver.test.ts index d84c0cb..0248188 100644 --- a/electron/__tests__/push-receiver.test.ts +++ b/electron/__tests__/push-receiver.test.ts @@ -122,7 +122,10 @@ describe('push:start', () => { const saved = JSON.parse(fs.readFileSync(storePath, 'utf8')); expect(saved.credentials).toEqual({ fcm: { token: 'token-1' } }); - expect(fs.statSync(storePath).mode & 0o777).toBe(0o600); + // Windows has no POSIX permission bits to check. + if (process.platform !== 'win32') { + expect(fs.statSync(storePath).mode & 0o777).toBe(0o600); + } // A restart reuses the saved credentials, so the token the server holds stays valid. const restarted = setup({ storePath }); @@ -196,6 +199,17 @@ describe('incoming pushes', () => { expect(send).toHaveBeenCalledWith('push:notification-click', expect.objectContaining({ eventCode: 'C1234' })); }); + it('hands a push to the page when the system has no native notifications', async () => { + const { ipcMain, notify, send } = setup(); + notify.mockReturnValue(false); + await ipcMain.invoke('push:start', firebase); + + FakeReceiver.created[0].notificationListener!(fcmMessage('C1234')); + + expect(notify).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('push:received', expect.objectContaining({ eventCode: 'C1234' })); + }); + it('keeps a click for a page that is not ready, until it asks', async () => { const { ipcMain, notify, send } = setup(); send.mockReturnValue(false); @@ -235,6 +249,38 @@ describe('push:stop', () => { await expect(ipcMain.invoke('push:start', firebase)).resolves.toEqual({ token: 'token-2' }); }); + it('stops a start still registering: nothing saved, connected or handed back, and the next start is not cut short', async () => { + const pending: (() => void)[] = []; + const { ipcMain, storePath } = setup({ + createReceiver: (config: ConstructorParameters[0]) => { + const receiver = new FakeReceiver(config); + const register = receiver.registerIfNeeded.bind(receiver); + receiver.registerIfNeeded = () => new Promise((resolve) => pending.push(() => resolve(register()))); + return receiver; + }, + }); + + const signedOut = ipcMain.invoke('push:start', firebase); + await ipcMain.invoke('push:stop', true); + const signedIn = ipcMain.invoke('push:start', firebase); + + pending[0](); + await expect(signedOut).resolves.toEqual({ error: 'no-token' }); + expect(FakeReceiver.created[0].destroyed).toBe(true); + expect(FakeReceiver.created[0].connected).toBe(false); + expect(fs.existsSync(storePath)).toBe(false); + + // The overtaken start settling must not free the slot the running one holds. + const repeat = ipcMain.invoke('push:start', firebase); + expect(FakeReceiver.created).toHaveLength(2); + + pending[1](); + await expect(signedIn).resolves.toEqual({ token: 'token-2' }); + await expect(repeat).resolves.toEqual({ token: 'token-2' }); + expect(FakeReceiver.created[1].connected).toBe(true); + expect(JSON.parse(fs.readFileSync(storePath, 'utf8')).credentials).toEqual({ fcm: { token: 'token-2' } }); + }); + it('keeps the credentials when only stopping', async () => { const { ipcMain, storePath, receiver } = setup(); await ipcMain.invoke('push:start', firebase); diff --git a/electron/push-receiver.js b/electron/push-receiver.js index 343eb82..8783a99 100644 --- a/electron/push-receiver.js +++ b/electron/push-receiver.js @@ -76,6 +76,9 @@ function registerPushReceiver(ipcMain, options) { let receiverKey = null; let starting = null; let pendingClick = null; + // Bumped by every stop. A start that a stop overtook (a sign-out while it was registering) must not save credentials, + // connect or hand out a token: the stop already destroyed its receiver and forgot what it had. + let generation = 0; function load() { try { @@ -129,10 +132,19 @@ function registerPushReceiver(ipcMain, options) { return; } - options.notify(payload, () => deliverClick(payload)); + if (options.notify(payload, () => deliverClick(payload))) { + return; + } + + // No native notifications on this system: the page's in-app alert is all that is left, focused or not. + if (!options.send('push:received', payload)) { + log.warn('Desktop push: a push could not be shown', { eventCode: payload.eventCode }); + } } function stop(shouldForget) { + generation += 1; + if (receiver) { try { receiver.destroy(); @@ -157,6 +169,8 @@ function registerPushReceiver(ipcMain, options) { } stop(false); + const startGeneration = generation; + const isCurrent = () => generation === startGeneration; const state = load(); // Credentials minted for another Firebase app or key can't receive this one's pushes. @@ -171,7 +185,10 @@ function registerPushReceiver(ipcMain, options) { }); instance.onCredentialsChanged(({ newCredentials }) => { - save({ ...load(), configKey: key, credentials: newCredentials, persistentIds: [] }); + // Saved after a sign-out forgot the old ones, these would be back on disk for the next launch. + if (isCurrent()) { + save({ ...load(), configKey: key, credentials: newCredentials, persistentIds: [] }); + } }); instance.onNotification(handleMessage); @@ -180,7 +197,19 @@ function registerPushReceiver(ipcMain, options) { // The token exists once registration is done; the connection that delivers pushes can come up after // (and keeps retrying on its own), so the page can register without waiting on it. - await instance.registerIfNeeded(); + try { + await instance.registerIfNeeded(); + } catch (error) { + if (!isCurrent()) { + return null; + } + throw error; + } + + if (!isCurrent()) { + return null; + } + instance.connect().catch((error) => log.warn('Desktop push: connection failed', error)); return instance.fcmToken; @@ -192,9 +221,13 @@ function registerPushReceiver(ipcMain, options) { } if (!starting) { - starting = start(firebase).finally(() => { - starting = null; + // A start a stop overtook settles after the next one began: only the newest may clear the slot. + const run = start(firebase).finally(() => { + if (starting === run) { + starting = null; + } }); + starting = run; } try { diff --git a/public/service-worker.js b/public/service-worker.js index 4ef336c..fb526f6 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -58,7 +58,10 @@ self.addEventListener('push', (event) => { self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => { windows.forEach((client) => client.postMessage({ type: 'PUSH_RECEIVED', data: push })); }), - ]) + ]).catch((error) => { + // A rejected waitUntil is dropped without a word: this is the only trace a failed push leaves. + console.error('Web push: the push could not be handled', { eventCode: push.eventCode, error }); + }) ); }); diff --git a/src/components/calls/__tests__/location-history-panel.test.tsx b/src/components/calls/__tests__/location-history-panel.test.tsx index 650b5a2..b4c0cb3 100644 --- a/src/components/calls/__tests__/location-history-panel.test.tsx +++ b/src/components/calls/__tests__/location-history-panel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { router } from 'expo-router'; import React from 'react'; @@ -6,6 +6,7 @@ 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 { dataProtectionStore } from '@/stores/data-protection/store'; import { LocationHistoryPanel } from '../location-history-panel'; @@ -16,7 +17,7 @@ jest.mock('expo-router', () => ({ router: { push: jest.fn() } })); jest.mock('@/stores/data-protection/store', () => { const { create } = jest.requireActual('zustand'); - return { dataProtectionStore: create(() => ({ grantToken: null })) }; + return { dataProtectionStore: create(() => ({ grantToken: null, stepUpExpiresAt: null })) }; }); const mockTrackEvent = jest.fn(); @@ -88,6 +89,7 @@ describe('LocationHistoryPanel', () => { beforeEach(() => { jest.clearAllMocks(); useLocationHistoryStore.setState({ entries: {} }); + dataProtectionStore.setState({ grantToken: null, stepUpExpiresAt: null }); }); it('lists previous calls with their match reasons and expands notes and closing notes', async () => { @@ -150,6 +152,57 @@ describe('LocationHistoryPanel', () => { expect(screen.getByTestId('location-history-empty')).toBeTruthy(); }); + it('shows note times in the device time zone, read from their UTC instants', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + const noted = new Date('2026-10-01T14:20:00Z'); + const pad = (value: number) => String(value).padStart(2, '0'); + const local = `${noted.getFullYear()}-${pad(noted.getMonth() + 1)}-${pad(noted.getDate())} ${pad(noted.getHours())}:${pad(noted.getMinutes())}`; + + render(); + await waitFor(() => expect(screen.getByTestId('location-history-notes-toggle-9012')).toBeTruthy()); + fireEvent.press(screen.getByTestId('location-history-notes-toggle-9012')); + + expect(screen.getByText(`${local} · Taylor Reed`)).toBeTruthy(); + }); + + it('drops revealed history at once when the grant is cleared, without waiting for the redacted reload', async () => { + dataProtectionStore.setState({ grantToken: 'grant', stepUpExpiresAt: Date.now() + 60 * 60 * 1000 }); + mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + render(); + await waitFor(() => expect(screen.getByText('Structure fire')).toBeTruthy()); + + mockCallHistory.mockReturnValueOnce(new Promise(() => undefined)); + act(() => dataProtectionStore.setState({ grantToken: null, stepUpExpiresAt: null })); + + expect(screen.getByTestId('location-history-loading')).toBeTruthy(); + expect(screen.queryByText('Structure fire')).toBeNull(); + expect(mockCallHistory).toHaveBeenCalledTimes(2); + }); + + it('drops revealed history when the grant window lapses', async () => { + dataProtectionStore.setState({ grantToken: 'grant', stepUpExpiresAt: Date.now() + 200 }); + mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + mockCallHistory.mockReturnValueOnce(new Promise(() => undefined)); + render(); + await waitFor(() => expect(screen.getByText('Structure fire')).toBeTruthy()); + + await waitFor(() => expect(screen.getByTestId('location-history-loading')).toBeTruthy()); + expect(screen.queryByText('Structure fire')).toBeNull(); + expect(mockCallHistory).toHaveBeenCalledTimes(2); + }); + + it('keeps the list up while a new grant reloads it', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: baseHistory } as never); + render(); + await waitFor(() => expect(screen.getByText('Structure fire')).toBeTruthy()); + + mockCallHistory.mockReturnValueOnce(new Promise(() => undefined)); + act(() => dataProtectionStore.setState({ grantToken: 'grant', stepUpExpiresAt: Date.now() + 60 * 60 * 1000 })); + + expect(mockCallHistory).toHaveBeenCalledTimes(2); + expect(screen.getByText('Structure fire')).toBeTruthy(); + }); + it('shows the load error when the request fails', async () => { mockCallHistory.mockRejectedValueOnce(new Error('offline')); diff --git a/src/components/calls/location-history-panel.tsx b/src/components/calls/location-history-panel.tsx index 166489f..d482f6f 100644 --- a/src/components/calls/location-history-panel.tsx +++ b/src/components/calls/location-history-panel.tsx @@ -13,7 +13,7 @@ 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 { formatDateForDisplay, parseApiUtcDate } 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'; @@ -31,15 +31,10 @@ const MATCH_STYLES: Record { - if (!value) { - return ''; - } - try { - return formatDateForDisplay(parseDateISOString(value), 'yyyy-MM-dd HH:mm'); - } catch { - return ''; - } + const date = parseApiUtcDate(value); + return date ? formatDateForDisplay(date, 'yyyy-MM-dd HH:mm') : ''; }; interface HistoryCallCardProps { @@ -47,15 +42,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; const loggedOn = call.LoggedOn || formatTimestamp(call.LoggedOnUtc); + const { CallId: callId } = call; + const openCall = useCallback(() => onOpenCall(callId), [onOpenCall, callId]); + const toggleNotes = useCallback(() => setExpanded((value) => !value), []); return ( - onOpenCall(call.CallId)} className="p-3" testID={`location-history-open-${call.CallId}`}> + @@ -97,7 +95,7 @@ const HistoryCallCard: React.FC = ({ call, onOpenCall }) = {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 })} @@ -125,7 +123,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 @@ -141,6 +139,7 @@ export const LocationHistoryPanel: React.FC = ({ sour const fetchHistory = useLocationHistoryStore((state) => state.fetchHistory); const clear = useLocationHistoryStore((state) => state.clear); const grantToken = dataProtectionStore((state) => state.grantToken); + const stepUpExpiresAt = dataProtectionStore((state) => state.stepUpExpiresAt); const { kind, id } = source; React.useEffect(() => { @@ -157,11 +156,21 @@ export const LocationHistoryPanel: React.FC = ({ sour if (previousGrant.current !== grantToken) { previousGrant.current = grantToken; if (id) { - fetchHistory({ kind, id }); + // A new grant only replaces REDACTED values, so the list stays up meanwhile; a cleared one takes what it revealed with it. + fetchHistory({ kind, id }, { discard: !grantToken }); } } }, [grantToken, kind, id, fetchHistory]); + // The grant's window is absolute and nothing clears the token when it lapses: what it revealed goes then. + React.useEffect(() => { + if (!id || !grantToken || stepUpExpiresAt == null) { + return; + } + const timer = setTimeout(() => fetchHistory({ kind, id }, { discard: true }), Math.max(0, stepUpExpiresAt - Date.now())); + return () => clearTimeout(timer); + }, [grantToken, stepUpExpiresAt, kind, id, fetchHistory]); + const history = entry?.history ?? null; React.useEffect(() => { if (history) { diff --git a/src/services/__tests__/push-notification.web.test.ts b/src/services/__tests__/push-notification.web.test.ts index 1904270..ae53e7b 100644 --- a/src/services/__tests__/push-notification.web.test.ts +++ b/src/services/__tests__/push-notification.web.test.ts @@ -66,6 +66,21 @@ let clickListeners: Set<() => void>; /** A click anywhere on the page, as the person would make one. */ const clickPage = () => [...clickListeners].forEach((listener) => listener()); +/** Lets every promise already settled run its callbacks. */ +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** Holds the next FCM token mint; the returned wait resolves, once a sync has reached it, to what releases it. */ +function holdNextMint(): () => Promise<(token: string) => void> { + const held: { release?: (token: string) => void } = {}; + mockFirebaseGetToken.mockImplementationOnce(() => new Promise((resolve) => (held.release = resolve))); + return async () => { + while (!held.release) { + await flush(); + } + return held.release; + }; +} + function installBrowser() { storage = new Map(); worker = { scriptURL: 'https://unit.test/service-worker.js', postMessage: jest.fn() }; @@ -262,6 +277,63 @@ describe('browser', () => { expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); }); + it('takes off the registration a sync in flight at sign-out was still writing', async () => { + permission = 'granted'; + const { push, hooks } = load(); + const reachedMint = holdNextMint(); + + const sync = push.syncWebPush(); + const releaseToken = await reachedMint(); + const signOut = hooks.runSignOutHooks('access-token'); + releaseToken('browser-token'); + await Promise.all([sync, signOut]); + + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(1); + expect(globals.fetch).toHaveBeenCalledWith('https://api.test/api/v4/Devices/UnRegisterWebPush', expect.objectContaining({ body: JSON.stringify({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }) })); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(push.pushNotificationService.getPushToken()).toBeNull(); + }); + + it('registers nothing for a session that ended while its token was minted', async () => { + permission = 'granted'; + const { push } = load(); + const reachedMint = holdNextMint(); + + const sync = push.syncWebPush(); + const releaseToken = await reachedMint(); + mockState.auth = { status: 'signedOut', accessToken: null }; + releaseToken('browser-token'); + await sync; + + expect(mockRegisterUnitDevice).not.toHaveBeenCalled(); + expect(push.pushNotificationService.getPushToken()).toBeNull(); + }); + + it('lets the next sign-in register only once a slow sign-out cleanup is done', async () => { + permission = 'granted'; + const { push, hooks } = load(); + await push.syncWebPush(); + const server: { answer?: () => void } = {}; + (globals.fetch as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => (server.answer = () => resolve({ ok: true })))); + + const signOut = hooks.runSignOutHooks('access-token'); + mockState.core.activeUnitId = '15'; + const signIn = push.syncWebPush(); + await flush(); + await flush(); + + expect(server.answer).toBeDefined(); + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(1); + + server.answer?.(); + await Promise.all([signOut, signIn]); + + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(2); + expect(mockRegisterUnitDevice).toHaveBeenLastCalledWith(expect.objectContaining({ UnitId: '15' })); + expect(mockFirebaseDeleteToken.mock.invocationCallOrder[0]).toBeLessThan(mockRegisterUnitDevice.mock.invocationCallOrder[1]); + }); + }); describe('desktop', () => { diff --git a/src/services/__tests__/service-worker.test.ts b/src/services/__tests__/service-worker.test.ts index c175ac8..65f2944 100644 --- a/src/services/__tests__/service-worker.test.ts +++ b/src/services/__tests__/service-worker.test.ts @@ -63,6 +63,7 @@ function loadWorker(windows: FakeClient[] = []) { }; return { + self, shown, opened, openedClient, @@ -91,6 +92,20 @@ describe('service worker', () => { expect(open.posted).toEqual([{ type: 'PUSH_RECEIVED', data: { title: 'Structure Fire', body: '123 Main St', eventCode: 'C1234', type: '3', category: 'calls' } }]); }); + it('logs a push it could not show instead of dropping the failure', async () => { + const worker = loadWorker(); + const failure = new Error('permission revoked'); + worker.self.registration.showNotification = async () => { + throw failure; + }; + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(worker.push(fcmPush('C1234', 'calls'))).resolves.toBeUndefined(); + + expect(consoleError).toHaveBeenCalledWith('Web push: the push could not be handled', { eventCode: 'C1234', error: failure }); + consoleError.mockRestore(); + }); + it('lets other pushes close on their own, and never shows an empty one', async () => { const worker = loadWorker(); diff --git a/src/services/push-notification.web.ts b/src/services/push-notification.web.ts index fe2c785..a51777e 100644 --- a/src/services/push-notification.web.ts +++ b/src/services/push-notification.web.ts @@ -294,6 +294,13 @@ async function runSync(): Promise { return; } + // Signed out or moved to another unit while the token was minted: the sign-out cleanup or the sync queued behind this + // one owns the device now, and registering here would put the token on a session that has ended. + const latest = currentIdentity(); + if (!latest || keyOf(latest) !== keyOf(identity)) { + return; + } + const current = readRegistration(); if (current && current.key === keyOf(identity) && current.token === token && Date.now() - current.registeredAt < REREGISTER_AFTER_MS) { return; @@ -311,13 +318,18 @@ async function runSync(): Promise { let syncQueue: Promise = Promise.resolve(); let askArmed = false; -/** Brings this device's registration in line with the signed-in unit. Calls run one at a time. */ -export function syncWebPush(): Promise { - const run = syncQueue.then(runSync, runSync); +/** Registration work (syncs and sign-out cleanups) runs one at a time, in the order it was asked for. */ +function enqueue(work: () => Promise): Promise { + const run = syncQueue.then(work, work); syncQueue = run.catch(() => undefined); return run; } +/** Brings this device's registration in line with the signed-in unit. Calls run one at a time. */ +export function syncWebPush(): Promise { + return enqueue(runSync); +} + /** * 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. @@ -352,29 +364,37 @@ export function askForPermissionOnce(): void { document.addEventListener('click', ask, true); } -registerSignOutHook(async (accessToken) => { +async function unregisterAtSignOut(registration: StoredRegistration, accessToken: string): Promise { + // 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: registration.token, Prefix: registration.prefix, UnitId: registration.unitId }), + }); + } catch (error) { + logger.warn({ message: 'Web push: the token could not be unregistered at sign-out', context: { error } }); + } +} + +async function signOutCleanup(accessToken: string | null, config: WebPushFirebaseConfig | null): Promise { 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, UnitId: stored.unitId }), - }); - } catch (error) { - logger.warn({ message: 'Web push: the token could not be unregistered at sign-out', context: { error } }); - } - } - - if (stored || getDesktopBridge()) { - await deleteLocalToken(getWebPushConfig()); - } + // Killing the token here runs beside the server call, not after it: sign-out only waits so long, and this device has + // to stop showing the session's pushes even when the server is slow to answer. + await Promise.all([stored || getDesktopBridge() ? deleteLocalToken(config) : undefined, stored && accessToken ? unregisterAtSignOut(stored, accessToken) : undefined]); notify(); +} + +registerSignOutHook((accessToken) => { + // Read while the session's config is still loaded: the cleanup can run after sign-out has moved on. + const config = getWebPushConfig(); + // Queued behind any sync in flight, so the registration that sync is still writing is the one taken off. The next + // sign-in's sync queues behind this in turn: a cleanup that outlasts sign-out's wait never tears down that session's token. + return enqueue(() => signOutCleanup(accessToken, config)); }); // --- Incoming pushes --- diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 666f87b..4cb85a9 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -300,6 +300,7 @@ const useAuthStore = create()( } catch (error) { logger.warn({ message: 'A sign-out hook failed', + operation: 'signOutHooks', context: { error }, }); } diff --git a/src/stores/calls/__tests__/location-history-store.test.ts b/src/stores/calls/__tests__/location-history-store.test.ts index 43b86b3..ac688e0 100644 --- a/src/stores/calls/__tests__/location-history-store.test.ts +++ b/src/stores/calls/__tests__/location-history-store.test.ts @@ -68,6 +68,25 @@ describe('useLocationHistoryStore', () => { expect(useLocationHistoryStore.getState().entries['call:42'].history?.Calls[0].CallId).toBe('new'); }); + it('keeps the current history while reloading, unless asked to discard it', async () => { + mockCallHistory.mockResolvedValueOnce({ Data: history(['1']) } as never); + await act(async () => { + await useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + }); + + mockCallHistory.mockReturnValue(deferred().promise); + act(() => { + void useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }); + }); + expect(useLocationHistoryStore.getState().entries['call:42']).toEqual(expect.objectContaining({ isLoading: true, history: expect.objectContaining({ Calls: [expect.objectContaining({ CallId: '1' })] }) })); + + act(() => { + void useLocationHistoryStore.getState().fetchHistory({ kind: 'call', id: '42' }, { discard: true }); + }); + expect(useLocationHistoryStore.getState().entries['call:42']).toEqual({ history: null, isLoading: true, error: null }); + mockCallHistory.mockReset(); + }); + it('records an error and clears the history when the request fails', async () => { mockCallHistory.mockRejectedValueOnce(new Error('boom')); diff --git a/src/stores/calls/location-history-store.ts b/src/stores/calls/location-history-store.ts index e67542e..203b85f 100644 --- a/src/stores/calls/location-history-store.ts +++ b/src/stores/calls/location-history-store.ts @@ -17,10 +17,15 @@ export interface LocationHistoryEntry { error: string | null; } +export interface FetchHistoryOptions { + /** Drops what is on screen while the replacement loads: for a grant that ended, whose revealed values must not stay up. */ + discard?: boolean; +} + interface LocationHistoryState { entries: Record; - fetchHistory: (source: LocationHistorySource) => Promise; + fetchHistory: (source: LocationHistorySource, options?: FetchHistoryOptions) => Promise; clear: (source: LocationHistorySource) => void; } @@ -38,11 +43,11 @@ let sequence = 0; export const useLocationHistoryStore = create((set) => ({ entries: {}, - fetchHistory: async (source: LocationHistorySource) => { + fetchHistory: async (source: LocationHistorySource, options?: FetchHistoryOptions) => { 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: options?.discard ? null : (state.entries[key]?.history ?? null), isLoading: true, error: null } } })); try { const result = source.kind === 'call' ? await getCallLocationHistory(source.id) : await getContactCallHistory(source.id); From a5312ddc05ff78293f0e28a43a323a5dfebb5a16 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 4 Oct 2026 12:56:45 -0700 Subject: [PATCH 4/4] RG-T139 PR #290 fixes --- Dockerfile | 5 +- electron/push-receiver.js | 2 +- public/service-worker.js | 2 +- src/api/devices/__tests__/push.test.ts | 34 ++++ src/api/devices/push.ts | 15 +- .../__tests__/push-notification.web.test.ts | 162 +++++++++++++++++- src/services/__tests__/service-worker.test.ts | 2 +- src/services/push-notification.web.ts | 74 ++++++-- 8 files changed, 257 insertions(+), 39 deletions(-) create mode 100644 src/api/devices/__tests__/push.test.ts diff --git a/Dockerfile b/Dockerfile index f27e0a9..5c3097c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,8 @@ COPY --from=build /app/dist /usr/share/nginx/html # Copy entrypoint script COPY docker/docker-entrypoint.sh /docker-entrypoint.sh -RUN chmod +x /docker-entrypoint.sh +# Windows checkouts can supply CRLF; the Linux shebang and shell require LF. +RUN sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh # Expose port 80 EXPOSE 80 @@ -60,4 +61,4 @@ ENV APP_ENV=production \ # Use entrypoint to inject environment variables at runtime ENTRYPOINT ["/docker-entrypoint.sh"] -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/electron/push-receiver.js b/electron/push-receiver.js index 8783a99..1298dd9 100644 --- a/electron/push-receiver.js +++ b/electron/push-receiver.js @@ -138,7 +138,7 @@ function registerPushReceiver(ipcMain, options) { // No native notifications on this system: the page's in-app alert is all that is left, focused or not. if (!options.send('push:received', payload)) { - log.warn('Desktop push: a push could not be shown', { eventCode: payload.eventCode }); + log.warn('Desktop push: a push could not be shown', { operation: 'push', eventCode: payload.eventCode }); } } diff --git a/public/service-worker.js b/public/service-worker.js index fb526f6..d17a7e9 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -60,7 +60,7 @@ self.addEventListener('push', (event) => { }), ]).catch((error) => { // A rejected waitUntil is dropped without a word: this is the only trace a failed push leaves. - console.error('Web push: the push could not be handled', { eventCode: push.eventCode, error }); + console.error('Web push: the push could not be handled', { operation: 'push', eventCode: push.eventCode, error }); }) ); }); diff --git a/src/api/devices/__tests__/push.test.ts b/src/api/devices/__tests__/push.test.ts new file mode 100644 index 0000000..1b20874 --- /dev/null +++ b/src/api/devices/__tests__/push.test.ts @@ -0,0 +1,34 @@ +jest.mock('@/api/common/client', () => ({ createApiEndpoint: jest.fn(() => ({ post: jest.fn() })) })); + +import { createApiEndpoint } from '@/api/common/client'; +import { registerUnitDevice, unRegisterWebPush } from '@/api/devices/push'; + +const [registerEndpoint, unregisterEndpoint] = (createApiEndpoint as jest.Mock).mock.results.map((result) => result.value as { post: jest.Mock }); + +describe('web push requests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards cancellation to the device registration endpoint', async () => { + const controller = new AbortController(); + const input = { UnitId: '12', Token: 'token', Platform: 3, DeviceUuid: 'device', Prefix: 'DEPT' }; + const response = { Data: null }; + registerEndpoint.post.mockResolvedValueOnce({ data: response }); + + await expect(registerUnitDevice(input, controller.signal)).resolves.toBe(response); + + expect(registerEndpoint.post).toHaveBeenCalledWith(input, controller.signal); + }); + + it('forwards cancellation to the web push unregistration endpoint', async () => { + const controller = new AbortController(); + const input = { UnitId: '12', Token: 'token', Prefix: 'DEPT' }; + const response = { Data: null }; + unregisterEndpoint.post.mockResolvedValueOnce({ data: response }); + + await expect(unRegisterWebPush(input, controller.signal)).resolves.toBe(response); + + expect(unregisterEndpoint.post).toHaveBeenCalledWith(input, controller.signal); + }); +}); diff --git a/src/api/devices/push.ts b/src/api/devices/push.ts index 28db024..d466e87 100644 --- a/src/api/devices/push.ts +++ b/src/api/devices/push.ts @@ -1,22 +1,17 @@ +import { createApiEndpoint } from '@/api/common/client'; 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 unRegisterWebPushApi = createApiEndpoint('/Devices/UnRegisterWebPush'); -export const registerUnitDevice = async (data: PushRegistrationUnitInput) => { - const response = await registerUnitDeviceApi.post({ - ...data, - }); +export const registerUnitDevice = async (data: PushRegistrationUnitInput, signal?: AbortSignal) => { + const response = await registerUnitDeviceApi.post({ ...data }, signal); return response.data; }; -export const unRegisterWebPush = async (data: WebPushUnRegistrationInput) => { - const response = await unRegisterWebPushApi.post({ - ...data, - }); +export const unRegisterWebPush = async (data: WebPushUnRegistrationInput, signal?: AbortSignal) => { + const response = await unRegisterWebPushApi.post({ ...data }, signal); return response.data; }; diff --git a/src/services/__tests__/push-notification.web.test.ts b/src/services/__tests__/push-notification.web.test.ts index ae53e7b..061f854 100644 --- a/src/services/__tests__/push-notification.web.test.ts +++ b/src/services/__tests__/push-notification.web.test.ts @@ -143,6 +143,10 @@ beforeEach(() => { globals.fetch = jest.fn(async () => ({ ok: true })); }); +afterEach(() => { + jest.useRealTimers(); +}); + describe('browser', () => { it('registers nothing, and never asks, while Core has no Firebase web app configured', async () => { mockState.core.config = null; @@ -165,7 +169,7 @@ describe('browser', () => { expect(requestPermission).not.toHaveBeenCalled(); expect(mockFirebaseGetToken).toHaveBeenCalledWith('messaging', expect.objectContaining({ vapidKey: 'BVapid' })); - expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', Token: 'browser-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', Token: 'browser-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }, expect.anything()); }); it('asks for permission once, on the first click after sign-in, then registers', async () => { @@ -181,7 +185,7 @@ describe('browser', () => { await new Promise((resolve) => setImmediate(resolve)); expect(requestPermission).toHaveBeenCalledTimes(1); - expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', Token: 'browser-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', Token: 'browser-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }, expect.anything()); clickPage(); expect(requestPermission).toHaveBeenCalledTimes(1); @@ -244,9 +248,9 @@ describe('browser', () => { mockState.core.activeUnitId = '15'; await push.syncWebPush(); - expect(mockUnRegisterWebPush).toHaveBeenCalledWith({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }); + expect(mockUnRegisterWebPush).toHaveBeenCalledWith({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }, expect.anything()); expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); - expect(mockRegisterUnitDevice).toHaveBeenLastCalledWith({ UnitId: '15', Token: 'rotated-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + expect(mockRegisterUnitDevice).toHaveBeenLastCalledWith({ UnitId: '15', Token: 'rotated-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }, expect.anything()); mockFirebaseGetToken.mockResolvedValue('browser-token'); }); @@ -261,6 +265,7 @@ describe('browser', () => { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer access-token', 'X-Resgrid-Client': 'unit' }, body: JSON.stringify({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }), + signal: expect.anything(), }); expect(mockUnRegisterWebPush).not.toHaveBeenCalled(); expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); @@ -309,6 +314,65 @@ describe('browser', () => { expect(push.pushNotificationService.getPushToken()).toBeNull(); }); + it('releases a hung token mint so sign-out removes the old registration and the next sign-in can sync', async () => { + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + permission = 'granted'; + const { push, hooks } = load(); + await push.syncWebPush(); + const reachedMint = holdNextMint(); + const sync = push.syncWebPush(); + const failedSync = expect(sync).rejects.toThrow('timed out'); + const releaseToken = await reachedMint(); + const signOut = hooks.runSignOutHooks('access-token'); + + await jest.advanceTimersByTimeAsync(hooks.SIGN_OUT_HOOK_TIMEOUT_MS); + await signOut; + mockState.auth = { status: 'signedOut', accessToken: null }; + await jest.advanceTimersByTimeAsync(30_000); + await failedSync; + + expect(push.pushNotificationService.getPushToken()).toBeNull(); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(globals.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ body: JSON.stringify({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }) })); + + mockState.auth = { status: 'signedIn', accessToken: 'next-access-token' }; + mockState.core.activeUnitId = '15'; + mockFirebaseGetToken.mockResolvedValue('next-token'); + await push.syncWebPush(); + releaseToken('late-token'); + await flush(); + + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(2); + expect(mockRegisterUnitDevice).toHaveBeenLastCalledWith(expect.objectContaining({ UnitId: '15', Token: 'next-token' }), expect.anything()); + expect(push.pushNotificationService.getPushToken()).toBe('next-token'); + mockFirebaseGetToken.mockResolvedValue('browser-token'); + }); + + it('does not mint a token when service-worker readiness arrives after its timeout', async () => { + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + permission = 'granted'; + const { push, hooks } = load(); + await push.syncWebPush(); + const held: { ready?: (registration: object) => void } = {}; + const registration = { active: null }; + (navigator.serviceWorker.register as jest.Mock).mockResolvedValueOnce(registration); + Object.defineProperty(navigator.serviceWorker, 'ready', { value: new Promise((resolve) => (held.ready = resolve)), configurable: true }); + const sync = push.syncWebPush(); + const failedSync = expect(sync).rejects.toThrow('timed out'); + await flush(); + const signOut = hooks.runSignOutHooks('access-token'); + await jest.advanceTimersByTimeAsync(30_000); + await Promise.all([failedSync, signOut]); + const mints = mockFirebaseGetToken.mock.calls.length; + + held.ready?.(registration); + await flush(); + + expect(mockFirebaseGetToken).toHaveBeenCalledTimes(mints); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(push.pushNotificationService.getPushToken()).toBeNull(); + }); + it('lets the next sign-in register only once a slow sign-out cleanup is done', async () => { permission = 'granted'; const { push, hooks } = load(); @@ -330,10 +394,75 @@ describe('browser', () => { expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(2); - expect(mockRegisterUnitDevice).toHaveBeenLastCalledWith(expect.objectContaining({ UnitId: '15' })); + expect(mockRegisterUnitDevice).toHaveBeenLastCalledWith(expect.objectContaining({ UnitId: '15' }), expect.anything()); expect(mockFirebaseDeleteToken.mock.invocationCallOrder[0]).toBeLessThan(mockRegisterUnitDevice.mock.invocationCallOrder[1]); }); + it('aborts a hung device registration and leaves its token for sign-out to remove', async () => { + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + permission = 'granted'; + const { push, hooks } = load(); + const held: { registration?: () => void } = {}; + mockRegisterUnitDevice.mockImplementationOnce(() => new Promise((resolve) => (held.registration = () => resolve({})))); + const sync = push.syncWebPush(); + const failedSync = expect(sync).rejects.toThrow('timed out'); + await flush(); + const signal = (mockRegisterUnitDevice.mock.calls as unknown as [unknown, AbortSignal][])[0][1]; + const signOut = hooks.runSignOutHooks('access-token'); + await jest.advanceTimersByTimeAsync(30_000); + await Promise.all([failedSync, signOut]); + + expect(signal.aborted).toBe(true); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(globals.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ body: JSON.stringify({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }) })); + held.registration?.(); + await flush(); + expect(push.pushNotificationService.getPushToken()).toBeNull(); + }); + + it('removes a registration if the unit changed while Core was accepting it', async () => { + permission = 'granted'; + const { push } = load(); + mockRegisterUnitDevice.mockImplementationOnce(async () => { + mockState.core.activeUnitId = '15'; + return {}; + }); + + await push.syncWebPush(); + + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(globals.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ body: JSON.stringify({ Token: 'browser-token', Prefix: 'DEPT', UnitId: '12' }) })); + expect(push.pushNotificationService.getPushToken()).toBeNull(); + }); + + it('retries a failed registration instead of treating its cleanup record as a successful registration', async () => { + permission = 'granted'; + const { push } = load(); + mockRegisterUnitDevice.mockRejectedValueOnce(new Error('offline')); + + await expect(push.syncWebPush()).rejects.toThrow('offline'); + await push.syncWebPush(); + + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(2); + }); + + it('aborts a stalled sign-out request so the next session can register', async () => { + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + permission = 'granted'; + const { push, hooks } = load(); + await push.syncWebPush(); + (globals.fetch as jest.Mock).mockImplementationOnce(() => new Promise(() => undefined)); + const signOut = hooks.runSignOutHooks('access-token'); + await flush(); + const signal = (globals.fetch as jest.Mock).mock.calls[0][1].signal as AbortSignal; + const signIn = push.syncWebPush(); + await jest.advanceTimersByTimeAsync(30_000); + await Promise.all([signOut, signIn]); + + expect(signal.aborted).toBe(true); + expect(mockFirebaseDeleteToken).toHaveBeenCalledTimes(1); + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(2); + }); }); describe('desktop', () => { @@ -356,7 +485,7 @@ describe('desktop', () => { await push.syncWebPush(); expect(bridge.pushStart).toHaveBeenCalledWith({ apiKey: 'api-key', authDomain: undefined, projectId: 'resgrid-web', messagingSenderId: '343968022249', appId: '1:343968022249:web:abc', vapidKey: 'BVapid' }); - expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', Token: 'desktop-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }); + expect(mockRegisterUnitDevice).toHaveBeenCalledWith({ UnitId: '12', Token: 'desktop-token', Platform: 3, DeviceUuid: 'device-uuid', Prefix: 'DEPT' }, expect.anything()); push.askForPermissionOnce(); clickPage(); expect(requestPermission).not.toHaveBeenCalled(); @@ -371,6 +500,27 @@ describe('desktop', () => { expect(bridge.pushStop).toHaveBeenCalledWith(true); }); + it('stops a hung desktop start after its timeout and ignores its late token', async () => { + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + const { push, hooks } = load(); + await push.syncWebPush(); + const held: { start?: (result: { token: string }) => void } = {}; + bridge.pushStart.mockImplementationOnce(() => new Promise((resolve) => (held.start = resolve))); + const sync = push.syncWebPush(); + const failedSync = expect(sync).rejects.toThrow('timed out'); + await flush(); + const signOut = hooks.runSignOutHooks('access-token'); + + await jest.advanceTimersByTimeAsync(30_000); + await Promise.all([failedSync, signOut]); + expect(bridge.pushStop).toHaveBeenCalledWith(true); + expect(push.pushNotificationService.getPushToken()).toBeNull(); + + held.start?.({ token: 'late-desktop-token' }); + await flush(); + expect(mockRegisterUnitDevice).toHaveBeenCalledTimes(1); + }); + it('registers nothing when the main process could not start', async () => { bridge.pushStart.mockResolvedValue({ error: 'PHONE_REGISTRATION_ERROR' }); const { push } = load(); diff --git a/src/services/__tests__/service-worker.test.ts b/src/services/__tests__/service-worker.test.ts index 65f2944..d9b48cd 100644 --- a/src/services/__tests__/service-worker.test.ts +++ b/src/services/__tests__/service-worker.test.ts @@ -102,7 +102,7 @@ describe('service worker', () => { await expect(worker.push(fcmPush('C1234', 'calls'))).resolves.toBeUndefined(); - expect(consoleError).toHaveBeenCalledWith('Web push: the push could not be handled', { eventCode: 'C1234', error: failure }); + expect(consoleError).toHaveBeenCalledWith('Web push: the push could not be handled', { operation: 'push', eventCode: 'C1234', error: failure }); consoleError.mockRestore(); }); diff --git a/src/services/push-notification.web.ts b/src/services/push-notification.web.ts index a51777e..558509c 100644 --- a/src/services/push-notification.web.ts +++ b/src/services/push-notification.web.ts @@ -82,6 +82,25 @@ 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; +/** A stalled SDK or IPC operation must release the queue so sign-out can remove the previous token. */ +const PUSH_OPERATION_TIMEOUT_MS = 10_000; + +async function withPushTimeout(operation: string, work: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`Web push: ${operation} timed out`)); + controller.abort(); + }, PUSH_OPERATION_TIMEOUT_MS); + }); + + try { + return await Promise.race([work(controller.signal), timeout]); + } finally { + clearTimeout(timer); + } +} const DEEP_LINK_RETRY = { maxAttempts: 40, @@ -167,7 +186,7 @@ const keyOf = (identity: UnitIdentity): string => `unit:${identity.unitId}:${ide // --- Browser (Firebase JS SDK) --- async function loadMessaging(config: WebPushFirebaseConfig) { - if (!(await isSupported())) { + if (!(await withPushTimeout('messaging support', () => isSupported()))) { return null; } @@ -180,7 +199,7 @@ async function loadMessaging(config: WebPushFirebaseConfig) { } async function findServiceWorker(): Promise { - const registrations = await navigator.serviceWorker.getRegistrations(); + const registrations = await withPushTimeout('find service worker', () => navigator.serviceWorker.getRegistrations()); return ( registrations.find((registration) => { const worker = registration.active ?? registration.waiting ?? registration.installing; @@ -195,12 +214,13 @@ async function mintBrowserToken(config: WebPushFirebaseConfig): Promise navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: '/' })); if (!serviceWorkerRegistration.active) { - await navigator.serviceWorker.ready; + await withPushTimeout('service worker readiness', () => navigator.serviceWorker.ready); } - return (await getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration })) || null; + // Each wait is bounded inside the mint: a late worker must not start getToken after this operation timed out. + return (await withPushTimeout('mint browser token', () => getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration }))) || null; } /** Kills the browser's token at FCM, so every channel still holding it stops delivering here. */ @@ -214,7 +234,7 @@ async function deleteBrowserToken(config: WebPushFirebaseConfig | null): Promise 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 withPushTimeout('read browser token', () => getToken(messaging, { vapidKey: config.vapidKey, serviceWorkerRegistration: registration })); await deleteToken(messaging); return; } @@ -234,7 +254,7 @@ async function deleteBrowserToken(config: WebPushFirebaseConfig | null): Promise // --- Desktop (Electron main process) --- async function startDesktop(bridge: DesktopPushBridge, config: WebPushFirebaseConfig): Promise { - const result = await bridge.pushStart(config); + const result = await withPushTimeout('start desktop receiver', () => bridge.pushStart(config)); if (!result?.token) { logger.warn({ message: 'Desktop push could not start', context: { error: result?.error } }); return null; @@ -260,7 +280,7 @@ async function deleteLocalToken(config: WebPushFirebaseConfig | null): Promise { try { - await unRegisterWebPush({ Token: registration.token, Prefix: registration.prefix, UnitId: registration.unitId }); + await withPushTimeout('unregister previous token', (signal) => unRegisterWebPush({ Token: registration.token, Prefix: registration.prefix, UnitId: registration.unitId }, signal)); } catch (error) { logger.warn({ message: 'Web push: the previous registration could not be removed', context: { error } }); } @@ -269,6 +289,7 @@ async function unregisterQuietly(registration: StoredRegistration): Promise { const identity = currentIdentity(); const config = getWebPushConfig(); + const accessToken = useAuthStore.getState().accessToken; if (!identity || !config) { return; } @@ -310,8 +331,20 @@ async function runSync(): Promise { await unregisterQuietly(current); } - await registerUnitDevice({ UnitId: identity.unitId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getDeviceUuid() || '', Prefix: identity.prefix }); - writeRegistration({ key: keyOf(identity), unitId: identity.unitId, prefix: identity.prefix, token, registeredAt: Date.now() }); + const registration = { key: keyOf(identity), unitId: identity.unitId, prefix: identity.prefix, token, registeredAt: Date.now() }; + try { + await withPushTimeout('register unit device', (signal) => registerUnitDevice({ UnitId: identity.unitId, Token: token, Platform: WEB_PLATFORM, DeviceUuid: getDeviceUuid() || '', Prefix: identity.prefix }, signal)); + } catch (error) { + // A timed-out request may have reached Core. Keep the token available for the queued sign-out cleanup. + writeRegistration({ ...registration, registeredAt: 0 }); + throw error; + } + const registeredIdentity = currentIdentity(); + if (!registeredIdentity || keyOf(registeredIdentity) !== keyOf(identity)) { + await Promise.all([accessToken ? unregisterAtSignOut(registration, accessToken) : undefined, deleteLocalToken(config)]); + return; + } + writeRegistration(registration); notify(); } @@ -368,13 +401,16 @@ async function unregisterAtSignOut(registration: StoredRegistration, 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: registration.token, Prefix: registration.prefix, UnitId: registration.unitId }), - }); + await withPushTimeout('unregister at sign-out', (signal) => + fetch(`${getBaseApiUrl()}/Devices/UnRegisterWebPush`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, [CLIENT_HEADER]: RESGRID_CLIENT }, + body: JSON.stringify({ Token: registration.token, Prefix: registration.prefix, UnitId: registration.unitId }), + signal, + }) + ); } catch (error) { - logger.warn({ message: 'Web push: the token could not be unregistered at sign-out', context: { error } }); + logger.warn({ message: 'Web push: the token could not be unregistered at sign-out', operation: 'signOutCleanup', context: { error } }); } } @@ -384,7 +420,7 @@ async function signOutCleanup(accessToken: string | null, config: WebPushFirebas // Killing the token here runs beside the server call, not after it: sign-out only waits so long, and this device has // to stop showing the session's pushes even when the server is slow to answer. - await Promise.all([stored || getDesktopBridge() ? deleteLocalToken(config) : undefined, stored && accessToken ? unregisterAtSignOut(stored, accessToken) : undefined]); + await Promise.all([deleteLocalToken(config), stored && accessToken ? unregisterAtSignOut(stored, accessToken) : undefined]); notify(); } @@ -394,7 +430,9 @@ registerSignOutHook((accessToken) => { const config = getWebPushConfig(); // Queued behind any sync in flight, so the registration that sync is still writing is the one taken off. The next // sign-in's sync queues behind this in turn: a cleanup that outlasts sign-out's wait never tears down that session's token. - return enqueue(() => signOutCleanup(accessToken, config)); + return enqueue(() => signOutCleanup(accessToken, config)).catch((error) => { + logger.warn({ message: 'Web push sign-out cleanup failed', operation: 'signOutCleanup', context: { error } }); + }); }); // --- Incoming pushes ---