feat: NAS storage for invoices/offers, code cleanup, date/time fixes
- NAS storage for created invoices (PDF via puppeteer), received invoices, and offers with auto-save on create/edit - Deterministic file paths derived from DB fields (no file_path column needed) - Separate NAS mount points: NAS_FINANCIALS_PATH, NAS_OFFERS_PATH - Invoice language field (cs/en) stored per invoice, replaces lang modal - Invoices list filtered by month/year matching KPI card selection - Centralized date helpers (src/utils/date.ts) replacing all .toISOString() calls that returned UTC instead of local time - Attendance project switching uses exact time (not rounded) - Comment cleanup: removed ~100 unnecessary/Czech comments - Removed as-any casts in orders and attendance - Prisma migrations: add invoice language, drop received_invoices BLOB columns Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { attendance_leave_type, Prisma } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import { getBusinessDaysInMonth } from "../utils/czech-holidays";
|
||||
import { localDateStr } from "../utils/date";
|
||||
|
||||
type AttendanceWithRelations = Prisma.attendanceGetPayload<{
|
||||
include: {
|
||||
@@ -254,7 +255,6 @@ export async function getStatus(userId: number) {
|
||||
};
|
||||
|
||||
// 5) Project logs for ongoing shift
|
||||
// Collect all project IDs from completed shifts for name lookup
|
||||
const completedProjectIds = new Set<number>();
|
||||
for (const shift of todayShiftsRaw) {
|
||||
for (const log of shift.attendance_project_logs) {
|
||||
@@ -262,7 +262,6 @@ export async function getStatus(userId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch project names for completed shifts
|
||||
const completedProjectNames = new Map<number, string>();
|
||||
if (completedProjectIds.size > 0) {
|
||||
const projects = await prisma.projects.findMany({
|
||||
@@ -277,7 +276,6 @@ export async function getStatus(userId: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich today's completed shifts with project names
|
||||
const todayShifts = todayShiftsRaw.map((shift) => ({
|
||||
...shift,
|
||||
project_logs: shift.attendance_project_logs.map((l) => ({
|
||||
@@ -337,7 +335,7 @@ export async function getStatus(userId: number) {
|
||||
today_shifts: todayShifts,
|
||||
leave_balance: leaveBalance,
|
||||
monthly_fund: monthlyFund,
|
||||
date: now.toISOString().split("T")[0],
|
||||
date: localDateStr(now),
|
||||
project_logs: projectLogs,
|
||||
active_project_id: activeProjectId,
|
||||
};
|
||||
@@ -759,7 +757,6 @@ export async function getPrintData(
|
||||
|
||||
const fundHours = getBusinessDaysInMonth(yr, mo - 1) * 8;
|
||||
|
||||
// Load project names for enrichment
|
||||
const typedRecords = records as AttendanceWithRelations[];
|
||||
|
||||
const projectIds = [
|
||||
@@ -784,7 +781,6 @@ export async function getPrintData(
|
||||
}
|
||||
}
|
||||
|
||||
// Group records by user and calculate totals
|
||||
const userTotals: Record<string, Record<string, unknown>> = {};
|
||||
for (const rec of typedRecords) {
|
||||
const uid = String(rec.user_id);
|
||||
@@ -806,7 +802,6 @@ export async function getPrintData(
|
||||
};
|
||||
}
|
||||
|
||||
// Build record with project_logs for frontend
|
||||
const projectLogs =
|
||||
rec.attendance_project_logs?.map((log) => ({
|
||||
project_id: log.project_id,
|
||||
@@ -843,7 +838,6 @@ export async function getPrintData(
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate fund coverage per user
|
||||
for (const uid of Object.keys(userTotals)) {
|
||||
const ut = userTotals[uid];
|
||||
const workedH = Math.round(((ut.minutes as number) / 60) * 10) / 10;
|
||||
@@ -864,7 +858,6 @@ export async function getPrintData(
|
||||
);
|
||||
}
|
||||
|
||||
// Leave balances
|
||||
const leaveBalances: Record<string, Record<string, number>> = {};
|
||||
const balanceRecords = await prisma.leave_balances.findMany({
|
||||
where: { year: yr },
|
||||
@@ -878,7 +871,6 @@ export async function getPrintData(
|
||||
};
|
||||
}
|
||||
|
||||
// Selected user name
|
||||
let selectedUserName = "";
|
||||
if (filterUserId) {
|
||||
const u = users.find((u) => u.id === filterUserId);
|
||||
@@ -912,7 +904,8 @@ export async function getActiveProjects() {
|
||||
});
|
||||
return activeProjects.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.project_number ? `${p.project_number} – ${p.name}` : p.name,
|
||||
name: p.name,
|
||||
project_number: p.project_number ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1069,9 +1062,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
||||
select: { user_id: true, shift_date: true },
|
||||
});
|
||||
const existingSet = new Set(
|
||||
existing.map(
|
||||
(r) => `${r.user_id}:${r.shift_date.toISOString().split("T")[0]}`,
|
||||
),
|
||||
existing.map((r) => `${r.user_id}:${localDateStr(r.shift_date)}`),
|
||||
);
|
||||
|
||||
let inserted = 0;
|
||||
@@ -1080,7 +1071,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
||||
for (const userId of data.user_ids.map(Number)) {
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(yr, mo - 1, day);
|
||||
const dateStr = date.toISOString().split("T")[0];
|
||||
const dateStr = localDateStr(date);
|
||||
const dow = date.getDay();
|
||||
|
||||
if (dow === 0 || dow === 6) continue;
|
||||
@@ -1136,7 +1127,7 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
||||
while (current <= end) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
const dateStr = current.toISOString().split("T")[0];
|
||||
const dateStr = localDateStr(current);
|
||||
const shiftDate = new Date(
|
||||
Date.UTC(
|
||||
current.getFullYear(),
|
||||
@@ -1161,7 +1152,6 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
// Update leave balance for vacation/sick
|
||||
const totalLeaveHours =
|
||||
created * (data.leave_hours ? Number(data.leave_hours) : 8);
|
||||
if (
|
||||
|
||||
@@ -111,7 +111,6 @@ export async function login(
|
||||
return { type: "error", message: "Účet je deaktivován", status: 403 };
|
||||
}
|
||||
|
||||
// Check lockout
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
return {
|
||||
type: "error",
|
||||
@@ -141,7 +140,6 @@ export async function login(
|
||||
};
|
||||
}
|
||||
|
||||
// Reset failed attempts
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
@@ -151,7 +149,6 @@ export async function login(
|
||||
},
|
||||
});
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if (user.totp_enabled) {
|
||||
const loginToken = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = hashToken(loginToken);
|
||||
@@ -167,7 +164,6 @@ export async function login(
|
||||
return { type: "totp_required", loginToken };
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const authData = await loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return {
|
||||
@@ -241,7 +237,6 @@ export async function refreshAccessToken(
|
||||
return { type: "error", message: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
// Rotate refresh token
|
||||
const newRefreshTokenRaw = generateRefreshToken();
|
||||
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
|
||||
|
||||
@@ -303,7 +298,6 @@ export async function logout(refreshTokenRaw: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up expired tokens
|
||||
await prisma.refresh_tokens.deleteMany({
|
||||
where: { expires_at: { lt: new Date() } },
|
||||
});
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import prisma from "../config/database";
|
||||
|
||||
// Re-export for convenience
|
||||
|
||||
// Status transition rules matching PHP
|
||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
issued: ["paid"],
|
||||
@@ -36,6 +34,8 @@ interface ListInvoicesParams {
|
||||
search: string;
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
function computeInvoiceTotals(
|
||||
@@ -75,13 +75,28 @@ export async function markOverdueInvoices() {
|
||||
}
|
||||
|
||||
export async function listInvoices(params: ListInvoicesParams) {
|
||||
const { page, limit, skip, sort, order, search, status, customer_id } =
|
||||
params;
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
skip,
|
||||
sort,
|
||||
order,
|
||||
search,
|
||||
status,
|
||||
customer_id,
|
||||
month,
|
||||
year,
|
||||
} = params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status) where.status = status;
|
||||
if (customer_id) where.customer_id = customer_id;
|
||||
if (month && year) {
|
||||
const from = new Date(year, month - 1, 1);
|
||||
const to = new Date(year, month, 1);
|
||||
where.issue_date = { gte: from, lt: to };
|
||||
}
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ invoice_number: { contains: search } },
|
||||
@@ -159,7 +174,6 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
include: { invoice_items: true },
|
||||
});
|
||||
|
||||
// Helper: compute invoice total WITH VAT (matching PHP)
|
||||
const invoiceTotalWithVat = (inv: (typeof allInvoices)[0]) => {
|
||||
const sub = inv.invoice_items.reduce(
|
||||
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
@@ -177,7 +191,6 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
return sub + vat;
|
||||
};
|
||||
|
||||
// Helper: aggregate by currency
|
||||
const aggregateByCurrency = (invoices: typeof allInvoices) => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const inv of invoices) {
|
||||
@@ -209,7 +222,6 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
const awaitingInvoices = allInvoices.filter((i) => i.status === "issued");
|
||||
const overdueInvoices = allInvoices.filter((i) => i.status === "overdue");
|
||||
|
||||
// VAT by currency
|
||||
const vatMap: Record<string, number> = {};
|
||||
for (const inv of monthInvoices) {
|
||||
if (!inv.apply_vat) continue;
|
||||
@@ -309,6 +321,7 @@ export async function createInvoice(body: Record<string, any>) {
|
||||
tax_date: body.tax_date ? new Date(String(body.tax_date)) : null,
|
||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||
billing_text: body.billing_text ? String(body.billing_text) : null,
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
internal_notes: body.internal_notes ? String(body.internal_notes) : null,
|
||||
},
|
||||
@@ -361,6 +374,7 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
|
||||
"bank_account",
|
||||
"issued_by",
|
||||
"billing_text",
|
||||
"language",
|
||||
];
|
||||
for (const f of strFields) {
|
||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sendMail } from "./mailer";
|
||||
import { config } from "../config/env";
|
||||
import { localDateCzStr, localDateTimeCzStr } from "../utils/date";
|
||||
|
||||
const LEAVE_TYPE_LABELS: Record<string, string> = {
|
||||
vacation: "Dovolená",
|
||||
@@ -10,7 +11,7 @@ const LEAVE_TYPE_LABELS: Record<string, string> = {
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
|
||||
return localDateCzStr(d);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
@@ -59,7 +60,7 @@ export async function notifyNewLeaveRequest(
|
||||
: "";
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = `${String(now.getDate()).padStart(2, "0")}.${String(now.getMonth() + 1).padStart(2, "0")}.${now.getFullYear()} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
||||
const timestamp = localDateTimeCzStr(now);
|
||||
|
||||
const html = `
|
||||
<html>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { config } from "../config/env";
|
||||
import { localDateStr, localTimeStr } from "../utils/date";
|
||||
|
||||
const FileType = require("file-type") as typeof import("file-type");
|
||||
|
||||
@@ -223,16 +224,7 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
const modified = lstat.mtime;
|
||||
const modifiedStr =
|
||||
modified.getFullYear() +
|
||||
"-" +
|
||||
String(modified.getMonth() + 1).padStart(2, "0") +
|
||||
"-" +
|
||||
String(modified.getDate()).padStart(2, "0") +
|
||||
" " +
|
||||
String(modified.getHours()).padStart(2, "0") +
|
||||
":" +
|
||||
String(modified.getMinutes()).padStart(2, "0");
|
||||
const modifiedStr = `${localDateStr(modified)} ${localTimeStr(modified)}`;
|
||||
|
||||
const item: FileItem = {
|
||||
name: entry,
|
||||
@@ -284,7 +276,6 @@ export class NasFileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Real path on disk for UI display
|
||||
let realDirPath: string;
|
||||
try {
|
||||
realDirPath = fs.realpathSync(dirPath);
|
||||
@@ -331,7 +322,6 @@ export class NasFileManager {
|
||||
return "Tento typ souboru není povolen";
|
||||
}
|
||||
|
||||
// MIME validation via file-type
|
||||
try {
|
||||
const typeResult = await FileType.fromBuffer(fileBuffer);
|
||||
if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
|
||||
@@ -343,7 +333,6 @@ export class NasFileManager {
|
||||
|
||||
let destPath = dirPath + "/" + safeName;
|
||||
|
||||
// If file exists, append counter
|
||||
if (fs.existsSync(destPath)) {
|
||||
const base = path.basename(safeName, ext ? "." + ext : "");
|
||||
let counter = 1;
|
||||
@@ -456,7 +445,6 @@ export class NasFileManager {
|
||||
return "Cílový soubor již existuje";
|
||||
}
|
||||
|
||||
// Validate target name
|
||||
const targetName = path.basename(toPath);
|
||||
if (this.sanitizeFilename(targetName) !== targetName) {
|
||||
return "Neplatný cílový název";
|
||||
@@ -513,7 +501,6 @@ export class NasFileManager {
|
||||
|
||||
public sanitizeFilename(name: string): string {
|
||||
let safe = path.basename(name);
|
||||
// Strip control chars and special chars
|
||||
safe = safe.replace(/[\x00-\x1f\x7f<>:"/\\|?*]/g, "");
|
||||
safe = safe.replace(/^[. ]+|[. ]+$/g, "");
|
||||
|
||||
@@ -583,7 +570,6 @@ export class NasFileManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize separators and trim
|
||||
const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/");
|
||||
|
||||
@@ -618,7 +604,6 @@ export class NasFileManager {
|
||||
const normalFull = fullPath.replace(/\\/g, "/");
|
||||
const normalBase = basePath.replace(/\\/g, "/");
|
||||
|
||||
// Get the relative portion after basePath
|
||||
if (!normalFull.startsWith(normalBase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
214
src/services/nas-financials-manager.ts
Normal file
214
src/services/nas-financials-manager.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Created (issued) invoices ────────────────────────────────────
|
||||
|
||||
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 = path.join(dir, safeName);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fullPath, pdfBuffer);
|
||||
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`;
|
||||
} catch {
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
deleteIssuedInvoice(relativePath: string): boolean {
|
||||
const fullPath = this.resolveSafePath(relativePath);
|
||||
if (!fullPath) return false;
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
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 = path.join(dir, safeName);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fullPath, fileBuffer);
|
||||
return {
|
||||
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`,
|
||||
};
|
||||
} 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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
deleteReceivedInvoice(filePath: string): boolean {
|
||||
const fullPath = this.resolveSafePath(filePath);
|
||||
if (!fullPath) return false;
|
||||
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
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 {
|
||||
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();
|
||||
146
src/services/nas-offers-manager.ts
Normal file
146
src/services/nas-offers-manager.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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 = `${prefix}_${seq}`;
|
||||
const fileName = `${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 = `${prefix}_${seq}`;
|
||||
const fileName = `${year}_${prefix}_${seq}.pdf`;
|
||||
return `${year}/${folderName}/${fileName}`;
|
||||
}
|
||||
|
||||
private parseParts(quotationNumber: string): {
|
||||
prefix: string;
|
||||
seq: string;
|
||||
} {
|
||||
const parts = quotationNumber.split("/");
|
||||
return {
|
||||
prefix: parts.length >= 2 ? parts[parts.length - 2] : "NA",
|
||||
seq: 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();
|
||||
@@ -11,9 +11,9 @@ interface OrderItemInput {
|
||||
position?: number;
|
||||
}
|
||||
interface OrderSectionInput {
|
||||
title?: string;
|
||||
title_cz?: string;
|
||||
content?: string;
|
||||
title?: string | null;
|
||||
title_cz?: string | null;
|
||||
content?: string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
@@ -331,6 +331,7 @@ export async function createOrder(body: CreateOrderData) {
|
||||
|
||||
interface UpdateOrderData {
|
||||
[key: string]: unknown;
|
||||
customer_id?: number | string | null;
|
||||
items?: OrderItemInput[];
|
||||
sections?: OrderSectionInput[];
|
||||
}
|
||||
@@ -342,7 +343,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
|
||||
const currentStatus = existing.status as string;
|
||||
|
||||
// Validate status transition
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const newStatus = String(body.status);
|
||||
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
||||
|
||||
@@ -97,12 +97,10 @@ export async function createUser(data: CreateUserData) {
|
||||
const firstName = data.first_name.trim();
|
||||
const lastName = data.last_name.trim();
|
||||
|
||||
// Email format
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { error: "Neplatný formát e-mailu", status: 400 } as const;
|
||||
}
|
||||
|
||||
// Username uniqueness
|
||||
const existingUsername = await prisma.users.findFirst({
|
||||
where: { username },
|
||||
});
|
||||
@@ -110,7 +108,6 @@ export async function createUser(data: CreateUserData) {
|
||||
return { error: "Uživatelské jméno již existuje", status: 409 } as const;
|
||||
}
|
||||
|
||||
// Email uniqueness
|
||||
const existingEmail = await prisma.users.findFirst({ where: { email } });
|
||||
if (existingEmail) {
|
||||
return { error: "E-mail již existuje", status: 409 } as const;
|
||||
@@ -144,7 +141,6 @@ export async function updateUser(id: number, body: UpdateUserData) {
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
// Username validation and uniqueness
|
||||
if (body.username !== undefined) {
|
||||
const newUsername = String(body.username).trim();
|
||||
if (newUsername !== existing.username) {
|
||||
@@ -161,7 +157,6 @@ export async function updateUser(id: number, body: UpdateUserData) {
|
||||
data.username = newUsername;
|
||||
}
|
||||
|
||||
// Email validation and uniqueness
|
||||
if (body.email !== undefined) {
|
||||
const newEmail = String(body.email).trim();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||
|
||||
Reference in New Issue
Block a user