| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- 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<T>} loader
- * @returns {Promise<T>}
- */
- 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}`,
- }));
- }
- );
- }
|