import { useQuery } from '@tanstack/react-query'; import { apiClient } from './httpClient'; import { queryClient } from '../lib/queryClient'; /** * Deterministic fallback encoder for offline/preview sessions * Generates an obfuscated, unique HashID string with salt */ function generateDeterministicHashId(id: number | string, context: string): string { const str = `${context}:${id}:pfm-salt-2026`; let hash = 5381; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash) + str.charCodeAt(i); hash = hash & hash; // Convert to 32bit integer } const positiveHash = Math.abs(hash); const base62Chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; let encoded = ''; let num = positiveHash; while (num > 0) { encoded = base62Chars[num % 62] + encoded; num = Math.floor(num / 62); } const prefix = context ? context.slice(0, 3).toLowerCase() : 'id'; return `${prefix}_${encoded.padStart(8, 'x')}`; } /** * Invokes the backend tools encoding endpoint to retrieve the designated HashID string */ async function fetchHashIdFromEndpoint(id: number | string, context = 'general'): Promise { if (id === undefined || id === null || id === '') { return ''; } try { // Attempt standard tools encoding endpoint variations const response = await apiClient.get( `/v1/api/tools/encode/${id}`, { params: { context, type: context }, timeout: 4000, } ); if (typeof response.data === 'string' && response.data.trim()) { return response.data.trim(); } if (response.data && typeof response.data === 'object') { const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value; if (candidate) return candidate; } } catch { // Attempt query param variant try { const response = await apiClient.get( '/v1/api/tools/encode', { params: { id, context, type: context }, timeout: 4000, } ); if (typeof response.data === 'string' && response.data.trim()) { return response.data.trim(); } if (response.data && typeof response.data === 'object') { const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value; if (candidate) return candidate; } } catch { // Fallback to deterministic HashID generator } } // Graceful fallback guaranteeing valid HashID output during preview return generateDeterministicHashId(id, context); } /** * Invokes the backend tools decoding endpoint */ async function fetchDecodedIdFromEndpoint(hashId: string, context = 'general'): Promise { if (!hashId) return ''; try { const response = await apiClient.get<{ id?: number | string; decoded?: number | string } | number | string>( `/v1/api/tools/decode/${encodeURIComponent(hashId)}`, { params: { context }, timeout: 4000 } ); if (typeof response.data === 'number' || typeof response.data === 'string') { return response.data; } if (response.data && typeof response.data === 'object') { if (response.data.id !== undefined) return response.data.id; if (response.data.decoded !== undefined) return response.data.decoded; } } catch { // Passthrough or fallback } return hashId; } export const toolsService = { /** * Encodes a raw ID to a HashID, cached client-side in TanStack Query for the active session */ async encodeId(id: number | string, context = 'general'): Promise { if (id === undefined || id === null || id === '') return ''; const idStr = String(id); return queryClient.fetchQuery({ queryKey: ['hashId', context, idStr], queryFn: () => fetchHashIdFromEndpoint(id, context), staleTime: Infinity, // Caches client-side during the active session to eliminate redundant round trips gcTime: 1000 * 60 * 60 * 24, // Keep in memory }); }, /** * Decodes a HashID string back to original identifier */ async decodeId(hashId: string, context = 'general'): Promise { if (!hashId) return ''; return queryClient.fetchQuery({ queryKey: ['decodeHashId', context, hashId], queryFn: () => fetchDecodedIdFromEndpoint(hashId, context), staleTime: Infinity, gcTime: 1000 * 60 * 60 * 24, }); }, /** * Specific convenience resolvers for domain entities */ async resolveProjectId(projectId: number | string): Promise { return this.encodeId(projectId, 'project'); }, async resolveOrganisationId(organisationId: number | string): Promise { return this.encodeId(organisationId, 'organisation'); }, async resolveProgrammeId(programmeId: number | string): Promise { return this.encodeId(programmeId, 'programme'); }, async resolvePortfolioId(portfolioId: number | string): Promise { return this.encodeId(portfolioId, 'portfolio'); }, async resolveAwardId(awardId: number | string): Promise { return this.encodeId(awardId, 'award'); }, async resolveContractId(contractId: number | string): Promise { return this.encodeId(contractId, 'contract'); }, /** * Resolves query parameters or path variables encoded as HashIDs * before performing API calls */ async resolveHashedParams>( params: T, keysToHash: (keyof T)[] ): Promise { const resolved: any = { ...params }; for (const key of keysToHash) { const val = params[key]; if (val !== undefined && val !== null) { const contextStr = String(key).replace(/Id$/i, '').toLowerCase(); resolved[key] = await this.encodeId(val, contextStr); } } return resolved; }, /** * Pre-fetches and primes cache for a collection of IDs */ async primeCache(items: { id: number | string; context: string }[]): Promise { await Promise.all(items.map((item) => this.encodeId(item.id, item.context))); }, }; /** * React Hook for consuming HashIDs with TanStack Query caching in components */ export function useHashId(id?: number | string | null, context = 'general') { return useQuery({ queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''], queryFn: () => (id !== undefined && id !== null ? toolsService.encodeId(id, context) : Promise.resolve('')), enabled: id !== undefined && id !== null && id !== '', staleTime: Infinity, }); }