import fs from "fs"; import path from "path"; import { config } from "../config/env"; /** * NAS storage for financial documents. * Structure: {basePath}/Vydané/YYYY/MM/ and {basePath}/Přijaté/YYYY/MM/ */ const DIR_ISSUED = "Vydané"; const DIR_RECEIVED = "Přijaté"; class NasFinancialsManager { private readonly basePath: string; constructor() { const raw = config.nas.financialsPath; this.basePath = raw ? path.resolve(raw).replace(/\\/g, "/") : ""; } isConfigured(): boolean { if (!this.basePath) return false; try { 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) invoices ──────────────────────────────────── /** Remove any existing PDF for this invoice number across all year/month folders */ cleanIssuedInvoice(invoiceNumber: string): void { if (!this.basePath) return; const safeName = this.sanitizeFilename(invoiceNumber) + ".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; } const filePath = path.join(monthPath, safeName); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } } } catch (err) { console.error( "[nas-financials-manager] cleanIssuedInvoice failed for:", invoiceNumber, err, ); } } saveIssuedInvoicePdf( invoiceNumber: string, year: number, month: number, pdfBuffer: Buffer, ): string | null { const dir = this.ensureDir(DIR_ISSUED, year, month); if (!dir) return null; const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf"; const fullPath = this.uniquePath(dir, safeName); try { fs.writeFileSync(fullPath, pdfBuffer); return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`; } catch (err) { console.error( "[nas-financials-manager] write failed for issued invoice:", fullPath, err, ); return null; } } readIssuedInvoice( 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 invoice:", fullPath, err, ); return null; } } deleteIssuedInvoice(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 invoice:", fullPath, err, ); return false; } } // ── Received invoices ──────────────────────────────────────────── buildReceivedPath(fileName: string, year: number, month: number): string { const safeName = this.sanitizeFilename(fileName) || "file"; return `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`; } saveReceivedInvoice( originalName: string, year: number, month: number, fileBuffer: Buffer, ): { filePath: string } | { error: string } { if (!this.isConfigured()) { return { error: "NAS financials 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)}`, }; } } readReceivedInvoice( 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 invoice:", fullPath, err, ); return null; } } deleteReceivedInvoice(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 invoice:", 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 nasFinancialsManager = new NasFinancialsManager();