import { collection, doc, setDoc, getDoc, getDocs, query, where, Timestamp, writeBatch } from 'firebase/firestore'; import { getFirestore } from 'firebase/firestore'; import { getApp } from 'firebase/app'; import { hashUserId, generateSalt } from '../utils/crypto'; const HASH_MAPPINGS_COLLECTION = 'user_hash_mappings'; export interface HashMapping { hash: string; userId: string; salt: string; createdAt: Timestamp; lastAccessed?: Timestamp; } /** * Service for managing user ID hash mappings in Firestore */ export class HashMappingService { private static instance: HashMappingService; private memoryCache: Map = new Map(); private db = getFirestore(getApp()); private constructor() {} static getInstance(): HashMappingService { if (!HashMappingService.instance) { HashMappingService.instance = new HashMappingService(); } return HashMappingService.instance; } /** * Get or create a hash mapping for a user ID */ async getOrCreateHashMapping(userId: string): Promise { // Check memory cache first const cached = Array.from(this.memoryCache.values()).find( mapping => mapping.userId === userId ); if (cached) { return cached.hash; } // Check Firestore const mappingsRef = collection(this.db, HASH_MAPPINGS_COLLECTION); const q = query(mappingsRef, where('userId', '==', userId)); const snapshot = await getDocs(q); if (!snapshot.empty) { const mapping = snapshot.docs[0].data() as HashMapping; this.memoryCache.set(mapping.hash, mapping); return mapping.hash; } // Create new mapping const salt = generateSalt(); const hash = hashUserId(userId, salt); const mapping: HashMapping = { hash, userId, salt, createdAt: Timestamp.now() }; await setDoc(doc(this.db, HASH_MAPPINGS_COLLECTION, hash), mapping); this.memoryCache.set(hash, mapping); return hash; } /** * Resolve a hash back to a user ID */ async resolveHash(hash: string): Promise { // Check memory cache first const cached = this.memoryCache.get(hash); if (cached) { // Update last accessed time asynchronously this.updateLastAccessed(hash); return cached.userId; } // Check Firestore const docRef = doc(this.db, HASH_MAPPINGS_COLLECTION, hash); const snapshot = await getDoc(docRef); if (!snapshot.exists()) { return null; } const mapping = snapshot.data() as HashMapping; this.memoryCache.set(hash, mapping); // Update last accessed time asynchronously this.updateLastAccessed(hash); return mapping.userId; } /** * Batch create hash mappings for multiple users */ async batchCreateHashMappings(userIds: string[]): Promise> { const batch = writeBatch(this.db); const userHashMap = new Map(); for (const userId of userIds) { // Check if mapping already exists const existingHash = await this.getOrCreateHashMapping(userId); if (existingHash) { userHashMap.set(userId, existingHash); continue; } const salt = generateSalt(); const hash = hashUserId(userId, salt); const mapping: HashMapping = { hash, userId, salt, createdAt: Timestamp.now() }; batch.set(doc(this.db, HASH_MAPPINGS_COLLECTION, hash), mapping); userHashMap.set(userId, hash); this.memoryCache.set(hash, mapping); } await batch.commit(); return userHashMap; } /** * Resolve multiple hashes in batch */ async batchResolveHashes(hashes: string[]): Promise> { const resolvedMap = new Map(); const uncachedHashes: string[] = []; // Check cache first for (const hash of hashes) { const cached = this.memoryCache.get(hash); if (cached) { resolvedMap.set(hash, cached.userId); } else { uncachedHashes.push(hash); } } // Fetch uncached from Firestore if (uncachedHashes.length > 0) { const mappingsRef = collection(this.db, HASH_MAPPINGS_COLLECTION); const q = query(mappingsRef, where('hash', 'in', uncachedHashes)); const snapshot = await getDocs(q); snapshot.docs.forEach(doc => { const mapping = doc.data() as HashMapping; resolvedMap.set(mapping.hash, mapping.userId); this.memoryCache.set(mapping.hash, mapping); }); } // Update last accessed times asynchronously hashes.forEach(hash => this.updateLastAccessed(hash)); return resolvedMap; } /** * Clear memory cache (useful for testing or memory management) */ clearCache(): void { this.memoryCache.clear(); } /** * Update last accessed timestamp for a hash mapping */ private async updateLastAccessed(hash: string): Promise { try { await setDoc( doc(this.db, HASH_MAPPINGS_COLLECTION, hash), { lastAccessed: Timestamp.now() }, { merge: true } ); } catch (error) { // Log error but don't throw - this is a non-critical operation console.error('Failed to update last accessed time:', error); } } } // Export singleton instance export const hashMappingService = HashMappingService.getInstance();