import fs from "fs"; import path from "path"; import { config } from "../config/env"; /** * NAS storage for financial/order documents (issued invoices, received * invoices, issued purchase orders — any document archived under the standard * Vydané/Přijaté layout). One instance is bound to a single base path, so a * separate singleton is exported per document family (invoices vs orders). * Structure: {basePath}/Vydané/YYYY/MM/ and {basePath}/Přijaté/YYYY/MM/ * * PERFORMANCE NOTE (audit finding — DEFERRED, intentional): * Every method here uses SYNCHRONOUS fs calls (mkdirSync/writeFileSync/ * readFileSync/readdirSync/statSync). When basePath is a network (SMB) share, a * slow or hung NAS will BLOCK the Node event loop for the duration of the call, * stalling all other requests in the same process. This is a known trade-off: * the file sizes are small (PDFs), the call sites are low-frequency admin * operations, and the synchronous API lets callers treat a returned path as a * verified, durable write (see migrate-received-invoices-to-nas.ts, which * relies on this to safely null out the DB BLOB). Converting to the async * fs.promises API is a large, cross-cutting change touching every caller and * their error handling — deliberately NOT done here to keep this fix scoped. */ const DIR_ISSUED = "Vydané"; const DIR_RECEIVED = "Přijaté"; export class NasDocumentManager { private readonly basePath: string; constructor(basePath: string) { this.basePath = basePath ? path.resolve(basePath).replace(/\\/g, "/") : ""; } isConfigured(): boolean { if (!this.basePath) return false; try { // Intentional side effect: ensure the base directory exists as part of the // configured-check so the first save doesn't fail on a missing root. This // mirrors nas-offers-manager.isConfigured() (established convention). fs.mkdirSync(this.basePath, { recursive: true }); return fs.statSync(this.basePath).isDirectory(); } catch (err) { console.error( "[nas-financials-manager] mkdir/stat failed for basePath:", this.basePath, err, ); return false; } } // ── Created (issued) documents ─────────────────────────────────── /** * Remove any existing PDF for this document number across all year/month * folders — including stale `_N.pdf` duplicates that the old * uniquePath-based save left behind when two archive calls raced (the * save-after-edit fire-and-forget vs. the /file fallback). Document * numbers are digit-only and never contain "_", so the suffix match * cannot hit a different document. */ cleanIssued(documentNumber: string): void { if (!this.basePath) return; const safeBase = this.sanitizeFilename(documentNumber); const stalePattern = new RegExp( `^${safeBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(_\\d+)?\\.pdf$`, ); const issuedDir = path.join(this.basePath, DIR_ISSUED); try { if (!fs.existsSync(issuedDir)) return; for (const yearDir of fs.readdirSync(issuedDir)) { const yearPath = path.join(issuedDir, yearDir); if (!fs.statSync(yearPath).isDirectory()) continue; for (const monthDir of fs.readdirSync(yearPath)) { const monthPath = path.join(yearPath, monthDir); try { if (!fs.statSync(monthPath).isDirectory()) continue; } catch (err) { console.error( "[nas-financials-manager] stat failed for monthPath:", monthPath, err, ); continue; } for (const file of fs.readdirSync(monthPath)) { if (stalePattern.test(file)) { fs.unlinkSync(path.join(monthPath, file)); } } } } } catch (err) { console.error( "[nas-financials-manager] cleanIssued failed for:", documentNumber, err, ); } } saveIssuedPdf( documentNumber: string, year: number, month: number, pdfBuffer: Buffer, ): string | null { // Guard parity with saveReceived: without this, an unconfigured // (empty) basePath would let ensureDir build a RELATIVE path and write the // PDF into the process CWD instead of the NAS. Bail out instead. if (!this.isConfigured()) return null; const dir = this.ensureDir(DIR_ISSUED, year, month); if (!dir) return null; // Deterministic path, OVERWRITE in place (same model as the offers // manager): the archive of a numbered document has exactly one canonical // file. uniquePath here used to spawn `_1.pdf` duplicates // whenever two archive calls raced — those are user-visible junk on the // NAS and invisible to the reader/cleaner, which match the exact name. const safeName = this.sanitizeFilename(documentNumber) + ".pdf"; const fullPath = path.join(dir, safeName); try { fs.writeFileSync(fullPath, pdfBuffer); return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`; } catch (err) { console.error( "[nas-financials-manager] write failed for issued document:", fullPath, err, ); return null; } } /** * Locate and read the archived PDF for an issued document by its number * alone, sweeping every Vydané/YYYY/MM folder (the same walk cleanIssued * uses). Needed because the archive folder is derived from the document * date at save time and the date can be edited afterwards — a fixed * expected-path read would miss a file that does exist in another month * folder. Returns null when not found (callers fall back to a live render). */ readIssuedByNumber( documentNumber: string, ): { data: Buffer; fileName: string } | null { if (!this.basePath) return null; const safeName = this.sanitizeFilename(documentNumber) + ".pdf"; const issuedDir = path.join(this.basePath, DIR_ISSUED); try { if (!fs.existsSync(issuedDir)) return null; for (const yearDir of fs.readdirSync(issuedDir)) { const yearPath = path.join(issuedDir, yearDir); if (!fs.statSync(yearPath).isDirectory()) continue; for (const monthDir of fs.readdirSync(yearPath)) { const monthPath = path.join(yearPath, monthDir); try { if (!fs.statSync(monthPath).isDirectory()) continue; } catch (err) { console.error( "[nas-financials-manager] stat failed for monthPath:", monthPath, err, ); continue; } const filePath = path.join(monthPath, safeName); if (fs.existsSync(filePath)) { return { data: fs.readFileSync(filePath), fileName: safeName }; } } } return null; } catch (err) { console.error( "[nas-financials-manager] readIssuedByNumber failed for:", documentNumber, err, ); return null; } } readIssued(relativePath: string): { data: Buffer; fileName: string } | null { const fullPath = this.resolveSafePath(relativePath); if (!fullPath) return null; try { const data = fs.readFileSync(fullPath); return { data, fileName: path.basename(fullPath) }; } catch (err) { console.error( "[nas-financials-manager] read failed for issued document:", fullPath, err, ); return null; } } deleteIssued(relativePath: string): boolean { const fullPath = this.resolveSafePath(relativePath); if (!fullPath) return false; try { fs.unlinkSync(fullPath); return true; } catch (err) { console.error( "[nas-financials-manager] delete failed for issued document:", fullPath, err, ); return false; } } // ── Received documents ─────────────────────────────────────────── buildReceivedPath(fileName: string, year: number, month: number): string { const safeName = this.sanitizeFilename(fileName) || "file"; return `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`; } saveReceived( originalName: string, year: number, month: number, fileBuffer: Buffer, ): { filePath: string } | { error: string } { if (!this.isConfigured()) { return { error: "NAS path is not configured" }; } const dir = this.ensureDir(DIR_RECEIVED, year, month); if (!dir) return { error: "Nelze vytvořit adresář na NAS" }; let safeName = this.sanitizeFilename(originalName); if (!safeName) safeName = "file"; const fullPath = this.uniquePath(dir, safeName); try { fs.writeFileSync(fullPath, fileBuffer); return { filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`, }; } catch (e) { return { error: `Nelze uložit soubor na NAS: ${e instanceof Error ? e.message : String(e)}`, }; } } readReceived(filePath: string): { data: Buffer; fileName: string } | null { const fullPath = this.resolveSafePath(filePath); if (!fullPath) return null; try { const data = fs.readFileSync(fullPath); return { data, fileName: path.basename(fullPath) }; } catch (err) { console.error( "[nas-financials-manager] read failed for received document:", fullPath, err, ); return null; } } deleteReceived(filePath: string): boolean { const fullPath = this.resolveSafePath(filePath); if (!fullPath) return false; try { fs.unlinkSync(fullPath); return true; } catch (err) { console.error( "[nas-financials-manager] delete failed for received document:", fullPath, err, ); return false; } } // ── Private helpers ────────────────────────────────────────────── private ensureDir( category: string, year: number, month: number, ): string | null { const dirPath = path.join( this.basePath, category, String(year), this.pad(month), ); try { fs.mkdirSync(dirPath, { recursive: true }); return dirPath; } catch (err) { console.error( "[nas-financials-manager] mkdir failed for path:", dirPath, err, ); return null; } } private pad(n: number): string { return String(n).padStart(2, "0"); } private sanitizeFilename(name: string): string { let safe = path.basename(name); safe = safe.replace(/[\x00-\x1f\x7f<>:"/\\|?*]/g, ""); safe = safe.replace(/^[. ]+|[. ]+$/g, ""); if ([...safe].length > 255) { const ext = path.extname(safe).slice(1); const base = path.basename(safe, ext ? "." + ext : ""); const maxBase = 250 - [...ext].length; safe = ext ? [...base].slice(0, maxBase).join("") + "." + ext : [...base].slice(0, maxBase).join(""); } return safe; } private uniquePath(dir: string, fileName: string): string { let fullPath = path.join(dir, fileName); if (!fs.existsSync(fullPath)) return fullPath; const ext = path.extname(fileName); const base = path.basename(fileName, ext); let counter = 1; while (fs.existsSync(fullPath)) { fullPath = path.join(dir, `${base}_${counter}${ext}`); counter++; } return fullPath; } private resolveSafePath(relativePath: string): string | null { if (!this.basePath) return null; if (relativePath.includes("\0") || relativePath.includes("..")) { return null; } const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); const candidate = path .resolve(this.basePath, normalized) .replace(/\\/g, "/"); const normalBase = this.basePath.replace(/\\/g, "/"); if (!candidate.startsWith(normalBase + "/")) { return null; } return candidate; } } export const nasInvoicesManager = new NasDocumentManager( config.nas.invoicesPath, ); export const nasOrdersManager = new NasDocumentManager(config.nas.ordersPath);