Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+150 -7
View File
@@ -1,6 +1,10 @@
import type { AxiosError, AxiosInstance } from 'axios';
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import type { CbsEnvelope } from '../../types/cbs';
import { sessionManager } from '../security/sessionManager';
import { secureStorage } from '../storage/secureStorage';
import { apiLogger } from './apiLogger';
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
const unauthorizedHandlers = new Set<() => void>();
@@ -12,6 +16,63 @@ export const registerUnauthorizedHandler = (handler: () => void) => {
};
};
const looksLikeEnvelope = (value: unknown): value is CbsEnvelope<unknown> =>
typeof value === 'object' && value !== null && 'success' in value;
/**
* `VALIDATION_ERROR` is the one failure that also fills `data`, with a
* field-to-message map. Anything else in there on a failure is not a field map.
*/
const readFieldErrors = (envelope: CbsEnvelope<unknown>): FieldErrors | undefined => {
if (envelope.errorCode !== 'VALIDATION_ERROR') {
return undefined;
}
const { data } = envelope;
if (typeof data !== 'object' || data === null) {
return undefined;
}
const entries = Object.entries(data as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};
/** The absolute URL a failed request was aimed at, for a dev-only diagnostic. */
const describeTarget = (error: AxiosError) => {
const base = error.config?.baseURL ?? '';
const path = error.config?.url ?? '';
return `${base}${path}` || 'the server';
};
const envelopeToError = (status: number, envelope: CbsEnvelope<unknown>) =>
new CbsApiError({
status,
code: isCbsErrorCode(envelope.errorCode) ? envelope.errorCode : 'UNKNOWN_ERROR',
message: envelope.message || 'Unexpected service error',
fieldErrors: readFieldErrors(envelope),
});
/**
* `SESSION_INVALID` means the token we hold is dead and every screen must drop
* it. `INVALID_CREDENTIALS` shares the 401 but means a wrong MPIN on the
* sign-in form — bouncing the user out of a screen they are not in yet would
* be nonsense, so only the former trips the global handler.
*/
const handleSessionLoss = async (error: CbsApiError) => {
if (error.code !== 'SESSION_INVALID') {
return;
}
await secureStorage.clearSession();
sessionManager.clear();
unauthorizedHandlers.forEach((handler) => handler());
};
export const attachInterceptors = (client: AxiosInstance) => {
client.interceptors.request.use(async (config) => {
const token = await secureStorage.getAccessToken();
@@ -22,18 +83,100 @@ export const attachInterceptors = (client: AxiosInstance) => {
config.headers = nextHeaders as never;
}
// Logged here, at the end of the interceptor, so the curl carries the
// Authorization header the request will actually be sent with.
apiLogger.request(config);
return config;
});
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
await secureStorage.clearSession();
unauthorizedHandlers.forEach((handler) => handler());
async (response: AxiosResponse) => {
apiLogger.response(response);
if (!looksLikeEnvelope(response.data)) {
return response;
}
return Promise.reject(error);
const envelope = response.data as CbsEnvelope<unknown>;
// Branch on `success`, not on the HTTP status: a failure can arrive with
// a 2xx, and treating it as a payload would hand callers `undefined`.
if (!envelope.success) {
const error = envelopeToError(response.status, envelope);
apiLogger.failure(response, `envelope said success: false -> ${error.code}`);
await handleSessionLoss(error);
throw error;
}
// Any authenticated call refreshes the server's idle timer, so mirror
// that locally — otherwise the app would sign the user out while the
// session is still very much alive.
if (response.config.headers?.Authorization) {
sessionManager.touch();
}
// Unwrap so callers read the payload directly rather than the envelope.
return { ...response, data: envelope.data };
},
async (error: AxiosError) => {
if (error instanceof CbsApiError) {
return Promise.reject(error);
}
if (error.response) {
apiLogger.failure(error.response, `HTTP error ${error.code ?? ''}`.trim());
} else {
apiLogger.networkFailure(error);
}
const status = Number(error.response?.status ?? 0);
const body = error.response?.data;
if (looksLikeEnvelope(body)) {
const cbsError = envelopeToError(status, body);
await handleSessionLoss(cbsError);
return Promise.reject(cbsError);
}
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
return Promise.reject(
new CbsApiError({
status: 0,
code: 'REQUEST_TIMEOUT',
message: 'The request timed out',
}),
);
}
// No response at all: nothing answered on the other end. The usual causes
// are the backend not running, it being bound to 127.0.0.1 instead of
// 0.0.0.0, the handset being on a different network from the host, a
// firewall on the host port, or cleartext HTTP blocked in a release build.
//
// Naming the URL that failed matters more than it looks: without it this
// is indistinguishable from a server-side fault, and the address the
// phone needs is often not the one that works in the laptop's browser.
if (!error.response) {
return Promise.reject(
new CbsApiError({
status: 0,
code: 'NETWORK_ERROR',
message: __DEV__
? `Could not reach ${describeTarget(error)}. Check the backend is running and that this device can reach that address.`
: error.message || 'Could not reach the server',
}),
);
}
return Promise.reject(
new CbsApiError({
status,
code: 'UNKNOWN_ERROR',
message: error.message || 'Unexpected service error',
}),
);
},
);
};