import fs from "node:fs/promises"; import path from "node:path"; // Root directory of the NAS share, injected via environment variable. // On the Linux app server, this is typically `/mnt/niederlassungen`. // Do NOT cache process.env.NAS_ROOT_PATH at module load time. // Instead, resolve it on demand so tests (and runtime) can change it. function getRoot() { const root = process.env.NAS_ROOT_PATH; if (!root) { throw new Error("NAS_ROOT_PATH environment variable is not set"); } return root; } // Build an absolute path below the NAS root from a list of segments. function fullPath(...segments) { const root = getRoot(); return path.join(root, ...segments.map(String)); } // Compare strings that represent numbers in a numeric way. // This ensures "2" comes before "10" (2 < 10), not after. function sortNumericStrings(a, b) { const na = parseInt(a, 10); const nb = parseInt(b, 10); if (!Number.isNaN(na) && !Number.isNaN(nb)) { return na - nb; } // Fallback to localeCompare if parsing fails return a.localeCompare(b, "en"); } // ----------------------------------------------------------------------------- // RHL-006: Storage micro-cache (process-local TTL cache) // ----------------------------------------------------------------------------- const TTL_BRANCHES_MS = 60_000; const TTL_YEARS_MS = 60_000; const TTL_MONTHS_MS = 15_000; const TTL_DAYS_MS = 15_000; const TTL_FILES_MS = 15_000; // Internal cache store: // key -> { expiresAt, value } OR { expiresAt, promise } // - value: resolved cache value // - promise: in-flight load promise to collapse concurrent reads const __storageCache = new Map(); /** * Build a stable cache key for a given listing type. * * We include NAS_ROOT_PATH in the key so tests that change the env var do not * accidentally reuse data from a previous test run. * * @param {string} type * @param {...string} parts * @returns {string} */ function buildCacheKey(type, ...parts) { const root = getRoot(); return [type, root, ...parts.map(String)].join("|"); } /** * Generic TTL-cache wrapper. * * Behavior: * 1) If a load is already in-flight (promise exists), reuse it. * 2) If a cached value exists and is not expired, return it. * 3) Otherwise run loader(), store the result, and return it. * * Failure policy: * - If loader() throws, the cache entry is removed so later calls can retry. * * @template T * @param {string} key * @param {number} ttlMs * @param {() => Promise} loader * @returns {Promise} */ async function withTtlCache(key, ttlMs, loader) { const now = Date.now(); const existing = __storageCache.get(key); // 1) Collapsing concurrent calls: if (existing?.promise) { return existing.promise; } // 2) Serve cached values while still fresh: if (existing && existing.value !== undefined && existing.expiresAt > now) { return existing.value; } // 3) Cache miss or expired: start a new load. const promise = (async () => { try { const value = await loader(); __storageCache.set(key, { value, expiresAt: Date.now() + ttlMs, }); return value; } catch (err) { __storageCache.delete(key); throw err; } })(); __storageCache.set(key, { promise, expiresAt: now + ttlMs, }); return promise; } /** * TEST-ONLY helper: clear the micro-cache. */ export function __clearStorageCacheForTests() { __storageCache.clear(); } // ----------------------------------------------------------------------------- // 1. Branches (NL01, NL02, ...) // ----------------------------------------------------------------------------- export async function listBranches() { return withTtlCache(buildCacheKey("branches"), TTL_BRANCHES_MS, async () => { const entries = await fs.readdir(fullPath(), { withFileTypes: true }); return entries .filter( (entry) => entry.isDirectory() && entry.name !== "@Recently-Snapshot" && /^NL\d+$/.test(entry.name) // strict: UI expects "NL" uppercase, Linux FS is case-sensitive ) .map((entry) => entry.name) .sort((a, b) => sortNumericStrings(a.slice(2), b.slice(2))); }); } // ----------------------------------------------------------------------------- // 2. Years (2023, 2024, ...) // ----------------------------------------------------------------------------- export async function listYears(branch) { return withTtlCache( buildCacheKey("years", branch), TTL_YEARS_MS, async () => { const dir = fullPath(branch); const entries = await fs.readdir(dir, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name)) .map((entry) => entry.name) .sort(sortNumericStrings); } ); } // ----------------------------------------------------------------------------- // 3. Months (01–12) // ----------------------------------------------------------------------------- export async function listMonths(branch, year) { return withTtlCache( buildCacheKey("months", branch, year), TTL_MONTHS_MS, async () => { const dir = fullPath(branch, year); const entries = await fs.readdir(dir, { withFileTypes: true }); // Important: filter to valid calendar months (1–12) so the UI never gets // impossible values that would be rejected by route param validation. return entries .filter((entry) => entry.isDirectory() && /^\d{1,2}$/.test(entry.name)) .map((entry) => entry.name.trim()) .filter((raw) => { const n = parseInt(raw, 10); return Number.isInteger(n) && n >= 1 && n <= 12; }) .map((raw) => raw.padStart(2, "0")) .sort(sortNumericStrings); } ); } // ----------------------------------------------------------------------------- // 4. Days (01–31) // ----------------------------------------------------------------------------- export async function listDays(branch, year, month) { return withTtlCache( buildCacheKey("days", branch, year, month), TTL_DAYS_MS, async () => { const dir = fullPath(branch, year, month); const entries = await fs.readdir(dir, { withFileTypes: true }); // Important: filter to valid calendar days (1–31) to align with UI param validation. return entries .filter((entry) => entry.isDirectory() && /^\d{1,2}$/.test(entry.name)) .map((entry) => entry.name.trim()) .filter((raw) => { const n = parseInt(raw, 10); return Number.isInteger(n) && n >= 1 && n <= 31; }) .map((raw) => raw.padStart(2, "0")) .sort(sortNumericStrings); } ); } // ----------------------------------------------------------------------------- // 5. Files (PDFs) for a given day // ----------------------------------------------------------------------------- export async function listFiles(branch, year, month, day) { return withTtlCache( buildCacheKey("files", branch, year, month, day), TTL_FILES_MS, async () => { const dir = fullPath(branch, year, month, day); const entries = await fs.readdir(dir); return entries .filter((name) => name.toLowerCase().endsWith(".pdf")) .sort((a, b) => a.localeCompare(b, "en")) .map((name) => ({ name, relativePath: `${branch}/${year}/${month}/${day}/${name}`, })); } ); }