import Hashids from 'hashids'; import { useQuery } from '@tanstack/react-query'; const env = (typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record; /** * Deterministic Hashids configuration matching pfm-api HasherSettings. * Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend. */ export const HASHER_SETTINGS = { minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11, salt: (env.VITE_HASHER_SALT as string) || 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==', }; let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); export function updateHasherSettings(salt?: string, minLength?: number | string): void { let updated = false; if (salt && typeof salt === 'string' && salt.trim() !== '') { HASHER_SETTINGS.salt = salt.trim(); updated = true; } if (minLength !== undefined && minLength !== null && minLength !== '') { const parsed = Number(minLength); if (!isNaN(parsed) && parsed > 0) { HASHER_SETTINGS.minHashLength = parsed; updated = true; } } if (updated) { hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); } } export function isAlreadyHashId(candidate: string): boolean { if ( typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength || /^\d+$/.test(candidate) ) { return false; } try { const decoded = hasher.decode(candidate); if (!Array.isArray(decoded) || decoded.length === 0) { return false; } return hasher.encode(decoded as number[]) === candidate; } catch { return false; } } export function encodeHashIdSync(id: number | string | null | undefined): string { if (id === undefined || id === null || id === '') { return ''; } if (typeof id === 'number') { if (isNaN(id) || !isFinite(id) || id < 0) return ''; return hasher.encode(id); } const str = String(id).trim(); if (!str) return ''; if (isAlreadyHashId(str)) { return str; } if (/^\d+$/.test(str)) { try { const num = parseInt(str, 10); if (!isNaN(num) && num >= 0) { return hasher.encode(num); } } catch { // Fall through } } return str; } export function decodeHashIdSync(hashId: string | number | null | undefined): number | string { if (hashId === undefined || hashId === null || hashId === '') return ''; if (typeof hashId === 'number') return hashId; const str = String(hashId).trim(); if (!str) return ''; if (/^-?\d+$/.test(str)) { const parsed = parseInt(str, 10); return isNaN(parsed) ? str : parsed; } try { const decoded = hasher.decode(str); if (Array.isArray(decoded) && decoded.length > 0) { return Number(decoded[0]); } } catch { // If decoding fails, return raw string } return str; } export function decodeToNumberSync(hashId: number | string | null | undefined): number { if (hashId === null || hashId === undefined || hashId === '') return 0; if (typeof hashId === 'number') { return isNaN(hashId) ? 0 : Math.trunc(hashId); } const str = String(hashId).trim(); if (/^-?\d+$/.test(str)) { const parsed = parseInt(str, 10); return isNaN(parsed) ? 0 : parsed; } try { const decoded = hasher.decode(str); if (Array.isArray(decoded) && decoded.length > 0) { return Number(decoded[0]); } } catch {} const fallback = Number(str); return isNaN(fallback) ? 0 : Math.trunc(fallback); } export const toolsService = { encodeIdSync(id: number | string | null | undefined): string { return encodeHashIdSync(id); }, decodeIdSync(hashId: string | number | null | undefined): number | string { return decodeHashIdSync(hashId); }, decodeToNumberSync(hashId: number | string | null | undefined): number { return decodeToNumberSync(hashId); }, async encodeId(id: number | string | null | undefined, _context = 'general'): Promise { return encodeHashIdSync(id); }, async decodeId(hashId: string | null | undefined, _context = 'general'): Promise { return decodeHashIdSync(hashId); }, async resolveProjectId(projectId: number | string): Promise { return encodeHashIdSync(projectId); }, async resolveOrganisationId(organisationId: number | string): Promise { return encodeHashIdSync(organisationId); }, async resolveProgrammeId(programmeId: number | string): Promise { return encodeHashIdSync(programmeId); }, async resolvePortfolioId(portfolioId: number | string): Promise { return encodeHashIdSync(portfolioId); }, async resolveAwardId(awardId: number | string): Promise { return encodeHashIdSync(awardId); }, async resolveContractId(contractId: number | string): Promise { return encodeHashIdSync(contractId); }, async resolveActivityId(activityId: number | string): Promise { return encodeHashIdSync(activityId); }, async resolveMilestoneId(milestoneId: number | string): Promise { return encodeHashIdSync(milestoneId); }, 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) { resolved[key] = encodeHashIdSync(val); } } return resolved; }, async primeCache(_items: { id: number | string; context: string }[]): Promise { // No-op - instant deterministic computation }, }; export function useHashId(id?: number | string | null, context = 'general') { return useQuery({ queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''], queryFn: () => Promise.resolve(encodeHashIdSync(id)), enabled: id !== undefined && id !== null && id !== '', staleTime: Infinity, }); }