import fs from "fs"; import path from "path"; import { config } from "../config/env"; /** * NAS storage for offers/quotations. * Structure: {basePath}/{YYYY}/{prefix_seq}/{year_prefix_seq}.pdf * The env path (NAS_OFFERS_PATH) should point directly to the Nabídky folder. */ class NasOffersManager { private readonly basePath: string; constructor() { const raw = config.nas.offersPath; 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 { return false; } } saveOfferPdf( quotationNumber: string, year: number, pdfBuffer: Buffer, ): string | null { const { prefix, seq } = this.parseParts(quotationNumber); const folderName = this.sanitizeFilename(`${prefix}_${seq}`); const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf"; const dir = this.ensureDir(year, folderName); if (!dir) return null; const fullPath = path.join(dir, fileName); try { fs.writeFileSync(fullPath, pdfBuffer); return `${year}/${folderName}/${fileName}`; } catch { return null; } } readOfferPdf( 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 { return null; } } deleteOfferPdf(relativePath: string): boolean { const fullPath = this.resolveSafePath(relativePath); if (!fullPath) return false; try { fs.unlinkSync(fullPath); // Remove parent folder if empty const dir = path.dirname(fullPath); const remaining = fs.readdirSync(dir); if (remaining.length === 0) { fs.rmdirSync(dir); } return true; } catch { return false; } } /** Build the relative NAS path for a given quotation number + year */ buildRelativePath(quotationNumber: string, year: number): string { const { prefix, seq } = this.parseParts(quotationNumber); const folderName = this.sanitizeFilename(`${prefix}_${seq}`); const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf"; return `${year}/${folderName}/${fileName}`; } private parseParts(quotationNumber: string): { prefix: string; seq: string; } { const parts = quotationNumber.split("/"); return { prefix: this.sanitizeFilename( parts.length >= 2 ? parts[parts.length - 2] : "NA", ), seq: this.sanitizeFilename(parts[parts.length - 1]), }; } // ── Private helpers ────────────────────────────────────────────── private ensureDir(year: number, quotationNumber?: string): string | null { const parts = [this.basePath, String(year)]; if (quotationNumber) parts.push(quotationNumber); const dirPath = path.join(...parts); try { fs.mkdirSync(dirPath, { recursive: true }); return dirPath; } catch { return null; } } 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 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 nasOffersManager = new NasOffersManager();