import axios, { InternalAxiosRequestConfig } from 'axios'; import { getApiBaseUrl } from '../config'; import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration'; import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService'; export const BASE_API_URL = getApiBaseUrl(); const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch'; let activeMutationEpoch: number = (() => { if (typeof window !== 'undefined') { try { const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY); if (stored) { const parsed = Number(stored); if (!isNaN(parsed) && parsed > 0) return parsed; } } catch {} } return Date.now(); })(); export function getMutationEpoch(): number { return activeMutationEpoch; } export function bumpMutationEpoch(): number { activeMutationEpoch = Date.now(); if (typeof window !== 'undefined') { try { sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch)); } catch {} } return activeMutationEpoch; } export const apiClient = axios.create({ baseURL: BASE_API_URL, headers: { 'Content-Type': 'application/json; version=1', Accept: 'application/json; version=1', 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache', }, timeout: 15000, }); apiClient.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { config.baseURL = getApiBaseUrl(); const token = localStorage.getItem('pfm_access_token'); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; } const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url); const isExternal = config.url?.startsWith('http://') || config.url?.startsWith('https://') || config.url?.includes('amazonaws.com') || config.url?.includes('garage'); // Always attach freshness timestamp and mutation epoch to internal GET requests // to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits if (config.method?.toUpperCase() === 'GET' && !isExternal) { if (config.params instanceof URLSearchParams) { config.params.set('_t', String(Date.now())); config.params.set('_v', String(getMutationEpoch())); } else { config.params = config.params || {}; config.params._t = Date.now(); config.params._v = getMutationEpoch(); } } if (token && !isLifecycleRequest) { if (isActivationPending()) { return Promise.reject(new Error('User account is pending administrator activation')); } const storedSub = localStorage.getItem('pfm_user_sub') || ''; if (!isUserSyncedInBrowser(storedSub, token)) { const syncRes = await syncUserAccount(storedSub, token); if (!syncRes.success) { return Promise.reject(new Error(syncRes.errorMessage || 'User account sync pending activation')); } } } return config; }, (error) => Promise.reject(error) ); apiClient.interceptors.response.use( (response) => { const method = response.config?.method?.toLowerCase(); if (method && ['post', 'put', 'patch', 'delete'].includes(method)) { bumpMutationEpoch(); } return response; }, (error) => { if (error?.response?.status === 401) { const requestUrl = error.config?.url; const isLifecycleEndpoint = isUserSyncEndpoint(requestUrl) || isTenantEndpoint(requestUrl); if (!isLifecycleEndpoint) { const detailMsg = error?.response?.data?.detail || error?.response?.data?.message || 'Authentication session expired or security token invalidated (HTTP 401).'; triggerSessionExpired(detailMsg, requestUrl); } else { if (typeof window !== 'undefined') { localStorage.removeItem('pfm_access_token'); localStorage.removeItem('pfm_access_user'); localStorage.removeItem('pfm_user_sync_record'); localStorage.removeItem('pfm_user_pending_activation'); localStorage.removeItem('pfm_user_pending_activation_reason'); } } } return Promise.reject(error); } );