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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,17 @@ jobs:
node-version: '24'
cache: 'yarn'

- name: 📦 Setup yarn cache
uses: actions/cache@v3
# node_modules is post-patch-package state, so patches/ must be part of the key:
# restoring an already-patched tree built from a different patches/ revision makes
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
# back node_modules patched by some other revision. The yarn tarball cache is handled
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
# re-download.
- name: 📦 Setup node_modules cache
uses: actions/cache@v4
with:
path: |
~/.cache/yarn
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}

- name: 📦 Install dependencies
run: yarn install --frozen-lockfile
Expand Down Expand Up @@ -172,15 +174,17 @@ jobs:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}

- name: 📦 Setup yarn cache
uses: actions/cache@v3
# node_modules is post-patch-package state, so patches/ must be part of the key:
# restoring an already-patched tree built from a different patches/ revision makes
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
# back node_modules patched by some other revision. The yarn tarball cache is handled
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
# re-download.
- name: 📦 Setup node_modules cache
uses: actions/cache@v4
with:
path: |
~/.cache/yarn
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}

- name: 📦 Install dependencies
run: |
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/app/call/new/address-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ describe('Address Search Logic', () => {
NovuApplicationId: '',
AnalyticsApiKey: '',
AnalyticsHost: '',
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
};

beforeEach(() => {
Expand Down Expand Up @@ -166,6 +169,9 @@ describe('Address Search Logic', () => {
NovuApplicationId: '',
AnalyticsApiKey: '',
AnalyticsHost: '',
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
};

const result = await performAddressSearch('123 Main St', configWithoutKey);
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/app/call/new/coordinates-search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ describe('Coordinates Search Logic', () => {
NovuApplicationId: '',
AnalyticsApiKey: '',
AnalyticsHost: '',
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
};

beforeEach(() => {
Expand Down Expand Up @@ -272,6 +275,9 @@ describe('Coordinates Search Logic', () => {
NovuApplicationId: '',
AnalyticsApiKey: '',
AnalyticsHost: '',
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
};

const result = await performCoordinatesSearch('40.7128, -74.0060', configWithoutKey);
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/app/call/new/plus-code-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ describe('Plus Code Search Logic', () => {
NovuApplicationId: '',
AnalyticsApiKey: '',
AnalyticsHost: '',
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
};

beforeEach(() => {
Expand Down Expand Up @@ -139,6 +142,9 @@ describe('Plus Code Search Logic', () => {
NovuApplicationId: '',
AnalyticsApiKey: '',
AnalyticsHost: '',
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
};

const result = await performPlusCodeSearch('849VCWC8+R9', configWithoutKey);
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/app/call/new/what3words.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const mockConfig: GetConfigResultData = {
NovuApplicationId: '',
AnalyticsApiKey: '',
AnalyticsHost: '',
MapCenterLatitude: 0,
MapCenterLongitude: 0,
MapCenterZoomLevel: 9,
};

// Mock the core store
Expand Down
2 changes: 1 addition & 1 deletion src/api/calls/callPriorities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type CallPrioritiesResult } from '@/models/v4/callPriorities/callPriori
import { createCachedApiEndpoint } from '../common/cached-client';

const callsPrioritesApi = createCachedApiEndpoint('/CallPriorities/GetAllCallPriorites', {
ttl: 60 * 1000 * 2880, // Cache for 2 days
ttl: 6 * 60 * 60 * 1000, // Cache for 6 hours -- reference data, changes rarely
enabled: true,
});

Expand Down
2 changes: 1 addition & 1 deletion src/api/calls/callTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type CallTypesResult } from '@/models/v4/callTypes/callTypesResult';
import { createCachedApiEndpoint } from '../common/cached-client';

const callsTypesApi = createCachedApiEndpoint('/CallTypes/GetAllCallTypes', {
ttl: 60 * 1000 * 2880, // Cache for 2 days
ttl: 6 * 60 * 60 * 1000, // Cache for 6 hours -- reference data, changes rarely
enabled: true,
});

Expand Down
22 changes: 22 additions & 0 deletions src/api/calls/newCallFieldPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type NewCallFieldPolicyResultData } from '@/models/v4/calls/newCallFieldPolicyResultData';

import { createApiEndpoint } from '../common/client';

const getNewCallFieldPolicyApi = createApiEndpoint('/Calls/GetNewCallFieldPolicy');

interface NewCallFieldPolicyResult {
Data: NewCallFieldPolicyResultData | null;
}

/**
* Fetches the department's new-call field policy.
*
* An empty rule list means the stock form — every field visible, nothing extra required — which is
* also what a failure degrades to, since hiding fields a dispatcher needs is far worse than showing
* one they were told to hide. The server enforces the same policy on save regardless.
*/
export const getNewCallFieldPolicy = async (signal?: AbortSignal): Promise<NewCallFieldPolicyResultData> => {
const response = await getNewCallFieldPolicyApi.get<NewCallFieldPolicyResult>(undefined, signal);

return response.data?.Data ?? { Rules: [] };
};
60 changes: 48 additions & 12 deletions src/api/common/cached-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { type AxiosResponse } from 'axios';
import { Platform } from 'react-native';

import { cacheManager } from '@/lib/cache/cache-manager';

Expand All @@ -10,20 +9,57 @@ interface CacheConfig {
enabled?: boolean; // Whether to use cache for this endpoint
}

interface GetOptions {
/** Skip the cached copy and refresh from the server. Use for pull-to-refresh and retries. */
forceRefresh?: boolean;
}

/**
* True when a v4 payload carries no rows.
*
* The v4 controllers answer an empty list with HTTP 200 and `{ Data: [], Status: 'not_found' }`, so
* a permissions blip or a transient server-side failure looks identical to a real answer at this
* layer. Caching that meant a single bad response hid every unit and every dispatch recipient for
* the whole TTL, and the UI reported it as "there are none" rather than "we could not load them".
* Empty answers are cheap to re-fetch, so never keep one.
*/
const isEmptyPayload = (payload: unknown): boolean => {
if (payload === null || payload === undefined) {
return true;
}

if (typeof payload !== 'object') {
return false;
}

const body = payload as { Data?: unknown; Status?: unknown };

if (typeof body.Status === 'string' && body.Status.toLowerCase() === 'not_found') {
return true;
}

if (!('Data' in body)) {
return false;
}

if (body.Data === null || body.Data === undefined) {
return true;
}

return Array.isArray(body.Data) && body.Data.length === 0;
};

export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConfig = { enabled: true }) => {
const api = createApiEndpoint(endpoint);
const defaultTTL = 5 * 60 * 1000; // 5 minutes

// Disable caching on web platform for now to avoid MMKV issues
const isCacheEnabled = cacheConfig.enabled && Platform.OS !== 'web';

return {
get: async <T>(params?: Record<string, unknown>): Promise<AxiosResponse<T>> => {
if (!isCacheEnabled) {
get: async <T>(params?: Record<string, unknown>, options?: GetOptions): Promise<AxiosResponse<T>> => {
if (!cacheConfig.enabled) {
return api.get<T>(params);
}

try {
if (!options?.forceRefresh) {
const cached = cacheManager.get<T>(endpoint, params);
if (cached) {
return Promise.resolve({
Expand All @@ -34,16 +70,16 @@ export const createCachedApiEndpoint = (endpoint: string, cacheConfig: CacheConf
config: {},
} as AxiosResponse<T>);
}
} catch (error) {
console.error('Cache read error, continuing without cache:', error);
}

const response = await api.get<T>(params);

try {
if (isEmptyPayload(response.data)) {
// A previously cached non-empty answer must not outlive an empty one, or the next read
// silently reverts to stale rows.
cacheManager.remove(endpoint, params);
} else {
cacheManager.set(endpoint, response.data, params, cacheConfig.ttl || defaultTTL);
} catch (error) {
console.error('Cache write error, continuing without caching:', error);
}

return response;
Expand Down
4 changes: 2 additions & 2 deletions src/api/contacts/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import { createApiEndpoint } from '../common/client';

// Define API endpoints
const getAllContactsApi = createCachedApiEndpoint('/Contacts/GetAllContacts', {
ttl: 60 * 1000 * 1440, // Cache for 1 day
ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale
enabled: true,
});

const getAllContactCategoriesApi = createCachedApiEndpoint('/Contacts/GetAllContactCategories', {
ttl: 60 * 1000 * 1440, // Cache for 1 day
ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale
enabled: true,
});

Expand Down
101 changes: 101 additions & 0 deletions src/api/geocoding/geocoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createApiEndpoint } from '../common/client';

/**
* Geocoding is proxied through the Resgrid API rather than called from the client.
*
* Two reasons. First, the Dispatch app's config key never carried a Google Maps key, so every
* direct lookup failed with "Google Maps API key not configured" and surfaced as "Failed to search
* for address, please try again". Second, Google's Geocoding *web service* and the what3words API
* send no CORS headers, so the browser and Electron builds could not call them even with a key —
* only the native build ever worked. The server-side endpoints also keep the provider keys off the
* client entirely.
*/

const forwardGeocodeApi = createApiEndpoint('/Geocoding/ForwardGeocode');
const reverseGeocodeApi = createApiEndpoint('/Geocoding/ReverseGeocode');
const what3WordsLookupApi = createApiEndpoint('/Geocoding/What3WordsLookup');
const plusCodeLookupApi = createApiEndpoint('/Geocoding/PlusCodeLookup');

interface ForwardGeocodeResult {
Data: {
Latitude: number | null;
Longitude: number | null;
Address: string | null;
LookupSucceeded: boolean;
};
}

interface ReverseGeocodeResult {
Data: {
Address: string | null;
LookupSucceeded: boolean;
};
}

/**
* Shaped like a Google Geocoding result so the existing screens — which render a picker when more
* than one candidate comes back — keep working unchanged. The server resolves a single best match,
* so the list holds zero or one entry today.
*/
export interface GeocodeCandidate {
formatted_address: string;
geometry: {
location: {
lat: number;
lng: number;
};
};
place_id: string;
}

export interface GeocodeLookup {
/** True when the lookup ran. False means it failed — a different message to "no match". */
succeeded: boolean;
candidates: GeocodeCandidate[];
}

const toCandidates = (data: ForwardGeocodeResult['Data'] | undefined, fallbackAddress: string): GeocodeLookup => {
if (!data) {
return { succeeded: false, candidates: [] };
}

if (data.Latitude === null || data.Latitude === undefined || data.Longitude === null || data.Longitude === undefined) {
return { succeeded: data.LookupSucceeded === true, candidates: [] };
}

return {
succeeded: true,
candidates: [
{
formatted_address: data.Address || fallbackAddress,
geometry: { location: { lat: data.Latitude, lng: data.Longitude } },
place_id: `${data.Latitude},${data.Longitude}`,
},
],
};
};

export const forwardGeocode = async (address: string): Promise<GeocodeLookup> => {
const response = await forwardGeocodeApi.get<ForwardGeocodeResult>({ address });
return toCandidates(response.data?.Data, address);
};

export const what3WordsLookup = async (words: string): Promise<GeocodeLookup> => {
const response = await what3WordsLookupApi.get<ForwardGeocodeResult>({ words });
return toCandidates(response.data?.Data, words);
};

export const plusCodeLookup = async (code: string): Promise<GeocodeLookup> => {
const response = await plusCodeLookupApi.get<ForwardGeocodeResult>({ code });
return toCandidates(response.data?.Data, code);
};

export const reverseGeocode = async (lat: number, lon: number): Promise<{ succeeded: boolean; address: string | null }> => {
const response = await reverseGeocodeApi.get<ReverseGeocodeResult>({ lat, lon });
const data = response.data?.Data;

return {
succeeded: data?.LookupSucceeded === true,
address: data?.Address || null,
};
};
2 changes: 1 addition & 1 deletion src/api/groups/groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createCachedApiEndpoint } from '../common/cached-client';
import { createApiEndpoint } from '../common/client';

const getAllGroupsApi = createCachedApiEndpoint('/Groups/GetAllGroups', {
ttl: 60 * 1000 * 2880, // Cache for 2 days
ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale
enabled: true,
});

Expand Down
2 changes: 1 addition & 1 deletion src/api/messaging/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type GetRecipientsResult } from '@/models/v4/messages/getRecipientsResu
import { createCachedApiEndpoint } from '../common/cached-client';

const recipientsApi = createCachedApiEndpoint('/Messages/GetRecipients', {
ttl: 60 * 1000 * 1440, // Cache for 1 day
ttl: 15 * 60 * 1000, // Cache for 15 minutes -- operational data, must not go stale
enabled: true,
});

Expand Down
Loading
Loading