import { Injectable } from '@angular/core'; import { from, Observable, of } from 'rxjs'; import { catchError, switchMap } from 'rxjs/operators'; import { AppConfigService } from './app-config.service'; import { AuthService } from './auth.service'; /** Shape of every entry stored in Cache Storage. */ export interface BrowserCacheEntry { data: T; cachedAt: number; // epoch ms } /** * Generic browser-side Cache Storage wrapper. * * Each logical cache is identified by a **cacheName** (e.g. `'agm-jobs-list-v1'`). * Within that cache, individual entries are keyed by an arbitrary **key** string * (typically a serialised set of query params). * * Usage: * ```ts * // Read * this.browserCache.get('my-cache-v1', key, 60_000).subscribe(data => { ... }); * * // Write * this.browserCache.put('my-cache-v1', key, data); * * // Invalidate * this.browserCache.invalidate('my-cache-v1'); * ``` * * All operations are no-ops when the Cache Storage API is unavailable * (e.g. in unit tests or older browsers). */ @Injectable({ providedIn: 'root' }) export class BrowserCacheService { private readonly supported = typeof caches !== 'undefined'; private readonly fallbackMaxAgeMs = 60_000; constructor( private readonly appConfig: AppConfigService, private readonly authSvc: AuthService ) {} private ttlStorageKey(cacheName: string): string { const userId = this.authSvc.user?._id || 'anonymous'; return `browser-cache-ttl:${userId}:${cacheName}`; } private get defaultMaxAgeMs(): number { return this.appConfig.settings?.browserListCacheTtlMs || this.fallbackMaxAgeMs; } getTtl(cacheName: string): number { const storedValue = localStorage.getItem(this.ttlStorageKey(cacheName)); if (storedValue === null) { return this.defaultMaxAgeMs; } const parsedValue = Number(storedValue); return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : this.defaultMaxAgeMs; } setTtl(cacheName: string, ttlMs: number): number { const normalizedValue = Number.isFinite(ttlMs) && ttlMs >= 0 ? Math.floor(ttlMs) : this.defaultMaxAgeMs; localStorage.setItem(this.ttlStorageKey(cacheName), String(normalizedValue)); return normalizedValue; } /** * Build the pseudo-URL used as the cache key inside a named Cache bucket. * We prefix with a fixed path so it looks like a valid Request URL. */ private entryUrl(cacheName: string, key: string): string { return `/browser-cache/${encodeURIComponent(cacheName)}?${key}`; } /** * Retrieve a cached value. * * @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`). * @param key Entry key — typically serialised query params. * @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale. * Defaults to `appConfig.browserListCacheTtlMs` or 60 000. * @returns The cached value, or `null` when unavailable / stale / missing. */ get(cacheName: string, key: string, maxAgeMs?: number): Observable { if (!this.supported) return of(null); const effectiveMaxAgeMs = maxAgeMs ?? this.getTtl(cacheName); return from(caches.open(cacheName)).pipe( switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))), switchMap(response => { if (!response) return of(null); return from(response.json() as Promise>); }), switchMap((entry: BrowserCacheEntry | null) => { if (!entry) return of(null); if (Date.now() - entry.cachedAt > effectiveMaxAgeMs) return of(null); return of(entry.data); }), catchError(() => of(null)) ); } /** * Store a value in Cache Storage. * Fire-and-forget — errors are silently swallowed so they never block the caller. * * @param cacheName Name of the Cache Storage bucket. * @param key Entry key. * @param data Value to store (must be JSON-serialisable). */ put(cacheName: string, key: string, data: T): void { if (!this.supported) return; const entry: BrowserCacheEntry = { data, cachedAt: Date.now() }; caches.open(cacheName) .then(cache => cache.put( this.entryUrl(cacheName, key), new Response(JSON.stringify(entry), { headers: { 'Content-Type': 'application/json' } }) )) .catch(() => { /* silent */ }); } /** * Delete an entire Cache Storage bucket, invalidating all its entries. * Typically called after a mutation (create / update / delete). * * @param cacheName Name of the Cache Storage bucket to delete. */ invalidate(cacheName: string): void { if (!this.supported) return; caches.delete(cacheName).catch(() => { /* silent */ }); } }