style: run prettier on entire codebase
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import prisma from '../config/database';
|
||||
import { AuditAction, EntityType, AuthData } from '../types';
|
||||
import { FastifyRequest } from "fastify";
|
||||
import prisma from "../config/database";
|
||||
import { AuditAction, EntityType, AuthData } from "../types";
|
||||
|
||||
export async function logAudit(params: {
|
||||
request: FastifyRequest;
|
||||
@@ -24,11 +24,11 @@ export async function logAudit(params: {
|
||||
description: params.description ?? null,
|
||||
old_values: params.oldValues ? JSON.stringify(params.oldValues) : null,
|
||||
new_values: params.newValues ? JSON.stringify(params.newValues) : null,
|
||||
user_agent: params.request.headers['user-agent'] ?? null,
|
||||
user_agent: params.request.headers["user-agent"] ?? null,
|
||||
session_id: null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to write audit log:', err);
|
||||
console.error("Failed to write audit log:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import crypto from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import prisma from '../config/database';
|
||||
import { config } from '../config/env';
|
||||
import { AuthData, JwtPayload } from '../types';
|
||||
import crypto from "crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { AuthData, JwtPayload } from "../types";
|
||||
|
||||
// Pre-computed bcrypt hash for timing-safe comparison when user not found
|
||||
const DUMMY_HASH = '$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO';
|
||||
const DUMMY_HASH =
|
||||
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO";
|
||||
|
||||
// --- Token helpers ---
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function generateAccessToken(user: { id: number; username: string; roleName: string | null }): string {
|
||||
function generateAccessToken(user: {
|
||||
id: number;
|
||||
username: string;
|
||||
roleName: string | null;
|
||||
}): string {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, username: user.username, role: user.roleName },
|
||||
config.jwt.secret,
|
||||
@@ -24,7 +29,7 @@ function generateAccessToken(user: { id: number; username: string; roleName: str
|
||||
}
|
||||
|
||||
function generateRefreshToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
// --- Auth data loading ---
|
||||
@@ -45,10 +50,12 @@ async function loadAuthData(userId: number): Promise<AuthData | null> {
|
||||
|
||||
if (!user || !user.is_active) return null;
|
||||
|
||||
const isAdmin = user.roles?.name === 'admin';
|
||||
const isAdmin = user.roles?.name === "admin";
|
||||
const permissions = isAdmin
|
||||
? (await prisma.permissions.findMany()).map((p: { name: string }) => p.name)
|
||||
: (user.roles?.role_permissions ?? []).map((rp: { permissions: { name: string } }) => rp.permissions.name);
|
||||
: (user.roles?.role_permissions ?? []).map(
|
||||
(rp: { permissions: { name: string } }) => rp.permissions.name,
|
||||
);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
@@ -60,7 +67,9 @@ async function loadAuthData(userId: number): Promise<AuthData | null> {
|
||||
roleName: user.roles?.name ?? null,
|
||||
permissions,
|
||||
totp_enabled: !!user.totp_enabled,
|
||||
require_2fa: !!(await prisma.company_settings.findFirst({ select: { require_2fa: true } }))?.require_2fa,
|
||||
require_2fa: !!(
|
||||
await prisma.company_settings.findFirst({ select: { require_2fa: true } })
|
||||
)?.require_2fa,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,9 +81,14 @@ export async function login(
|
||||
rememberMe: boolean,
|
||||
request: FastifyRequest,
|
||||
): Promise<
|
||||
| { type: 'success'; accessToken: string; refreshToken: string; user: AuthData }
|
||||
| { type: 'totp_required'; loginToken: string }
|
||||
| { type: 'error'; message: string; status: number }
|
||||
| {
|
||||
type: "success";
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: AuthData;
|
||||
}
|
||||
| { type: "totp_required"; loginToken: string }
|
||||
| { type: "error"; message: string; status: number }
|
||||
> {
|
||||
const user = await prisma.users.findFirst({
|
||||
where: {
|
||||
@@ -86,40 +100,60 @@ export async function login(
|
||||
if (!user) {
|
||||
// Timing-safe: run bcrypt even when user not found
|
||||
await bcrypt.compare(password, DUMMY_HASH);
|
||||
return { type: 'error', message: 'Neplatné přihlašovací údaje', status: 401 };
|
||||
return {
|
||||
type: "error",
|
||||
message: "Neplatné přihlašovací údaje",
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return { type: 'error', message: 'Účet je deaktivován', status: 403 };
|
||||
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', message: 'Účet je dočasně uzamčen. Zkuste to později.', status: 429 };
|
||||
return {
|
||||
type: "error",
|
||||
message: "Účet je dočasně uzamčen. Zkuste to později.",
|
||||
status: 429,
|
||||
};
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!passwordValid) {
|
||||
const attempts = (user.failed_login_attempts ?? 0) + 1;
|
||||
const updateData: Record<string, unknown> = { failed_login_attempts: attempts };
|
||||
const updateData: Record<string, unknown> = {
|
||||
failed_login_attempts: attempts,
|
||||
};
|
||||
|
||||
if (attempts >= config.security.maxLoginAttempts) {
|
||||
updateData.locked_until = new Date(Date.now() + config.security.lockoutMinutes * 60_000);
|
||||
updateData.locked_until = new Date(
|
||||
Date.now() + config.security.lockoutMinutes * 60_000,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id: user.id }, data: updateData });
|
||||
return { type: 'error', message: 'Neplatné přihlašovací údaje', status: 401 };
|
||||
return {
|
||||
type: "error",
|
||||
message: "Neplatné přihlašovací údaje",
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
// Reset failed attempts
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
data: { failed_login_attempts: 0, locked_until: null, last_login: new Date() },
|
||||
data: {
|
||||
failed_login_attempts: 0,
|
||||
locked_until: null,
|
||||
last_login: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Check if 2FA is enabled
|
||||
if (user.totp_enabled) {
|
||||
const loginToken = crypto.randomBytes(32).toString('hex');
|
||||
const loginToken = crypto.randomBytes(32).toString("hex");
|
||||
const tokenHash = hashToken(loginToken);
|
||||
|
||||
await prisma.totp_login_tokens.create({
|
||||
@@ -130,13 +164,17 @@ export async function login(
|
||||
},
|
||||
});
|
||||
|
||||
return { type: 'totp_required', loginToken };
|
||||
return { type: "totp_required", loginToken };
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const authData = await loadAuthData(user.id);
|
||||
if (!authData) {
|
||||
return { type: 'error', message: 'Chyba načítání uživatelských dat', status: 500 };
|
||||
return {
|
||||
type: "error",
|
||||
message: "Chyba načítání uživatelských dat",
|
||||
status: 500,
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken({
|
||||
@@ -159,19 +197,30 @@ export async function login(
|
||||
expires_at: new Date(Date.now() + expiresIn * 1000),
|
||||
remember_me: rememberMe,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers['user-agent'] ?? null,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { type: 'success', accessToken, refreshToken: refreshTokenRaw, user: authData };
|
||||
return {
|
||||
type: "success",
|
||||
accessToken,
|
||||
refreshToken: refreshTokenRaw,
|
||||
user: authData,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
refreshTokenRaw: string,
|
||||
request: FastifyRequest,
|
||||
): Promise<
|
||||
| { type: 'success'; accessToken: string; refreshToken: string; user: AuthData; rememberMe: boolean }
|
||||
| { type: 'error'; message: string; status: number }
|
||||
| {
|
||||
type: "success";
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: AuthData;
|
||||
rememberMe: boolean;
|
||||
}
|
||||
| { type: "error"; message: string; status: number }
|
||||
> {
|
||||
const tokenHash = hashToken(refreshTokenRaw);
|
||||
|
||||
@@ -179,13 +228,17 @@ export async function refreshAccessToken(
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
|
||||
if (!storedToken || storedToken.replaced_at || new Date(storedToken.expires_at) < new Date()) {
|
||||
return { type: 'error', message: 'Neplatný refresh token', status: 401 };
|
||||
if (
|
||||
!storedToken ||
|
||||
storedToken.replaced_at ||
|
||||
new Date(storedToken.expires_at) < new Date()
|
||||
) {
|
||||
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
||||
}
|
||||
|
||||
const authData = await loadAuthData(storedToken.user_id);
|
||||
if (!authData) {
|
||||
return { type: 'error', message: 'Uživatel nenalezen', status: 401 };
|
||||
return { type: "error", message: "Uživatel nenalezen", status: 401 };
|
||||
}
|
||||
|
||||
// Rotate refresh token
|
||||
@@ -208,7 +261,7 @@ export async function refreshAccessToken(
|
||||
expires_at: new Date(Date.now() + expiresIn * 1000),
|
||||
remember_me: storedToken.remember_me,
|
||||
ip_address: request.ip,
|
||||
user_agent: request.headers['user-agent'] ?? null,
|
||||
user_agent: request.headers["user-agent"] ?? null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -219,12 +272,20 @@ export async function refreshAccessToken(
|
||||
roleName: authData.roleName,
|
||||
});
|
||||
|
||||
return { type: 'success', accessToken, refreshToken: newRefreshTokenRaw, user: authData, rememberMe: storedToken.remember_me ?? false };
|
||||
return {
|
||||
type: "success",
|
||||
accessToken,
|
||||
refreshToken: newRefreshTokenRaw,
|
||||
user: authData,
|
||||
rememberMe: storedToken.remember_me ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function logout(refreshTokenRaw: string): Promise<void> {
|
||||
const tokenHash = hashToken(refreshTokenRaw);
|
||||
const token = await prisma.refresh_tokens.findFirst({ where: { token_hash: tokenHash } });
|
||||
const token = await prisma.refresh_tokens.findFirst({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
|
||||
if (token) {
|
||||
// Delete all tokens for this user from the same IP + user agent (same browser session)
|
||||
@@ -237,16 +298,25 @@ export async function logout(refreshTokenRaw: string): Promise<void> {
|
||||
});
|
||||
} else {
|
||||
// Fallback: just delete by hash
|
||||
await prisma.refresh_tokens.deleteMany({ where: { token_hash: tokenHash } });
|
||||
await prisma.refresh_tokens.deleteMany({
|
||||
where: { token_hash: tokenHash },
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up expired tokens
|
||||
await prisma.refresh_tokens.deleteMany({ where: { expires_at: { lt: new Date() } } });
|
||||
await prisma.refresh_tokens.deleteMany({
|
||||
where: { expires_at: { lt: new Date() } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyAccessToken(token: string): Promise<AuthData | null> {
|
||||
export async function verifyAccessToken(
|
||||
token: string,
|
||||
): Promise<AuthData | null> {
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwt.secret) as unknown as JwtPayload;
|
||||
const payload = jwt.verify(
|
||||
token,
|
||||
config.jwt.secret,
|
||||
) as unknown as JwtPayload;
|
||||
return loadAuthData(payload.sub);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,35 +1,59 @@
|
||||
import prisma from '../config/database';
|
||||
import prisma from "../config/database";
|
||||
|
||||
// Re-export for convenience
|
||||
|
||||
// Status transition rules matching PHP
|
||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
issued: ['paid'],
|
||||
overdue: ['paid'],
|
||||
issued: ["paid"],
|
||||
overdue: ["paid"],
|
||||
paid: [],
|
||||
};
|
||||
|
||||
const ALLOWED_SORT_FIELDS = ['id', 'invoice_number', 'status', 'issue_date', 'due_date', 'currency'];
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
"invoice_number",
|
||||
"status",
|
||||
"issue_date",
|
||||
"due_date",
|
||||
"currency",
|
||||
];
|
||||
|
||||
interface InvoiceItemInput { description?: string; quantity?: number; unit?: string; unit_price?: number; vat_rate?: number; position?: number }
|
||||
interface InvoiceItemInput {
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
unit_price?: number;
|
||||
vat_rate?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface ListInvoicesParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: 'asc' | 'desc';
|
||||
order: "asc" | "desc";
|
||||
search: string;
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
}
|
||||
|
||||
function computeInvoiceTotals(items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>, applyVat: boolean | null, defaultVatRate: unknown) {
|
||||
const subtotal = items.reduce((s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
|
||||
function computeInvoiceTotals(
|
||||
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
||||
applyVat: boolean | null,
|
||||
defaultVatRate: unknown,
|
||||
) {
|
||||
const subtotal = items.reduce(
|
||||
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vatAmount = applyVat
|
||||
? items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
return s + base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
|
||||
return (
|
||||
s +
|
||||
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100)
|
||||
);
|
||||
}, 0)
|
||||
: 0;
|
||||
return {
|
||||
@@ -42,15 +66,18 @@ function computeInvoiceTotals(items: Array<{ quantity: unknown; unit_price: unkn
|
||||
export async function markOverdueInvoices() {
|
||||
try {
|
||||
await prisma.invoices.updateMany({
|
||||
where: { status: 'issued', due_date: { lt: new Date() } },
|
||||
data: { status: 'overdue' },
|
||||
where: { status: "issued", due_date: { lt: new Date() } },
|
||||
data: { status: "overdue" },
|
||||
});
|
||||
} catch { /* silent */ }
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInvoices(params: ListInvoicesParams) {
|
||||
const { page, limit, skip, sort, order, search, status, customer_id } = params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
|
||||
const { page, limit, skip, sort, order, search, status, customer_id } =
|
||||
params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status) where.status = status;
|
||||
@@ -80,8 +107,12 @@ export async function listInvoices(params: ListInvoicesParams) {
|
||||
prisma.invoices.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = invoices.map(inv => {
|
||||
const totals = computeInvoiceTotals(inv.invoice_items, inv.apply_vat, inv.vat_rate);
|
||||
const enriched = invoices.map((inv) => {
|
||||
const totals = computeInvoiceTotals(
|
||||
inv.invoice_items,
|
||||
inv.apply_vat,
|
||||
inv.vat_rate,
|
||||
);
|
||||
const { invoice_items, ...rest } = inv;
|
||||
return {
|
||||
...rest,
|
||||
@@ -96,8 +127,10 @@ export async function listInvoices(params: ListInvoicesParams) {
|
||||
}
|
||||
|
||||
export async function getNextInvoiceNumberFormatted() {
|
||||
const settings = await prisma.company_settings.findFirst({ select: { invoice_type_code: true } });
|
||||
const typeCode = settings?.invoice_type_code || '81';
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { invoice_type_code: true },
|
||||
});
|
||||
const typeCode = settings?.invoice_type_code || "81";
|
||||
const yy = String(new Date().getFullYear()).slice(-2);
|
||||
const prefix = `${yy}${typeCode}`;
|
||||
const prefixLen = prefix.length;
|
||||
@@ -110,14 +143,14 @@ export async function getNextInvoiceNumberFormatted() {
|
||||
WHERE invoice_number LIKE ${likePattern}
|
||||
`;
|
||||
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
|
||||
const number = `${prefix}${String(nextNum).padStart(4, '0')}`;
|
||||
const number = `${prefix}${String(nextNum).padStart(4, "0")}`;
|
||||
return { number, next_number: number };
|
||||
}
|
||||
|
||||
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
const now = new Date();
|
||||
const year = queryYear || now.getFullYear();
|
||||
const month = queryMonth || (now.getMonth() + 1);
|
||||
const month = queryMonth || now.getMonth() + 1;
|
||||
|
||||
const monthStart = new Date(year, month - 1, 1);
|
||||
const monthEnd = new Date(year, month, 0, 23, 59, 59);
|
||||
@@ -127,12 +160,18 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
});
|
||||
|
||||
// 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), 0);
|
||||
const invoiceTotalWithVat = (inv: (typeof allInvoices)[0]) => {
|
||||
const sub = inv.invoice_items.reduce(
|
||||
(s, i) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vat = inv.apply_vat
|
||||
? inv.invoice_items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
return s + base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100);
|
||||
return (
|
||||
s +
|
||||
base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100)
|
||||
);
|
||||
}, 0)
|
||||
: 0;
|
||||
return sub + vat;
|
||||
@@ -142,10 +181,15 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
const aggregateByCurrency = (invoices: typeof allInvoices) => {
|
||||
const map: Record<string, number> = {};
|
||||
for (const inv of invoices) {
|
||||
const cur = inv.currency || 'CZK';
|
||||
const cur = inv.currency || "CZK";
|
||||
map[cur] = (map[cur] || 0) + invoiceTotalWithVat(inv);
|
||||
}
|
||||
return Object.entries(map).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
|
||||
return Object.entries(map)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([currency, amount]) => ({
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
}));
|
||||
};
|
||||
|
||||
const sumCzk = (invoices: typeof allInvoices) => {
|
||||
@@ -156,27 +200,34 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
return Math.round(total * 100) / 100;
|
||||
};
|
||||
|
||||
const monthInvoices = allInvoices.filter(inv => {
|
||||
const monthInvoices = allInvoices.filter((inv) => {
|
||||
const issueDate = inv.issue_date ? new Date(inv.issue_date) : null;
|
||||
return issueDate && issueDate >= monthStart && issueDate <= monthEnd;
|
||||
});
|
||||
|
||||
const paidInvoices = monthInvoices.filter(i => i.status === 'paid');
|
||||
const awaitingInvoices = allInvoices.filter(i => i.status === 'issued');
|
||||
const overdueInvoices = allInvoices.filter(i => i.status === 'overdue');
|
||||
const paidInvoices = monthInvoices.filter((i) => i.status === "paid");
|
||||
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;
|
||||
const cur = inv.currency || 'CZK';
|
||||
const cur = inv.currency || "CZK";
|
||||
for (const item of inv.invoice_items) {
|
||||
const base = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const vat = base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100);
|
||||
const base =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
const vat =
|
||||
base * ((Number(item.vat_rate) || Number(inv.vat_rate) || 21) / 100);
|
||||
vatMap[cur] = (vatMap[cur] || 0) + vat;
|
||||
}
|
||||
}
|
||||
const vatAmounts = Object.entries(vatMap).filter(([, v]) => v > 0).map(([currency, amount]) => ({ amount: Math.round(amount * 100) / 100, currency }));
|
||||
const vatAmounts = Object.entries(vatMap)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([currency, amount]) => ({
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
}));
|
||||
let vatCzk = 0;
|
||||
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
|
||||
|
||||
@@ -202,12 +253,16 @@ export async function getOrderDataForInvoice(orderId: number) {
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: 'asc' } },
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!order) return null;
|
||||
const { order_items, customers, ...rest } = order;
|
||||
return { ...rest, items: order_items, customer_name: customers?.name || null };
|
||||
return {
|
||||
...rest,
|
||||
items: order_items,
|
||||
customer_name: customers?.name || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInvoice(id: number) {
|
||||
@@ -215,7 +270,7 @@ export async function getInvoice(id: number) {
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
invoice_items: { orderBy: { position: 'asc' } },
|
||||
invoice_items: { orderBy: { position: "asc" } },
|
||||
orders: { select: { id: true, order_number: true } },
|
||||
},
|
||||
});
|
||||
@@ -237,12 +292,14 @@ export async function createInvoice(body: Record<string, any>) {
|
||||
invoice_number: body.invoice_number ? String(body.invoice_number) : null,
|
||||
order_id: body.order_id ? Number(body.order_id) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
status: body.status ? String(body.status) : 'issued',
|
||||
currency: body.currency ? String(body.currency) : 'CZK',
|
||||
status: body.status ? String(body.status) : "issued",
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
payment_method: body.payment_method ? String(body.payment_method) : null,
|
||||
constant_symbol: body.constant_symbol ? String(body.constant_symbol) : null,
|
||||
constant_symbol: body.constant_symbol
|
||||
? String(body.constant_symbol)
|
||||
: null,
|
||||
bank_name: body.bank_name ? String(body.bank_name) : null,
|
||||
bank_swift: body.bank_swift ? String(body.bank_swift) : null,
|
||||
bank_iban: body.bank_iban ? String(body.bank_iban) : null,
|
||||
@@ -276,7 +333,7 @@ export async function createInvoice(body: Record<string, any>) {
|
||||
|
||||
export async function updateInvoice(id: number, body: Record<string, any>) {
|
||||
const existing = await prisma.invoices.findUnique({ where: { id } });
|
||||
if (!existing) return { error: 'not_found' as const };
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
|
||||
const currentStatus = existing.status as string;
|
||||
|
||||
@@ -285,43 +342,68 @@ export async function updateInvoice(id: number, body: Record<string, any>) {
|
||||
const newStatus = String(body.status);
|
||||
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
||||
if (!allowed.includes(newStatus)) {
|
||||
return { error: 'invalid_transition' as const, currentStatus, newStatus };
|
||||
return { error: "invalid_transition" as const, currentStatus, newStatus };
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
|
||||
// Only allow full editing in 'issued' state
|
||||
const isDraft = currentStatus === 'issued';
|
||||
const isDraft = currentStatus === "issued";
|
||||
if (isDraft) {
|
||||
const strFields = ['currency', 'payment_method', 'constant_symbol', 'bank_name', 'bank_swift', 'bank_iban', 'bank_account', 'issued_by', 'billing_text'];
|
||||
const strFields = [
|
||||
"currency",
|
||||
"payment_method",
|
||||
"constant_symbol",
|
||||
"bank_name",
|
||||
"bank_swift",
|
||||
"bank_iban",
|
||||
"bank_account",
|
||||
"issued_by",
|
||||
"billing_text",
|
||||
];
|
||||
for (const f of strFields) {
|
||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
}
|
||||
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (body.customer_id !== undefined)
|
||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
||||
if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1';
|
||||
if (body.issue_date !== undefined) data.issue_date = body.issue_date ? new Date(String(body.issue_date)) : null;
|
||||
if (body.due_date !== undefined) data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
|
||||
if (body.tax_date !== undefined) data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
|
||||
if (body.apply_vat !== undefined)
|
||||
data.apply_vat =
|
||||
body.apply_vat === true ||
|
||||
body.apply_vat === 1 ||
|
||||
body.apply_vat === "1";
|
||||
if (body.issue_date !== undefined)
|
||||
data.issue_date = body.issue_date
|
||||
? new Date(String(body.issue_date))
|
||||
: null;
|
||||
if (body.due_date !== undefined)
|
||||
data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
|
||||
if (body.tax_date !== undefined)
|
||||
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
|
||||
}
|
||||
|
||||
// Notes editable in issued/overdue
|
||||
if (currentStatus === 'issued' || currentStatus === 'overdue') {
|
||||
if (body.notes !== undefined) data.notes = body.notes ? String(body.notes) : null;
|
||||
if (body.internal_notes !== undefined) data.internal_notes = body.internal_notes ? String(body.internal_notes) : null;
|
||||
if (currentStatus === "issued" || currentStatus === "overdue") {
|
||||
if (body.notes !== undefined)
|
||||
data.notes = body.notes ? String(body.notes) : null;
|
||||
if (body.internal_notes !== undefined)
|
||||
data.internal_notes = body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Status change
|
||||
if (body.status !== undefined) {
|
||||
data.status = String(body.status);
|
||||
// Auto-set paid_date when transitioning to paid
|
||||
if (String(body.status) === 'paid' && !existing.paid_date) {
|
||||
if (String(body.status) === "paid" && !existing.paid_date) {
|
||||
data.paid_date = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
if (body.paid_date !== undefined) data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
|
||||
if (body.paid_date !== undefined)
|
||||
data.paid_date = body.paid_date ? new Date(String(body.paid_date)) : null;
|
||||
|
||||
await prisma.invoices.update({ where: { id }, data });
|
||||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { sendMail } from './mailer';
|
||||
import { config } from '../config/env';
|
||||
import { sendMail } from "./mailer";
|
||||
import { config } from "../config/env";
|
||||
|
||||
const LEAVE_TYPE_LABELS: Record<string, string> = {
|
||||
vacation: 'Dovolená',
|
||||
sick: 'Nemocenská',
|
||||
unpaid: 'Neplacené volno',
|
||||
vacation: "Dovolená",
|
||||
sick: "Nemocenská",
|
||||
unpaid: "Neplacené volno",
|
||||
};
|
||||
|
||||
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 `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}.${d.getFullYear()}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
interface LeaveRequestData {
|
||||
@@ -29,18 +33,21 @@ interface LeaveRequestData {
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export async function notifyNewLeaveRequest(request: LeaveRequestData, employeeName: string): Promise<void> {
|
||||
export async function notifyNewLeaveRequest(
|
||||
request: LeaveRequestData,
|
||||
employeeName: string,
|
||||
): Promise<void> {
|
||||
const notifyEmail = config.email.leaveNotify;
|
||||
if (!notifyEmail) return;
|
||||
|
||||
const leaveType = LEAVE_TYPE_LABELS[request.leave_type] || request.leave_type;
|
||||
const dateFrom = formatDate(request.date_from);
|
||||
const dateTo = formatDate(request.date_to);
|
||||
const notes = request.notes || '';
|
||||
const notes = request.notes || "";
|
||||
|
||||
const subject = `Nová žádost o nepřítomnost - ${employeeName} (${leaveType})`;
|
||||
|
||||
const appUrl = config.appUrl || '';
|
||||
const appUrl = config.appUrl || "";
|
||||
const approvalLink = appUrl
|
||||
? `<p style="margin-top: 20px;">
|
||||
<a href="${escapeHtml(appUrl)}/leave-approval"
|
||||
@@ -49,10 +56,10 @@ export async function notifyNewLeaveRequest(request: LeaveRequestData, employeeN
|
||||
Přejít ke schvalování
|
||||
</a>
|
||||
</p>`
|
||||
: '';
|
||||
: "";
|
||||
|
||||
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 = `${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 html = `
|
||||
<html>
|
||||
@@ -75,10 +82,14 @@ export async function notifyNewLeaveRequest(request: LeaveRequestData, employeeN
|
||||
<td style="padding: 10px; background: #f5f5f5; font-weight: bold;">Pracovní dny:</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #ddd;">${request.total_days} dní (${request.total_hours} hodin)</td>
|
||||
</tr>
|
||||
${notes ? `<tr>
|
||||
${
|
||||
notes
|
||||
? `<tr>
|
||||
<td style="padding: 10px; background: #f5f5f5; font-weight: bold;">Poznámka:</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #ddd;">${escapeHtml(notes)}</td>
|
||||
</tr>` : ''}
|
||||
</tr>`
|
||||
: ""
|
||||
}
|
||||
</table>
|
||||
${approvalLink}
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||||
@@ -91,6 +102,8 @@ export async function notifyNewLeaveRequest(request: LeaveRequestData, employeeN
|
||||
|
||||
const sent = await sendMail(notifyEmail, subject, html);
|
||||
if (!sent) {
|
||||
console.error(`LeaveNotification: Failed to send notification to ${notifyEmail}`);
|
||||
console.error(
|
||||
`LeaveNotification: Failed to send notification to ${notifyEmail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config/env';
|
||||
import nodemailer from "nodemailer";
|
||||
import { config } from "../config/env";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
sendmail: true,
|
||||
newline: 'unix',
|
||||
path: '/usr/sbin/sendmail',
|
||||
newline: "unix",
|
||||
path: "/usr/sbin/sendmail",
|
||||
});
|
||||
|
||||
export async function sendMail(to: string, subject: string, html: string): Promise<boolean> {
|
||||
const from = config.email.smtpFrom || config.email.contactFrom || 'web@boha-automation.cz';
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
): Promise<boolean> {
|
||||
const from =
|
||||
config.email.smtpFrom ||
|
||||
config.email.contactFrom ||
|
||||
"web@boha-automation.cz";
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
|
||||
@@ -1,67 +1,84 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { config } from '../config/env';
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { config } from "../config/env";
|
||||
|
||||
const FileType = require('file-type') as typeof import('file-type');
|
||||
const FileType = require("file-type") as typeof import("file-type");
|
||||
|
||||
const BLOCKED_EXTENSIONS = new Set([
|
||||
'exe', 'bat', 'sh', 'php', 'htaccess', 'env', 'cmd', 'com', 'msi', 'ps1',
|
||||
'vbs', 'vbe', 'js', 'ws', 'wsf', 'scr', 'pif', 'jar', 'reg',
|
||||
"exe",
|
||||
"bat",
|
||||
"sh",
|
||||
"php",
|
||||
"htaccess",
|
||||
"env",
|
||||
"cmd",
|
||||
"com",
|
||||
"msi",
|
||||
"ps1",
|
||||
"vbs",
|
||||
"vbe",
|
||||
"js",
|
||||
"ws",
|
||||
"wsf",
|
||||
"scr",
|
||||
"pif",
|
||||
"jar",
|
||||
"reg",
|
||||
]);
|
||||
|
||||
const SUSPICIOUS_MIMES = [
|
||||
'application/x-executable',
|
||||
'application/x-msdos-program',
|
||||
'application/x-dosexec',
|
||||
'application/x-msdownload',
|
||||
"application/x-executable",
|
||||
"application/x-msdos-program",
|
||||
"application/x-dosexec",
|
||||
"application/x-msdownload",
|
||||
];
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip',
|
||||
rar: 'application/x-rar-compressed',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
tar: 'application/x-tar',
|
||||
gz: 'application/gzip',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
tif: 'image/tiff',
|
||||
tiff: 'image/tiff',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
mp4: 'video/mp4',
|
||||
avi: 'video/x-msvideo',
|
||||
mkv: 'video/x-matroska',
|
||||
mov: 'video/quicktime',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
xml: 'application/xml',
|
||||
json: 'application/json',
|
||||
dwg: 'application/acad',
|
||||
dxf: 'application/dxf',
|
||||
step: 'application/step',
|
||||
stp: 'application/step',
|
||||
iges: 'application/iges',
|
||||
igs: 'application/iges',
|
||||
pdf: "application/pdf",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
zip: "application/zip",
|
||||
rar: "application/x-rar-compressed",
|
||||
"7z": "application/x-7z-compressed",
|
||||
tar: "application/x-tar",
|
||||
gz: "application/gzip",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
ico: "image/x-icon",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
mp3: "audio/mpeg",
|
||||
wav: "audio/wav",
|
||||
mp4: "video/mp4",
|
||||
avi: "video/x-msvideo",
|
||||
mkv: "video/x-matroska",
|
||||
mov: "video/quicktime",
|
||||
txt: "text/plain",
|
||||
csv: "text/csv",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
xml: "application/xml",
|
||||
json: "application/json",
|
||||
dwg: "application/acad",
|
||||
dxf: "application/dxf",
|
||||
step: "application/step",
|
||||
stp: "application/step",
|
||||
iges: "application/iges",
|
||||
igs: "application/iges",
|
||||
};
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: 'file' | 'folder';
|
||||
type: "file" | "folder";
|
||||
modified: string;
|
||||
is_symlink: boolean;
|
||||
link_target?: string;
|
||||
@@ -88,23 +105,28 @@ export class NasFileManager {
|
||||
private readonly basePath: string;
|
||||
|
||||
constructor() {
|
||||
this.basePath = path.resolve(config.nas.path).replace(/\\/g, '/');
|
||||
this.basePath = path.resolve(config.nas.path).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
public isConfigured(): boolean {
|
||||
if (!this.basePath) return false;
|
||||
try {
|
||||
return fs.existsSync(this.basePath) && fs.statSync(this.basePath).isDirectory();
|
||||
return (
|
||||
fs.existsSync(this.basePath) && fs.statSync(this.basePath).isDirectory()
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public createProjectFolder(projectNumber: string, projectName: string): boolean {
|
||||
public createProjectFolder(
|
||||
projectNumber: string,
|
||||
projectName: string,
|
||||
): boolean {
|
||||
if (!this.isConfigured()) return false;
|
||||
|
||||
const folderName = this.buildFolderName(projectNumber, projectName);
|
||||
const fullPath = this.basePath + '/' + folderName;
|
||||
const fullPath = this.basePath + "/" + folderName;
|
||||
|
||||
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
||||
return true;
|
||||
@@ -143,7 +165,7 @@ export class NasFileManager {
|
||||
if (currentPath === null) return false;
|
||||
|
||||
const newFolderName = this.buildFolderName(projectNumber, newName);
|
||||
const newPath = this.basePath + '/' + newFolderName;
|
||||
const newPath = this.basePath + "/" + newFolderName;
|
||||
|
||||
if (currentPath === newPath) return true;
|
||||
|
||||
@@ -155,7 +177,10 @@ export class NasFileManager {
|
||||
}
|
||||
}
|
||||
|
||||
public listFiles(projectNumber: string, subPath: string = ''): ListFilesResult | null {
|
||||
public listFiles(
|
||||
projectNumber: string,
|
||||
subPath: string = "",
|
||||
): ListFilesResult | null {
|
||||
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||
if (dirPath === null) return null;
|
||||
|
||||
@@ -175,7 +200,7 @@ export class NasFileManager {
|
||||
|
||||
const items: FileItem[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = dirPath + '/' + entry;
|
||||
const fullPath = dirPath + "/" + entry;
|
||||
|
||||
let lstat: fs.Stats;
|
||||
try {
|
||||
@@ -200,14 +225,18 @@ 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');
|
||||
"-" +
|
||||
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 item: FileItem = {
|
||||
name: entry,
|
||||
type: isDir ? 'folder' : 'file',
|
||||
type: isDir ? "folder" : "file",
|
||||
modified: modifiedStr,
|
||||
is_symlink: isLink,
|
||||
};
|
||||
@@ -215,7 +244,7 @@ export class NasFileManager {
|
||||
if (isLink) {
|
||||
try {
|
||||
const target = fs.readlinkSync(fullPath);
|
||||
item.link_target = target.replace(/\//g, '\\');
|
||||
item.link_target = target.replace(/\//g, "\\");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -236,14 +265,20 @@ export class NasFileManager {
|
||||
// Sort: folders first, then files, both alphabetically (natural sort)
|
||||
items.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
return a.type === "folder" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return a.name.localeCompare(b.name, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
|
||||
const breadcrumb: string[] = [''];
|
||||
if (subPath !== '') {
|
||||
const parts = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').split('/');
|
||||
const breadcrumb: string[] = [""];
|
||||
if (subPath !== "") {
|
||||
const parts = subPath
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.split("/");
|
||||
for (const part of parts) {
|
||||
breadcrumb.push(part);
|
||||
}
|
||||
@@ -261,7 +296,7 @@ export class NasFileManager {
|
||||
path: subPath,
|
||||
items,
|
||||
breadcrumb,
|
||||
full_path: realDirPath.replace(/\//g, '\\'),
|
||||
full_path: realDirPath.replace(/\//g, "\\"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -272,8 +307,12 @@ export class NasFileManager {
|
||||
fileName: string,
|
||||
): Promise<string | null> {
|
||||
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||
if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return 'Cílová složka neexistuje';
|
||||
if (
|
||||
dirPath === null ||
|
||||
!fs.existsSync(dirPath) ||
|
||||
!fs.statSync(dirPath).isDirectory()
|
||||
) {
|
||||
return "Cílová složka neexistuje";
|
||||
}
|
||||
|
||||
if (fileBuffer.length > config.nas.maxUploadSize) {
|
||||
@@ -283,34 +322,34 @@ export class NasFileManager {
|
||||
|
||||
const originalName = path.basename(fileName);
|
||||
let safeName = this.sanitizeFilename(originalName);
|
||||
if (safeName === '') {
|
||||
return 'Neplatný název souboru';
|
||||
if (safeName === "") {
|
||||
return "Neplatný název souboru";
|
||||
}
|
||||
|
||||
const ext = path.extname(safeName).slice(1).toLowerCase();
|
||||
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||
return 'Tento typ souboru není povolen';
|
||||
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)) {
|
||||
return 'Obsah souboru neodpovídá jeho příponě';
|
||||
return "Obsah souboru neodpovídá jeho příponě";
|
||||
}
|
||||
} catch {
|
||||
// If file-type fails, continue without MIME check
|
||||
}
|
||||
|
||||
let destPath = dirPath + '/' + safeName;
|
||||
let destPath = dirPath + "/" + safeName;
|
||||
|
||||
// If file exists, append counter
|
||||
if (fs.existsSync(destPath)) {
|
||||
const base = path.basename(safeName, ext ? '.' + ext : '');
|
||||
const base = path.basename(safeName, ext ? "." + ext : "");
|
||||
let counter = 1;
|
||||
do {
|
||||
safeName = base + '_' + counter + (ext ? '.' + ext : '');
|
||||
destPath = dirPath + '/' + safeName;
|
||||
safeName = base + "_" + counter + (ext ? "." + ext : "");
|
||||
destPath = dirPath + "/" + safeName;
|
||||
counter++;
|
||||
} while (fs.existsSync(destPath));
|
||||
}
|
||||
@@ -318,7 +357,7 @@ export class NasFileManager {
|
||||
try {
|
||||
fs.writeFileSync(destPath, fileBuffer);
|
||||
} catch {
|
||||
return 'Nepodařilo se uložit soubor';
|
||||
return "Nepodařilo se uložit soubor";
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -340,7 +379,7 @@ export class NasFileManager {
|
||||
|
||||
const fileName = path.basename(fullPath);
|
||||
const ext = path.extname(fileName).slice(1).toLowerCase();
|
||||
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
||||
const mime = MIME_MAP[ext] || "application/octet-stream";
|
||||
|
||||
return {
|
||||
filePath: fullPath,
|
||||
@@ -349,25 +388,28 @@ export class NasFileManager {
|
||||
};
|
||||
}
|
||||
|
||||
public async deleteItem(projectNumber: string, filePath: string): Promise<string | null> {
|
||||
if (filePath === '' || filePath === '/') {
|
||||
return 'Nelze smazat kořenovou složku projektu';
|
||||
public async deleteItem(
|
||||
projectNumber: string,
|
||||
filePath: string,
|
||||
): Promise<string | null> {
|
||||
if (filePath === "" || filePath === "/") {
|
||||
return "Nelze smazat kořenovou složku projektu";
|
||||
}
|
||||
|
||||
const fullPath = this.resolveProjectPath(projectNumber, filePath);
|
||||
if (fullPath === null) {
|
||||
return 'Neplatná cesta';
|
||||
return "Neplatná cesta";
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return 'Soubor nebo složka neexistuje';
|
||||
return "Soubor nebo složka neexistuje";
|
||||
}
|
||||
|
||||
let isDir: boolean;
|
||||
try {
|
||||
isDir = fs.lstatSync(fullPath).isDirectory();
|
||||
} catch {
|
||||
return 'Neplatná cesta';
|
||||
return "Neplatná cesta";
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -378,76 +420,92 @@ export class NasFileManager {
|
||||
}
|
||||
} catch {
|
||||
return isDir
|
||||
? 'Nepodařilo se smazat složku'
|
||||
: 'Nepodařilo se smazat soubor';
|
||||
? "Nepodařilo se smazat složku"
|
||||
: "Nepodařilo se smazat soubor";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public moveItem(projectNumber: string, fromPath: string, toPath: string): string | null {
|
||||
if (fromPath === '' || fromPath === '/') {
|
||||
return 'Nelze přesunout kořenovou složku';
|
||||
public moveItem(
|
||||
projectNumber: string,
|
||||
fromPath: string,
|
||||
toPath: string,
|
||||
): string | null {
|
||||
if (fromPath === "" || fromPath === "/") {
|
||||
return "Nelze přesunout kořenovou složku";
|
||||
}
|
||||
|
||||
const fullFrom = this.resolveProjectPath(projectNumber, fromPath);
|
||||
const fullTo = this.resolveProjectPath(projectNumber, toPath);
|
||||
|
||||
if (fullFrom === null || fullTo === null) {
|
||||
return 'Neplatná cesta';
|
||||
return "Neplatná cesta";
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullFrom)) {
|
||||
return 'Zdrojový soubor neexistuje';
|
||||
return "Zdrojový soubor neexistuje";
|
||||
}
|
||||
|
||||
// Case-insensitive FS (Windows) — allow case-only rename
|
||||
const sameFile =
|
||||
fullFrom.replace(/\\/g, '/').toLowerCase() ===
|
||||
fullTo.replace(/\\/g, '/').toLowerCase();
|
||||
fullFrom.replace(/\\/g, "/").toLowerCase() ===
|
||||
fullTo.replace(/\\/g, "/").toLowerCase();
|
||||
|
||||
if (fs.existsSync(fullTo) && !sameFile) {
|
||||
return 'Cílový soubor již existuje';
|
||||
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';
|
||||
return "Neplatný cílový název";
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(fullFrom, fullTo);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
return 'Přesun mezi různými disky není podporován';
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "EXDEV"
|
||||
) {
|
||||
return "Přesun mezi různými disky není podporován";
|
||||
}
|
||||
return 'Nepodařilo se přesunout soubor';
|
||||
return "Nepodařilo se přesunout soubor";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public createFolder(projectNumber: string, subPath: string, folderName: string): string | null {
|
||||
public createFolder(
|
||||
projectNumber: string,
|
||||
subPath: string,
|
||||
folderName: string,
|
||||
): string | null {
|
||||
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||
if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return 'Nadřazená složka neexistuje';
|
||||
if (
|
||||
dirPath === null ||
|
||||
!fs.existsSync(dirPath) ||
|
||||
!fs.statSync(dirPath).isDirectory()
|
||||
) {
|
||||
return "Nadřazená složka neexistuje";
|
||||
}
|
||||
|
||||
const safeName = this.sanitizeFilename(folderName);
|
||||
if (safeName === '') {
|
||||
return 'Neplatný název složky';
|
||||
if (safeName === "") {
|
||||
return "Neplatný název složky";
|
||||
}
|
||||
|
||||
const newPath = dirPath + '/' + safeName;
|
||||
const newPath = dirPath + "/" + safeName;
|
||||
if (fs.existsSync(newPath)) {
|
||||
return 'Složka s tímto názvem již existuje';
|
||||
return "Složka s tímto názvem již existuje";
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(newPath, { mode: 0o775 });
|
||||
} catch {
|
||||
return 'Nepodařilo se vytvořit složku';
|
||||
return "Nepodařilo se vytvořit složku";
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -456,15 +514,15 @@ 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, '');
|
||||
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 base = path.basename(safe, ext ? "." + ext : "");
|
||||
const maxBase = 250 - [...ext].length;
|
||||
const trimmedBase = [...base].slice(0, maxBase).join('');
|
||||
safe = ext ? trimmedBase + '.' + ext : trimmedBase;
|
||||
const trimmedBase = [...base].slice(0, maxBase).join("");
|
||||
safe = ext ? trimmedBase + "." + ext : trimmedBase;
|
||||
}
|
||||
|
||||
return safe;
|
||||
@@ -482,10 +540,10 @@ export class NasFileManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = projectNumber + '_';
|
||||
const prefix = projectNumber + "_";
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith(prefix)) {
|
||||
const fullPath = this.basePath + '/' + entry;
|
||||
const fullPath = this.basePath + "/" + entry;
|
||||
try {
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
return fullPath;
|
||||
@@ -500,35 +558,41 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
private buildFolderName(projectNumber: string, projectName: string): string {
|
||||
let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, '');
|
||||
safe = safe.trim().replace(/ /g, '_');
|
||||
safe = safe.replace(/_+/g, '_');
|
||||
let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, "");
|
||||
safe = safe.trim().replace(/ /g, "_");
|
||||
safe = safe.replace(/_+/g, "_");
|
||||
if ([...safe].length > 200) {
|
||||
safe = [...safe].slice(0, 200).join('');
|
||||
safe = [...safe].slice(0, 200).join("");
|
||||
}
|
||||
return projectNumber + '_' + safe;
|
||||
return projectNumber + "_" + safe;
|
||||
}
|
||||
|
||||
private resolveProjectPath(projectNumber: string, subPath: string): string | null {
|
||||
private resolveProjectPath(
|
||||
projectNumber: string,
|
||||
subPath: string,
|
||||
): string | null {
|
||||
const folderPath = this.findProjectFolder(projectNumber);
|
||||
if (folderPath === null) return null;
|
||||
|
||||
if (subPath === '' || subPath === '/') {
|
||||
if (subPath === "" || subPath === "/") {
|
||||
return folderPath;
|
||||
}
|
||||
|
||||
// Basic path traversal protection
|
||||
if (subPath.includes('\0') || subPath.includes('..')) {
|
||||
if (subPath.includes("\0") || subPath.includes("..")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize separators and trim
|
||||
const normalized = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, '/');
|
||||
const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/");
|
||||
|
||||
// Verify candidate is within project folder
|
||||
const normalFolder = folderPath.replace(/\\/g, '/');
|
||||
if (!candidate.startsWith(normalFolder + '/') && candidate !== normalFolder) {
|
||||
const normalFolder = folderPath.replace(/\\/g, "/");
|
||||
if (
|
||||
!candidate.startsWith(normalFolder + "/") &&
|
||||
candidate !== normalFolder
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -551,8 +615,8 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
private walkAndRejectSymlinks(fullPath: string, basePath: string): boolean {
|
||||
const normalFull = fullPath.replace(/\\/g, '/');
|
||||
const normalBase = basePath.replace(/\\/g, '/');
|
||||
const normalFull = fullPath.replace(/\\/g, "/");
|
||||
const normalBase = basePath.replace(/\\/g, "/");
|
||||
|
||||
// Get the relative portion after basePath
|
||||
if (!normalFull.startsWith(normalBase)) {
|
||||
@@ -562,11 +626,11 @@ export class NasFileManager {
|
||||
const relative = normalFull.slice(normalBase.length);
|
||||
if (!relative) return true; // same as base
|
||||
|
||||
const parts = relative.split('/').filter(Boolean);
|
||||
const parts = relative.split("/").filter(Boolean);
|
||||
let current = normalBase;
|
||||
|
||||
for (const part of parts) {
|
||||
current = current + '/' + part;
|
||||
current = current + "/" + part;
|
||||
try {
|
||||
const lstat = fs.lstatSync(current);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
@@ -592,15 +656,15 @@ export class NasFileManager {
|
||||
|
||||
private formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return bytes + ' B';
|
||||
return bytes + " B";
|
||||
}
|
||||
if (bytes < 1048576) {
|
||||
return (Math.round((bytes / 1024) * 10) / 10) + ' KB';
|
||||
return Math.round((bytes / 1024) * 10) / 10 + " KB";
|
||||
}
|
||||
if (bytes < 1073741824) {
|
||||
return (Math.round((bytes / 1048576) * 10) / 10) + ' MB';
|
||||
return Math.round((bytes / 1048576) * 10) / 10 + " MB";
|
||||
}
|
||||
return (Math.round((bytes / 1073741824) * 10) / 10) + ' GB';
|
||||
return Math.round((bytes / 1073741824) * 10) / 10 + " GB";
|
||||
}
|
||||
|
||||
private isSuspiciousMime(mime: string, ext: string): boolean {
|
||||
@@ -609,7 +673,7 @@ export class NasFileManager {
|
||||
}
|
||||
|
||||
// PHP files
|
||||
if (mime.includes('php') || mime.includes('x-httpd')) {
|
||||
if (mime.includes("php") || mime.includes("x-httpd")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import prisma from '../config/database';
|
||||
import prisma from "../config/database";
|
||||
|
||||
/**
|
||||
* Shared number generator for orders and projects.
|
||||
@@ -6,8 +6,10 @@ import prisma from '../config/database';
|
||||
* Queries MAX from both orders and projects tables.
|
||||
*/
|
||||
export async function generateSharedNumber(): Promise<string> {
|
||||
const settings = await prisma.company_settings.findFirst({ select: { order_type_code: true } });
|
||||
const typeCode = settings?.order_type_code || '71';
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { order_type_code: true },
|
||||
});
|
||||
const typeCode = settings?.order_type_code || "71";
|
||||
const yy = String(new Date().getFullYear()).slice(-2);
|
||||
const prefix = `${yy}${typeCode}`;
|
||||
const prefixLen = prefix.length;
|
||||
@@ -23,7 +25,7 @@ export async function generateSharedNumber(): Promise<string> {
|
||||
) combined
|
||||
`;
|
||||
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
||||
return `${prefix}${String(nextNum).padStart(4, '0')}`;
|
||||
return `${prefix}${String(nextNum).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,8 +33,10 @@ export async function generateSharedNumber(): Promise<string> {
|
||||
* Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008)
|
||||
*/
|
||||
export async function generateOfferNumber(): Promise<string> {
|
||||
const settings = await prisma.company_settings.findFirst({ select: { quotation_prefix: true } });
|
||||
const prefix = settings?.quotation_prefix || 'NA';
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { quotation_prefix: true },
|
||||
});
|
||||
const prefix = settings?.quotation_prefix || "NA";
|
||||
const year = new Date().getFullYear();
|
||||
const likePattern = `${year}/${prefix}/%`;
|
||||
|
||||
@@ -42,7 +46,7 @@ export async function generateOfferNumber(): Promise<string> {
|
||||
WHERE quotation_number LIKE ${likePattern}
|
||||
`;
|
||||
const nextNum = Number(result[0]?.max_num ?? 0) + 1;
|
||||
return `${year}/${prefix}/${String(nextNum).padStart(3, '0')}`;
|
||||
return `${year}/${prefix}/${String(nextNum).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +55,7 @@ export async function generateOfferNumber(): Promise<string> {
|
||||
export async function generateInvoiceNumber(year: number): Promise<number> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.number_sequences.findFirst({
|
||||
where: { type: 'invoice', year },
|
||||
where: { type: "invoice", year },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
@@ -64,7 +68,7 @@ export async function generateInvoiceNumber(year: number): Promise<number> {
|
||||
}
|
||||
|
||||
await tx.number_sequences.create({
|
||||
data: { type: 'invoice', year, last_number: 1 },
|
||||
data: { type: "invoice", year, last_number: 1 },
|
||||
});
|
||||
return 1;
|
||||
});
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import prisma from '../config/database';
|
||||
import { generateOfferNumber } from './numbering.service';
|
||||
import prisma from "../config/database";
|
||||
import { generateOfferNumber } from "./numbering.service";
|
||||
|
||||
interface QuotationItemInput { description?: string; item_description?: string; quantity?: number; unit?: string; unit_price?: number; is_included_in_total?: boolean; position?: number }
|
||||
interface ScopeSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
|
||||
interface QuotationItemInput {
|
||||
description?: string;
|
||||
item_description?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
unit_price?: number;
|
||||
is_included_in_total?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
interface ScopeSectionInput {
|
||||
title?: string;
|
||||
title_cz?: string;
|
||||
content?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { generateOfferNumber as getNextOfferNumber } from './numbering.service';
|
||||
export { generateOfferNumber as getNextOfferNumber } from "./numbering.service";
|
||||
|
||||
const ALLOWED_SORT_FIELDS = ['id', 'quotation_number', 'project_code', 'created_at', 'valid_until', 'currency', 'status'];
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
"quotation_number",
|
||||
"project_code",
|
||||
"created_at",
|
||||
"valid_until",
|
||||
"currency",
|
||||
"status",
|
||||
];
|
||||
|
||||
interface ListOffersParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: 'asc' | 'desc';
|
||||
order: "asc" | "desc";
|
||||
search: string;
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
@@ -23,8 +44,14 @@ interface ListOffersParams {
|
||||
function enrichQuotation(q: any) {
|
||||
const subtotal = q.quotation_items
|
||||
.filter((i: any) => i.is_included_in_total !== false)
|
||||
.reduce((s: number, i: any) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
|
||||
const vatAmount = q.apply_vat ? subtotal * ((Number(q.vat_rate) || 21) / 100) : 0;
|
||||
.reduce(
|
||||
(s: number, i: any) =>
|
||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vatAmount = q.apply_vat
|
||||
? subtotal * ((Number(q.vat_rate) || 21) / 100)
|
||||
: 0;
|
||||
const { quotation_items, scope_sections, ...rest } = q;
|
||||
return {
|
||||
...rest,
|
||||
@@ -38,8 +65,9 @@ function enrichQuotation(q: any) {
|
||||
}
|
||||
|
||||
export async function listOffers(params: ListOffersParams) {
|
||||
const { page, limit, skip, sort, order, search, status, customer_id } = params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
|
||||
const { page, limit, skip, sort, order, search, status, customer_id } =
|
||||
params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (status) where.status = status;
|
||||
@@ -60,8 +88,8 @@ export async function listOffers(params: ListOffersParams) {
|
||||
orderBy: { [sortField]: order },
|
||||
include: {
|
||||
customers: { select: { id: true, name: true } },
|
||||
quotation_items: { orderBy: { position: 'asc' } },
|
||||
scope_sections: { orderBy: { position: 'asc' } },
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
}),
|
||||
prisma.quotations.count({ where }),
|
||||
@@ -77,8 +105,8 @@ export async function getOffer(id: number) {
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
quotation_items: { orderBy: { position: 'asc' } },
|
||||
scope_sections: { orderBy: { position: 'asc' } },
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!quotation) return null;
|
||||
@@ -107,18 +135,22 @@ export async function getOffer(id: number) {
|
||||
export async function createOffer(body: Record<string, any>) {
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: body.quotation_number ? String(body.quotation_number) : null,
|
||||
quotation_number: body.quotation_number
|
||||
? String(body.quotation_number)
|
||||
: null,
|
||||
project_code: body.project_code ? String(body.project_code) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
valid_until: body.valid_until ? new Date(String(body.valid_until)) : null,
|
||||
currency: body.currency ? String(body.currency) : 'CZK',
|
||||
language: body.language ? String(body.language) : 'cs',
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
exchange_rate: body.exchange_rate ? Number(body.exchange_rate) : 1.0,
|
||||
status: body.status ? String(body.status) : 'active',
|
||||
status: body.status ? String(body.status) : "active",
|
||||
scope_title: body.scope_title ? String(body.scope_title) : null,
|
||||
scope_description: body.scope_description ? String(body.scope_description) : null,
|
||||
scope_description: body.scope_description
|
||||
? String(body.scope_description)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -154,24 +186,57 @@ export async function createOffer(body: Record<string, any>) {
|
||||
|
||||
export async function updateOffer(id: number, body: Record<string, any>) {
|
||||
const existing = await prisma.quotations.findUnique({ where: { id } });
|
||||
if (!existing) return { error: 'not_found' as const };
|
||||
if (existing.status === 'invalidated') return { error: 'invalidated' as const };
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
if (existing.status === "invalidated")
|
||||
return { error: "invalidated" as const };
|
||||
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: {
|
||||
quotation_number: body.quotation_number !== undefined ? String(body.quotation_number) : undefined,
|
||||
customer_id: body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||
valid_until: body.valid_until !== undefined ? (body.valid_until ? new Date(String(body.valid_until)) : null) : undefined,
|
||||
quotation_number:
|
||||
body.quotation_number !== undefined
|
||||
? String(body.quotation_number)
|
||||
: undefined,
|
||||
customer_id:
|
||||
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
|
||||
valid_until:
|
||||
body.valid_until !== undefined
|
||||
? body.valid_until
|
||||
? new Date(String(body.valid_until))
|
||||
: null
|
||||
: undefined,
|
||||
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
||||
language: body.language !== undefined ? String(body.language) : undefined,
|
||||
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
||||
apply_vat: body.apply_vat !== undefined ? (body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1') : undefined,
|
||||
exchange_rate: body.exchange_rate !== undefined ? Number(body.exchange_rate) : undefined,
|
||||
apply_vat:
|
||||
body.apply_vat !== undefined
|
||||
? body.apply_vat === true ||
|
||||
body.apply_vat === 1 ||
|
||||
body.apply_vat === "1"
|
||||
: undefined,
|
||||
exchange_rate:
|
||||
body.exchange_rate !== undefined
|
||||
? Number(body.exchange_rate)
|
||||
: undefined,
|
||||
status: body.status !== undefined ? String(body.status) : undefined,
|
||||
project_code: body.project_code !== undefined ? (body.project_code ? String(body.project_code) : null) : undefined,
|
||||
scope_title: body.scope_title !== undefined ? (body.scope_title ? String(body.scope_title) : null) : undefined,
|
||||
scope_description: body.scope_description !== undefined ? (body.scope_description ? String(body.scope_description) : null) : undefined,
|
||||
project_code:
|
||||
body.project_code !== undefined
|
||||
? body.project_code
|
||||
? String(body.project_code)
|
||||
: null
|
||||
: undefined,
|
||||
scope_title:
|
||||
body.scope_title !== undefined
|
||||
? body.scope_title
|
||||
? String(body.scope_title)
|
||||
: null
|
||||
: undefined,
|
||||
scope_description:
|
||||
body.scope_description !== undefined
|
||||
? body.scope_description
|
||||
? String(body.scope_description)
|
||||
: null
|
||||
: undefined,
|
||||
modified_at: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -222,7 +287,10 @@ export async function deleteOffer(id: number) {
|
||||
export async function duplicateOffer(id: number) {
|
||||
const original = await prisma.quotations.findUnique({
|
||||
where: { id },
|
||||
include: { quotation_items: { orderBy: { position: 'asc' } }, scope_sections: { orderBy: { position: 'asc' } } },
|
||||
include: {
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!original) return null;
|
||||
|
||||
@@ -239,7 +307,7 @@ export async function duplicateOffer(id: number) {
|
||||
vat_rate: original.vat_rate,
|
||||
apply_vat: original.apply_vat,
|
||||
exchange_rate: original.exchange_rate,
|
||||
status: 'active',
|
||||
status: "active",
|
||||
scope_title: original.scope_title,
|
||||
scope_description: original.scope_description,
|
||||
},
|
||||
@@ -279,6 +347,9 @@ export async function invalidateOffer(id: number) {
|
||||
const existing = await prisma.quotations.findUnique({ where: { id } });
|
||||
if (!existing) return null;
|
||||
|
||||
await prisma.quotations.update({ where: { id }, data: { status: 'invalidated', modified_at: new Date() } });
|
||||
await prisma.quotations.update({
|
||||
where: { id },
|
||||
data: { status: "invalidated", modified_at: new Date() },
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
import prisma from '../config/database';
|
||||
import { generateSharedNumber } from './numbering.service';
|
||||
import prisma from "../config/database";
|
||||
import { generateSharedNumber } from "./numbering.service";
|
||||
|
||||
interface OrderItemInput { description?: string | null; item_description?: string | null; quantity?: number; unit?: string | null; unit_price?: number; is_included_in_total?: boolean; position?: number }
|
||||
interface OrderSectionInput { title?: string; title_cz?: string; content?: string; position?: number }
|
||||
interface OrderItemInput {
|
||||
description?: string | null;
|
||||
item_description?: string | null;
|
||||
quantity?: number;
|
||||
unit?: string | null;
|
||||
unit_price?: number;
|
||||
is_included_in_total?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
interface OrderSectionInput {
|
||||
title?: string;
|
||||
title_cz?: string;
|
||||
content?: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
// Status transition rules matching PHP
|
||||
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
prijata: ['v_realizaci', 'stornovana'],
|
||||
v_realizaci: ['dokoncena', 'stornovana'],
|
||||
prijata: ["v_realizaci", "stornovana"],
|
||||
v_realizaci: ["dokoncena", "stornovana"],
|
||||
dokoncena: [],
|
||||
stornovana: [],
|
||||
};
|
||||
|
||||
const ORDER_ALLOWED_SORT_FIELDS = ['id', 'order_number', 'status', 'currency', 'created_at'];
|
||||
const ORDER_ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
"order_number",
|
||||
"status",
|
||||
"currency",
|
||||
"created_at",
|
||||
];
|
||||
|
||||
function enrichOrder(o: any) {
|
||||
const subtotal = o.order_items
|
||||
.filter((i: any) => i.is_included_in_total !== false)
|
||||
.reduce((s: number, i: any) => s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0), 0);
|
||||
const vatAmount = o.apply_vat ? subtotal * ((Number(o.vat_rate) || 21) / 100) : 0;
|
||||
.reduce(
|
||||
(s: number, i: any) =>
|
||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
||||
0,
|
||||
);
|
||||
const vatAmount = o.apply_vat
|
||||
? subtotal * ((Number(o.vat_rate) || 21) / 100)
|
||||
: 0;
|
||||
const { order_items, order_sections, ...rest } = o;
|
||||
const invoice = o.invoices?.[0] || null;
|
||||
return {
|
||||
@@ -41,14 +66,16 @@ interface ListOrdersParams {
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: 'asc' | 'desc';
|
||||
order: "asc" | "desc";
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
}
|
||||
|
||||
export async function listOrders(params: ListOrdersParams) {
|
||||
const { page, limit, skip, order } = params;
|
||||
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort) ? params.sort : 'id';
|
||||
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
|
||||
? params.sort
|
||||
: "id";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (params.status) where.status = params.status;
|
||||
@@ -56,11 +83,14 @@ export async function listOrders(params: ListOrdersParams) {
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.orders.findMany({
|
||||
where, skip, take: limit, orderBy: { [sortField]: order },
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: {
|
||||
customers: { select: { id: true, name: true } },
|
||||
order_items: { orderBy: { position: 'asc' } },
|
||||
order_sections: { orderBy: { position: 'asc' } },
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
order_sections: { orderBy: { position: "asc" } },
|
||||
quotations: { select: { quotation_number: true, project_code: true } },
|
||||
invoices: { select: { id: true, invoice_number: true }, take: 1 },
|
||||
},
|
||||
@@ -77,11 +107,18 @@ export async function getOrder(id: number) {
|
||||
where: { id },
|
||||
include: {
|
||||
customers: true,
|
||||
order_items: { orderBy: { position: 'asc' } },
|
||||
order_sections: { orderBy: { position: 'asc' } },
|
||||
quotations: { select: { id: true, quotation_number: true, project_code: true } },
|
||||
projects: { select: { id: true, project_number: true, name: true, status: true } },
|
||||
invoices: { select: { id: true, invoice_number: true, status: true }, take: 1 },
|
||||
order_items: { orderBy: { position: "asc" } },
|
||||
order_sections: { orderBy: { position: "asc" } },
|
||||
quotations: {
|
||||
select: { id: true, quotation_number: true, project_code: true },
|
||||
},
|
||||
projects: {
|
||||
select: { id: true, project_number: true, name: true, status: true },
|
||||
},
|
||||
invoices: {
|
||||
select: { id: true, invoice_number: true, status: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!order) return null;
|
||||
@@ -99,7 +136,7 @@ export async function getOrder(id: number) {
|
||||
invoice: invoice,
|
||||
invoice_id: invoice?.id || null,
|
||||
invoice_number: invoice?.invoice_number || null,
|
||||
valid_transitions: VALID_TRANSITIONS[(order.status as string) || ''] || [],
|
||||
valid_transitions: VALID_TRANSITIONS[(order.status as string) || ""] || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,19 +159,26 @@ interface CreateOrderFromQuotationData {
|
||||
attachmentName?: string | null;
|
||||
}
|
||||
|
||||
export async function createOrderFromQuotation(data: CreateOrderFromQuotationData) {
|
||||
const { quotationId, customerOrderNumber, attachmentBuffer, attachmentName } = data;
|
||||
export async function createOrderFromQuotation(
|
||||
data: CreateOrderFromQuotationData,
|
||||
) {
|
||||
const { quotationId, customerOrderNumber, attachmentBuffer, attachmentName } =
|
||||
data;
|
||||
|
||||
const quotation = await prisma.quotations.findUnique({
|
||||
where: { id: quotationId },
|
||||
include: {
|
||||
quotation_items: { orderBy: { position: 'asc' } },
|
||||
scope_sections: { orderBy: { position: 'asc' } },
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!quotation) return { error: 'Nabídka nenalezena', status: 404 } as const;
|
||||
if (quotation.order_id) return { error: 'Z této nabídky již byla vytvořena objednávka', status: 400 } as const;
|
||||
if (!quotation) return { error: "Nabídka nenalezena", status: 404 } as const;
|
||||
if (quotation.order_id)
|
||||
return {
|
||||
error: "Z této nabídky již byla vytvořena objednávka",
|
||||
status: 400,
|
||||
} as const;
|
||||
|
||||
const orderNumber = await generateSharedNumber();
|
||||
const projectNumber = await generateSharedNumber();
|
||||
@@ -146,15 +190,17 @@ export async function createOrderFromQuotation(data: CreateOrderFromQuotationDat
|
||||
customer_order_number: customerOrderNumber || null,
|
||||
quotation_id: quotationId,
|
||||
customer_id: quotation.customer_id,
|
||||
status: 'prijata',
|
||||
currency: quotation.currency || 'CZK',
|
||||
language: quotation.language || 'cs',
|
||||
status: "prijata",
|
||||
currency: quotation.currency || "CZK",
|
||||
language: quotation.language || "cs",
|
||||
vat_rate: quotation.vat_rate ?? 21.0,
|
||||
apply_vat: quotation.apply_vat ?? true,
|
||||
exchange_rate: quotation.exchange_rate ?? 1.0,
|
||||
scope_title: quotation.scope_title,
|
||||
scope_description: quotation.scope_description,
|
||||
attachment_data: attachmentBuffer ? new Uint8Array(attachmentBuffer) : null,
|
||||
attachment_data: attachmentBuffer
|
||||
? new Uint8Array(attachmentBuffer)
|
||||
: null,
|
||||
attachment_name: attachmentName || null,
|
||||
},
|
||||
});
|
||||
@@ -188,24 +234,32 @@ export async function createOrderFromQuotation(data: CreateOrderFromQuotationDat
|
||||
|
||||
await tx.quotations.update({
|
||||
where: { id: quotationId },
|
||||
data: { order_id: order.id, status: 'ordered', modified_at: new Date() },
|
||||
data: { order_id: order.id, status: "ordered", modified_at: new Date() },
|
||||
});
|
||||
|
||||
const project = await tx.projects.create({
|
||||
data: {
|
||||
project_number: projectNumber,
|
||||
name: quotation.project_code || quotation.quotation_number || orderNumber,
|
||||
name:
|
||||
quotation.project_code || quotation.quotation_number || orderNumber,
|
||||
customer_id: quotation.customer_id,
|
||||
quotation_id: quotationId,
|
||||
order_id: order.id,
|
||||
status: 'aktivni',
|
||||
status: "aktivni",
|
||||
},
|
||||
});
|
||||
|
||||
return { order, project };
|
||||
});
|
||||
|
||||
return { data: { order_id: result.order.id, id: result.order.id, order_number: orderNumber, quotationId } };
|
||||
return {
|
||||
data: {
|
||||
order_id: result.order.id,
|
||||
id: result.order.id,
|
||||
order_number: orderNumber,
|
||||
quotationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateOrderData {
|
||||
@@ -283,7 +337,8 @@ interface UpdateOrderData {
|
||||
|
||||
export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
const existing = await prisma.orders.findUnique({ where: { id } });
|
||||
if (!existing) return { error: 'Objednávka nenalezena', status: 404 } as const;
|
||||
if (!existing)
|
||||
return { error: "Objednávka nenalezena", status: 404 } as const;
|
||||
|
||||
const currentStatus = existing.status as string;
|
||||
|
||||
@@ -292,23 +347,41 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
const newStatus = String(body.status);
|
||||
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
||||
if (!allowed.includes(newStatus)) {
|
||||
return { error: `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`, status: 400 } as const;
|
||||
return {
|
||||
error: `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`,
|
||||
status: 400,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = ['order_number', 'customer_order_number', 'status', 'currency', 'language', 'scope_title', 'scope_description', 'notes'];
|
||||
const strFields = [
|
||||
"order_number",
|
||||
"customer_order_number",
|
||||
"status",
|
||||
"currency",
|
||||
"language",
|
||||
"scope_title",
|
||||
"scope_description",
|
||||
"notes",
|
||||
];
|
||||
for (const f of strFields) {
|
||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
}
|
||||
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (body.customer_id !== undefined)
|
||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
|
||||
if (body.apply_vat !== undefined) data.apply_vat = body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === '1';
|
||||
if (body.apply_vat !== undefined)
|
||||
data.apply_vat =
|
||||
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
|
||||
|
||||
await prisma.orders.update({ where: { id }, data });
|
||||
|
||||
// Sync project_number when order_number changes (matching PHP)
|
||||
if (body.order_number !== undefined && String(body.order_number) !== existing.order_number) {
|
||||
if (
|
||||
body.order_number !== undefined &&
|
||||
String(body.order_number) !== existing.order_number
|
||||
) {
|
||||
await prisma.projects.updateMany({
|
||||
where: { order_id: id },
|
||||
data: { project_number: String(body.order_number) },
|
||||
@@ -318,9 +391,9 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
// Sync project status when order status changes (matching PHP)
|
||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
v_realizaci: 'aktivni',
|
||||
dokoncena: 'dokonceny',
|
||||
stornovana: 'zruseny',
|
||||
v_realizaci: "aktivni",
|
||||
dokoncena: "dokonceny",
|
||||
stornovana: "zruseny",
|
||||
};
|
||||
const projectStatus = statusMap[String(body.status)];
|
||||
if (projectStatus) {
|
||||
@@ -337,9 +410,14 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
await tx.order_items.deleteMany({ where: { order_id: id } });
|
||||
await tx.order_items.createMany({
|
||||
data: (body.items as OrderItemInput[]).map((item, i) => ({
|
||||
order_id: id, description: item.description ?? null, item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1, unit: item.unit ?? null, unit_price: item.unit_price ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false, position: item.position ?? i,
|
||||
order_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
is_included_in_total: item.is_included_in_total !== false,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -347,7 +425,11 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
await tx.order_sections.deleteMany({ where: { order_id: id } });
|
||||
await tx.order_sections.createMany({
|
||||
data: (body.sections as OrderSectionInput[]).map((s, i) => ({
|
||||
order_id: id, title: s.title ?? null, title_cz: s.title_cz ?? null, content: s.content ?? null, position: s.position ?? i,
|
||||
order_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -359,7 +441,8 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
||||
|
||||
export async function deleteOrder(id: number) {
|
||||
const existing = await prisma.orders.findUnique({ where: { id } });
|
||||
if (!existing) return { error: 'Objednávka nenalezena', status: 404 } as const;
|
||||
if (!existing)
|
||||
return { error: "Objednávka nenalezena", status: 404 } as const;
|
||||
|
||||
// Clear quotation back-reference (matching PHP)
|
||||
await prisma.quotations.updateMany({
|
||||
@@ -368,10 +451,15 @@ export async function deleteOrder(id: number) {
|
||||
});
|
||||
|
||||
// Delete linked project and its notes (matching PHP)
|
||||
const linkedProjects = await prisma.projects.findMany({ where: { order_id: id }, select: { id: true } });
|
||||
const linkedProjects = await prisma.projects.findMany({
|
||||
where: { order_id: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (linkedProjects.length > 0) {
|
||||
const projectIds = linkedProjects.map(p => p.id);
|
||||
await prisma.project_notes.deleteMany({ where: { project_id: { in: projectIds } } });
|
||||
const projectIds = linkedProjects.map((p) => p.id);
|
||||
await prisma.project_notes.deleteMany({
|
||||
where: { project_id: { in: projectIds } },
|
||||
});
|
||||
await prisma.projects.deleteMany({ where: { order_id: id } });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
import prisma from '../config/database';
|
||||
import { generateSharedNumber } from './numbering.service';
|
||||
import { NasFileManager } from './nas-file-manager';
|
||||
import prisma from "../config/database";
|
||||
import { generateSharedNumber } from "./numbering.service";
|
||||
import { NasFileManager } from "./nas-file-manager";
|
||||
|
||||
const nasFileManager = new NasFileManager();
|
||||
|
||||
const ALLOWED_SORT_FIELDS = ['id', 'project_number', 'name', 'status', 'created_at'];
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
"project_number",
|
||||
"name",
|
||||
"status",
|
||||
"created_at",
|
||||
];
|
||||
|
||||
interface ListProjectsParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: 'asc' | 'desc';
|
||||
order: "asc" | "desc";
|
||||
search: string;
|
||||
status?: string;
|
||||
customer_id?: number;
|
||||
}
|
||||
|
||||
export async function listProjects(params: ListProjectsParams) {
|
||||
const { page, limit, skip, sort, order, search, status, customer_id } = params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
|
||||
const { page, limit, skip, sort, order, search, status, customer_id } =
|
||||
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 (search) where.OR = [{ name: { contains: search } }, { project_number: { contains: search } }];
|
||||
if (search)
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ project_number: { contains: search } },
|
||||
];
|
||||
|
||||
const [projects, total] = await Promise.all([
|
||||
prisma.projects.findMany({
|
||||
where, skip, take: limit, orderBy: { [sortField]: order },
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: order },
|
||||
include: {
|
||||
customers: { select: { id: true, name: true } },
|
||||
users: { select: { id: true, first_name: true, last_name: true } },
|
||||
@@ -38,10 +52,12 @@ export async function listProjects(params: ListProjectsParams) {
|
||||
prisma.projects.count({ where }),
|
||||
]);
|
||||
|
||||
const enriched = projects.map(p => ({
|
||||
const enriched = projects.map((p) => ({
|
||||
...p,
|
||||
customer_name: p.customers?.name || null,
|
||||
responsible_user_name: p.users ? `${p.users.first_name} ${p.users.last_name}`.trim() : null,
|
||||
responsible_user_name: p.users
|
||||
? `${p.users.first_name} ${p.users.last_name}`.trim()
|
||||
: null,
|
||||
order_number: (p.orders as any)?.order_number || null,
|
||||
}));
|
||||
|
||||
@@ -51,12 +67,20 @@ export async function listProjects(params: ListProjectsParams) {
|
||||
export async function getProject(id: number) {
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id },
|
||||
include: { customers: true, users: true, quotations: true, orders: true, project_notes: { orderBy: { created_at: 'desc' } } },
|
||||
include: {
|
||||
customers: true,
|
||||
users: true,
|
||||
quotations: true,
|
||||
orders: true,
|
||||
project_notes: { orderBy: { created_at: "desc" } },
|
||||
},
|
||||
});
|
||||
if (!project) return null;
|
||||
return {
|
||||
...project,
|
||||
has_nas_folder: project.project_number ? nasFileManager.projectFolderExists(project.project_number) : false,
|
||||
has_nas_folder: project.project_number
|
||||
? nasFileManager.projectFolderExists(project.project_number)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,10 +90,12 @@ export async function createProject(body: Record<string, any>) {
|
||||
project_number: body.project_number ? String(body.project_number) : null,
|
||||
name: body.name ? String(body.name) : null,
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
responsible_user_id: body.responsible_user_id ? Number(body.responsible_user_id) : null,
|
||||
responsible_user_id: body.responsible_user_id
|
||||
? Number(body.responsible_user_id)
|
||||
: null,
|
||||
quotation_id: body.quotation_id ? Number(body.quotation_id) : null,
|
||||
order_id: body.order_id ? Number(body.order_id) : null,
|
||||
status: body.status ? String(body.status) : 'aktivni',
|
||||
status: body.status ? String(body.status) : "aktivni",
|
||||
start_date: body.start_date ? new Date(String(body.start_date)) : null,
|
||||
end_date: body.end_date ? new Date(String(body.end_date)) : null,
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
@@ -77,7 +103,10 @@ export async function createProject(body: Record<string, any>) {
|
||||
});
|
||||
|
||||
if (project.project_number && nasFileManager.isConfigured()) {
|
||||
nasFileManager.createProjectFolder(project.project_number, project.name || '');
|
||||
nasFileManager.createProjectFolder(
|
||||
project.project_number,
|
||||
project.name || "",
|
||||
);
|
||||
}
|
||||
|
||||
return project;
|
||||
@@ -88,19 +117,37 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
if (!existing) return null;
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
const strFields = ['project_number', 'name', 'status', 'notes'];
|
||||
for (const f of strFields) if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
if (body.customer_id !== undefined) data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (body.responsible_user_id !== undefined) data.responsible_user_id = body.responsible_user_id ? Number(body.responsible_user_id) : null;
|
||||
if (body.quotation_id !== undefined) data.quotation_id = body.quotation_id ? Number(body.quotation_id) : null;
|
||||
if (body.order_id !== undefined) data.order_id = body.order_id ? Number(body.order_id) : null;
|
||||
if (body.start_date !== undefined) data.start_date = body.start_date ? new Date(String(body.start_date)) : null;
|
||||
if (body.end_date !== undefined) data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
|
||||
const strFields = ["project_number", "name", "status", "notes"];
|
||||
for (const f of strFields)
|
||||
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
||||
if (body.customer_id !== undefined)
|
||||
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
||||
if (body.responsible_user_id !== undefined)
|
||||
data.responsible_user_id = body.responsible_user_id
|
||||
? Number(body.responsible_user_id)
|
||||
: null;
|
||||
if (body.quotation_id !== undefined)
|
||||
data.quotation_id = body.quotation_id ? Number(body.quotation_id) : null;
|
||||
if (body.order_id !== undefined)
|
||||
data.order_id = body.order_id ? Number(body.order_id) : null;
|
||||
if (body.start_date !== undefined)
|
||||
data.start_date = body.start_date
|
||||
? new Date(String(body.start_date))
|
||||
: null;
|
||||
if (body.end_date !== undefined)
|
||||
data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
|
||||
|
||||
await prisma.projects.update({ where: { id }, data });
|
||||
|
||||
if (existing.name !== data.name && existing.project_number && nasFileManager.isConfigured()) {
|
||||
nasFileManager.renameProjectFolder(existing.project_number, String(data.name || ''));
|
||||
if (
|
||||
existing.name !== data.name &&
|
||||
existing.project_number &&
|
||||
nasFileManager.isConfigured()
|
||||
) {
|
||||
nasFileManager.renameProjectFolder(
|
||||
existing.project_number,
|
||||
String(data.name || ""),
|
||||
);
|
||||
}
|
||||
|
||||
return existing;
|
||||
@@ -108,8 +155,8 @@ export async function updateProject(id: number, body: Record<string, any>) {
|
||||
|
||||
export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
||||
const existing = await prisma.projects.findUnique({ where: { id } });
|
||||
if (!existing) return { error: 'not_found' as const };
|
||||
if (existing.order_id) return { error: 'has_order' as const };
|
||||
if (!existing) return { error: "not_found" as const };
|
||||
if (existing.order_id) return { error: "has_order" as const };
|
||||
|
||||
if (deleteFiles && existing.project_number && nasFileManager.isConfigured()) {
|
||||
await nasFileManager.deleteProjectFolder(existing.project_number);
|
||||
@@ -119,7 +166,15 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function createProjectNote(projectId: number, data: { userId: number; firstName: string; lastName: string; content?: string }) {
|
||||
export async function createProjectNote(
|
||||
projectId: number,
|
||||
data: {
|
||||
userId: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
content?: string;
|
||||
},
|
||||
) {
|
||||
const note = await prisma.project_notes.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
@@ -132,7 +187,9 @@ export async function createProjectNote(projectId: number, data: { userId: numbe
|
||||
}
|
||||
|
||||
export async function deleteProjectNote(projectId: number, noteId: number) {
|
||||
const note = await prisma.project_notes.findFirst({ where: { id: noteId, project_id: projectId } });
|
||||
const note = await prisma.project_notes.findFirst({
|
||||
where: { id: noteId, project_id: projectId },
|
||||
});
|
||||
if (!note) return null;
|
||||
|
||||
await prisma.project_notes.delete({ where: { id: noteId } });
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import prisma from '../config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { config } from '../config/env';
|
||||
import prisma from "../config/database";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { config } from "../config/env";
|
||||
|
||||
const ALLOWED_SORT_FIELDS = ['id', 'username', 'email', 'first_name', 'last_name', 'created_at'];
|
||||
const ALLOWED_SORT_FIELDS = [
|
||||
"id",
|
||||
"username",
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"created_at",
|
||||
];
|
||||
|
||||
const USER_SELECT = {
|
||||
id: true, username: true, email: true, first_name: true, last_name: true,
|
||||
role_id: true, is_active: true, last_login: true, totp_enabled: true,
|
||||
created_at: true, updated_at: true,
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
first_name: true,
|
||||
last_name: true,
|
||||
role_id: true,
|
||||
is_active: true,
|
||||
last_login: true,
|
||||
totp_enabled: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { id: true, name: true, display_name: true } },
|
||||
} as const;
|
||||
|
||||
@@ -16,7 +31,7 @@ export interface ListUsersParams {
|
||||
limit: number;
|
||||
skip: number;
|
||||
sort: string;
|
||||
order: 'asc' | 'desc';
|
||||
order: "asc" | "desc";
|
||||
search: string;
|
||||
}
|
||||
|
||||
@@ -42,7 +57,7 @@ export interface UpdateUserData {
|
||||
|
||||
export async function listUsers(params: ListUsersParams) {
|
||||
const { page, limit, skip, sort, order, search } = params;
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : 'id';
|
||||
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
||||
|
||||
const where = search
|
||||
? {
|
||||
@@ -84,22 +99,27 @@ export async function createUser(data: CreateUserData) {
|
||||
|
||||
// Email format
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return { error: 'Neplatný formát e-mailu', status: 400 } as const;
|
||||
return { error: "Neplatný formát e-mailu", status: 400 } as const;
|
||||
}
|
||||
|
||||
// Username uniqueness
|
||||
const existingUsername = await prisma.users.findFirst({ where: { username } });
|
||||
const existingUsername = await prisma.users.findFirst({
|
||||
where: { username },
|
||||
});
|
||||
if (existingUsername) {
|
||||
return { error: 'Uživatelské jméno již existuje', status: 409 } as const;
|
||||
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;
|
||||
return { error: "E-mail již existuje", status: 409 } as const;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(data.password, config.security.bcryptCost);
|
||||
const passwordHash = await bcrypt.hash(
|
||||
data.password,
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
|
||||
const user = await prisma.users.create({
|
||||
data: {
|
||||
@@ -119,7 +139,7 @@ export async function createUser(data: CreateUserData) {
|
||||
export async function updateUser(id: number, body: UpdateUserData) {
|
||||
const existing = await prisma.users.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return { error: 'Uživatel nenalezen', status: 404 } as const;
|
||||
return { error: "Uživatel nenalezen", status: 404 } as const;
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
@@ -128,9 +148,14 @@ export async function updateUser(id: number, body: UpdateUserData) {
|
||||
if (body.username !== undefined) {
|
||||
const newUsername = String(body.username).trim();
|
||||
if (newUsername !== existing.username) {
|
||||
const existingUsername = await prisma.users.findFirst({ where: { username: newUsername } });
|
||||
const existingUsername = await prisma.users.findFirst({
|
||||
where: { username: newUsername },
|
||||
});
|
||||
if (existingUsername) {
|
||||
return { error: 'Uživatelské jméno již existuje', status: 409 } as const;
|
||||
return {
|
||||
error: "Uživatelské jméno již existuje",
|
||||
status: 409,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
data.username = newUsername;
|
||||
@@ -140,27 +165,33 @@ export async function updateUser(id: number, body: UpdateUserData) {
|
||||
if (body.email !== undefined) {
|
||||
const newEmail = String(body.email).trim();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||
return { error: 'Neplatný formát e-mailu', status: 400 } as const;
|
||||
return { error: "Neplatný formát e-mailu", status: 400 } as const;
|
||||
}
|
||||
const existingEmail = await prisma.users.findFirst({
|
||||
where: { email: newEmail, id: { not: id } },
|
||||
});
|
||||
if (existingEmail) {
|
||||
return { error: 'E-mail již existuje', status: 409 } as const;
|
||||
return { error: "E-mail již existuje", status: 409 } as const;
|
||||
}
|
||||
data.email = newEmail;
|
||||
}
|
||||
|
||||
if (body.first_name !== undefined) data.first_name = String(body.first_name);
|
||||
if (body.last_name !== undefined) data.last_name = String(body.last_name);
|
||||
if (body.role_id !== undefined) data.role_id = body.role_id ? Number(body.role_id) : null;
|
||||
if (body.is_active !== undefined) data.is_active = body.is_active === true || body.is_active === 1 || body.is_active === '1';
|
||||
if (body.role_id !== undefined)
|
||||
data.role_id = body.role_id ? Number(body.role_id) : null;
|
||||
if (body.is_active !== undefined)
|
||||
data.is_active =
|
||||
body.is_active === true || body.is_active === 1 || body.is_active === "1";
|
||||
if (body.password) {
|
||||
const newPassword = String(body.password);
|
||||
if (newPassword.length < 8) {
|
||||
return { error: 'Heslo musí mít alespoň 8 znaků', status: 400 } as const;
|
||||
return { error: "Heslo musí mít alespoň 8 znaků", status: 400 } as const;
|
||||
}
|
||||
data.password_hash = await bcrypt.hash(newPassword, config.security.bcryptCost);
|
||||
data.password_hash = await bcrypt.hash(
|
||||
newPassword,
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
data.password_changed_at = new Date();
|
||||
}
|
||||
|
||||
@@ -171,12 +202,12 @@ export async function updateUser(id: number, body: UpdateUserData) {
|
||||
|
||||
export async function deleteUser(id: number, currentUserId?: number) {
|
||||
if (id === currentUserId) {
|
||||
return { error: 'Nelze smazat vlastní účet', status: 400 } as const;
|
||||
return { error: "Nelze smazat vlastní účet", status: 400 } as const;
|
||||
}
|
||||
|
||||
const existing = await prisma.users.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return { error: 'Uživatel nenalezen', status: 404 } as const;
|
||||
return { error: "Uživatel nenalezen", status: 404 } as const;
|
||||
}
|
||||
|
||||
await prisma.refresh_tokens.deleteMany({ where: { user_id: id } });
|
||||
|
||||
Reference in New Issue
Block a user