Refactored UI endpoint usage

This commit is contained in:
2026-09-20 08:26:06 +02:00
parent b703b0c64d
commit 5829bffaa0
35 changed files with 17482 additions and 648 deletions
+119 -31
View File
@@ -27,6 +27,7 @@ import {
Award,
CreateAwardRequest,
Contract,
ContractStatus,
CreateContractRequest,
UpdateContractRequest,
ExecuteContractRequest,
@@ -429,6 +430,18 @@ export const createAward = async (payload: CreateAwardRequest): Promise<Award> =
return res.data;
};
export const fetchHashId = async (id64: number | string): Promise<string> => {
try {
const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`);
if (res.data && typeof res.data === 'string') {
return res.data;
}
} catch {
// Fall back to client-side deterministic Hashids
}
return toolsService.encodeIdSync(id64);
};
export const getContracts = async (page = 1, pageSize = 100): Promise<Contract[]> => {
const res = await apiClient.get<Contract[]>('/v1/api/grants/contracts', {
params: { page, pageSize },
@@ -444,15 +457,94 @@ export const getContract = async (contractId: string | number): Promise<Contract
export const createContract = async (payload: CreateContractRequest): Promise<Contract> => {
const userSub = getCurrentUserSub();
const hashedAwardId = await toolsService.resolveAwardId(payload.awardId);
const res = await apiClient.post('/v1/api/grants/contracts', {
...payload,
awardId: hashedAwardId,
createdBy: userSub || undefined,
});
return res.data;
// Extract ID from headers (e.g. Location or Content-Location) if present
const locationHeader = res.headers?.['location'] || res.headers?.['Location'];
let headerId: string | undefined;
if (locationHeader && typeof locationHeader === 'string') {
const parts = locationHeader.split('/');
headerId = parts[parts.length - 1];
}
// Case 1: Backend returns string contractId/hash directly
if (typeof res.data === 'string' && res.data.trim().length > 0) {
const rawStr = res.data.trim();
return {
id: rawStr,
contractId: rawStr,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
} as unknown as Contract;
}
// Case 2: Backend returns object
if (res.data && typeof res.data === 'object') {
const candidateId =
res.data.id ??
res.data.contractId ??
res.data.Id ??
res.data.ContractId ??
headerId;
if (candidateId) {
return {
...res.data,
id: candidateId,
contractId: candidateId,
awardId: res.data.awardId || hashedAwardId,
};
}
}
// Case 3: ID extracted from Location header
if (headerId) {
return {
id: headerId,
contractId: headerId,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
} as unknown as Contract;
}
// Case 4: If backend returns empty 200 OK without body, locate newly created contract by awardId
try {
const existing = await getContracts(1, 10);
const matched = existing.find(
(c) =>
String(c.awardId) === String(hashedAwardId) ||
String(c.awardId) === String(payload.awardId)
);
if (matched) return matched;
} catch {}
return (res.data || {
id: hashedAwardId,
contractId: hashedAwardId,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
}) as unknown as Contract;
};
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
await apiClient.put('/v1/api/grants/contracts', payload);
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.put('/v1/api/grants/contracts', {
...payload,
contractId: hashedContractId,
});
};
export const uploadContractFile = async (
@@ -471,14 +563,19 @@ export const uploadContractFile = async (
export const getContractSecureLink = async (contractId: string | number): Promise<string> => {
const hashedContractId = await toolsService.resolveContractId(contractId);
const res = await apiClient.get<string>(`/v1/api/grants/contracts/${hashedContractId}/securelink`);
return res.data;
const res = await apiClient.get<string | { url?: string; link?: string }>(
`/v1/api/grants/contracts/${hashedContractId}/securelink`
);
if (typeof res.data === 'string') return res.data;
return (res.data as any)?.url || (res.data as any)?.link || String(res.data);
};
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
const userSub = getCurrentUserSub();
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.post('/v1/api/grants/contracts/execute', {
...payload,
contractId: hashedContractId,
executedBy: userSub || undefined,
});
};
@@ -759,7 +856,7 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus |
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
};
export {
import {
syncUserAccount,
ensureUserSynced,
isUserSyncedInBrowser,
@@ -771,6 +868,18 @@ export {
getActivationPendingReason,
} from './userSyncService';
export {
syncUserAccount,
ensureUserSynced,
isUserSyncedInBrowser,
saveUserSyncInBrowser,
clearUserSyncInBrowser,
getUserSyncRecord,
isActivationPending,
setActivationPending,
getActivationPendingReason,
};
/**
* Synchronizes the authenticated user with the backend to enable the user in the system.
* By default, sync is saved in the browser to prevent hotspotting the sync endpoint.
@@ -786,13 +895,15 @@ export const syncUser = async (
},
force = false
): Promise<boolean> => {
const { ensureUserSynced } = await import('./userSyncService');
return ensureUserSynced(userData?.sub || userData?.id, undefined, userData, force);
};
export const syncIntegration = async (force = true): Promise<void> => {
const { ensureUserSynced } = await import('./userSyncService');
await ensureUserSynced(undefined, undefined, undefined, force);
try {
await apiClient.post('/v1/api/integration/sync', {});
} catch {
await ensureUserSynced(undefined, undefined, undefined, force);
}
};
// ==========================================
@@ -821,26 +932,3 @@ export const getApplicantInvites = async (applicantUserId: string): Promise<Invi
const res = await apiClient.get<Invite[]>(`/v1/api/communications/invites/${applicantUserId}`);
return res.data || [];
};
// Legacy compatibility helpers
export const inviteUser = async (payload: InviteUserRequest): Promise<User> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/governance/users/invite', {
...payload,
invitedBy: userSub || undefined,
});
return res.data;
};
export const revokeUser = async (userId: string): Promise<void> => {
await apiClient.delete(`/v1/api/governance/users/${userId}`);
};
export const getAuditLogs = async (): Promise<AuditLog[]> => {
try {
const res = await apiClient.get<AuditLog[]>('/v1/api/governance/audit');
return res.data || [];
} catch {
return [];
}
};
@@ -1,6 +1,7 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration';
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
export const BASE_API_URL = getApiBaseUrl();
@@ -28,8 +29,6 @@ apiClient.interceptors.request.use(
// All API calls require an authenticated and enabled user in the backend system.
const isSyncRequest = isUserSyncEndpoint(config.url);
if (token && !isSyncRequest) {
const { isUserSyncedInBrowser, isActivationPending, syncUserAccount } = await import('./userSyncService');
// If user account is flagged as pending administrator activation, block downstream API calls
if (isActivationPending()) {
return Promise.reject(new Error('User account is pending administrator activation'));
@@ -57,11 +56,9 @@ apiClient.interceptors.response.use(
const requestUrl = error.config?.url;
const isSyncEndpoint = isUserSyncEndpoint(requestUrl);
// Disambiguation between Session Lockout and Session Expired:
// - Session Expired process monitors ALL OTHER endpoints for HTTP 401 other than the user sync endpoint.
// - User sync endpoint 401 is subordinated to the session lockout / auth initialization flow through call order and priority.
// Handle 401 Unauthorized errors
if (!isSyncEndpoint) {
// Active working endpoint returned HTTP 401: trigger modal with progress bar countdown and landing redirect
// Working endpoint returned HTTP 401: trigger session expiration prompt
const detailMsg =
error?.response?.data?.detail ||
error?.response?.data?.message ||
@@ -69,7 +66,7 @@ apiClient.interceptors.response.use(
triggerSessionExpired(detailMsg, requestUrl);
} else {
// User sync endpoint returned HTTP 401: clean up session data silently for landing navigation
// User sync endpoint returned HTTP 401: clear cached auth state
if (typeof window !== 'undefined') {
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_access_user');
@@ -58,19 +58,14 @@ export function isSessionExpiredState(): boolean {
/**
* Triggers global session expiration when an endpoint returns HTTP 401.
* Prevents multiple modal popups if multiple parallel requests fail with 401.
*
* NOTE: As established by system call priority and subordination, this process
* strictly monitors all other endpoints EXCEPT the user sync endpoint, which is
* reserved for the session lockout / activation process.
*/
export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
// Disambiguation: Ignore HTTP 401 if originating from the user sync endpoint
// Ignore HTTP 401 if originating from user sync endpoint
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
return;
}
// Priority Subordination: If user is in account activation lockout, session expiration
// is avoided and cancelled in favor of the higher-priority activation modal.
// If user is pending account activation, ignore general session expiration
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
return;
}
@@ -87,7 +82,7 @@ export function triggerSessionExpired(reason?: string, requestUrl?: string): voi
hasTriggeredSessionExpired = true;
// Session expiration modal dismisses all other subordinates when it is displayed
// Dismiss open secondary modals
dismissSubordinateModals();
// Dispatch custom browser event
@@ -172,22 +172,20 @@ export function getUserSyncRecord(): UserSyncRecord | null {
}
export const USER_SYNC_CANDIDATE_ENDPOINTS = [
'/v1/api/users/sync',
'/v1/api/integration/sync',
'/v1/api/users/sync',
'/v1/api/user/sync',
];
/**
* Determines whether a given URL or request path targets a user sync endpoint.
* This is used to differentiate user sync / lockout lifecycle calls from regular
* business working endpoints, ensuring established call priority and subordination.
*/
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/users/sync') ||
clean.includes('/integration/sync') ||
clean.includes('/users/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
@@ -195,9 +193,7 @@ export function isUserSyncEndpoint(url?: string): boolean {
}
/**
* Executes the backend user sync request.
* Tries the primary '/v1/api/users/sync' endpoint, falling back to
* '/v1/api/integration/sync' or '/v1/api/user/sync' if needed.
* Executes the backend user sync request across available candidate endpoints.
*/
async function executeBackendSync(
userSub?: string,
@@ -218,20 +214,33 @@ async function executeBackendSync(
for (const endpoint of candidateEndpoints) {
try {
const res = await apiClient.post(endpoint, payload);
return { endpoint, data: res.data };
let res;
if (endpoint === '/v1/api/integration/sync') {
try {
res = await apiClient.post(endpoint, payload);
} catch (postErr: any) {
const errStatus = postErr?.response?.status;
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
res = await apiClient.post(endpoint, {});
} else {
throw postErr;
}
}
} else {
res = await apiClient.post(endpoint, payload);
}
return { endpoint, data: res?.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
// 1. If HTTP 401 Unauthorized: token expired or invalid, fail fast
// Fail fast on token expiration/invalid
if (status === 401) {
throw err;
}
// 2. If HTTP 403 Forbidden, 423 Locked, or non-404 client error:
// The endpoint is valid, but the user is blocked/disabled/not activated by the administrator.
if (status === 403 || status === 423 || (status && status >= 400 && status !== 404 && status !== 405)) {
// User blocked or pending activation
if (status === 403 || status === 423) {
throw err;
}