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==', }; // Singleton Hashids instance initialized with salt & minHashLength let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); /** * Dynamically reconfigures the deterministic Hashids instance from runtime configuration. */ 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); } } /** * Checks whether an incoming string is already a valid Hashid matching the configured salt and min length. * Prevents double-encoding already-hashed route parameters or IDs. */ function isAlreadyHashId(candidate: string): boolean { if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) { return false; } try { const decoded = hasher.decode(candidate); return Array.isArray(decoded) && decoded.length > 0; } catch { return false; } } /** * Deterministically encodes a numeric ID or numeric string into a HashID string. * Synchronous and does not perform HTTP network round trips. */ export function encodeHashIdSync(id: number | string | null | undefined): string { if (id === undefined || id === null || id === '') { return ''; } // If already a number if (typeof id === 'number') { if (isNaN(id) || !isFinite(id) || id < 0) return ''; return hasher.encode(id); } const str = String(id).trim(); if (!str) return ''; // Check if string is already a valid HashID if (isAlreadyHashId(str)) { return str; } // Check if string is pure digits (e.g. "123") if (/^\d+$/.test(str)) { try { const num = parseInt(str, 10); if (!isNaN(num) && num >= 0) { return hasher.encode(num); } } catch { // Fall through } } // Non-numeric identifiers (UUIDs, codes) returned as-is return str; } /** * Deterministically decodes a HashID string back to original numeric ID or string. * Synchronous and does not perform HTTP network round trips. */ 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 already pure integer string 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; } /** * Decodes a HashID or numeric value strictly to a canonical integer. * Useful for request payloads expecting int64 integers. */ 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 = { /** * Synchronous encoding */ encodeIdSync(id: number | string | null | undefined): string { return encodeHashIdSync(id); }, /** * Synchronous decoding */ decodeIdSync(hashId: string | number | null | undefined): number | string { return decodeHashIdSync(hashId); }, /** * Synchronous decoding strictly to canonical integer */ decodeToNumberSync(hashId: number | string | null | undefined): number { return decodeToNumberSync(hashId); }, /** * Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers) */ async encodeId(id: number | string | null | undefined, _context = 'general'): Promise { return encodeHashIdSync(id); }, /** * Decodes a HashID string back to original identifier */ async decodeId(hashId: string | null | undefined, _context = 'general'): Promise { return decodeHashIdSync(hashId); }, /** * Domain-specific convenience resolvers for entities */ 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); }, /** * 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) { resolved[key] = encodeHashIdSync(val); } } return resolved; }, /** * Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side) */ async primeCache(_items: { id: number | string; context: string }[]): Promise { // No-op - instant deterministic computation }, }; /** * 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: () => Promise.resolve(encodeHashIdSync(id)), enabled: id !== undefined && id !== null && id !== '', staleTime: Infinity, }); }