security: fix all Critical and High findings from FLAWS_REPORT audit

- Auth: pessimistic locking on login tokens and refresh token rotation,
  backup code attempt counter, rate limiting verification
- Schema: unique constraints on business numbers, FK relations,
  unsigned/signed alignment, attendance duplicate prevention
- Invoices/PDFs: DOMPurify sanitization, bounded queries in stats
  and alerts, VAT rounding, Puppeteer error handling
- Orders/Offers: transactional parent+child creation, Zod NaN
  refinement, status enums, uniqueness checks
- Projects/Files: path traversal protection, streamed uploads,
  permission guards, query param validation
- Attendance/HR: duplicate checks, ownership validation, GPS
  restrictions, trip distance validation
- Frontend: modal lock reference counting, XSS escaping in print
  HTML, ref mutation fixes, accessibility attributes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-24 00:58:35 +02:00
parent 122eee175e
commit 528e55991b
57 changed files with 2355 additions and 1010 deletions

View File

@@ -254,10 +254,7 @@ export async function getStatus(userId: number) {
}
const worked = Math.round(workedHours * 100) / 100;
const holidayDays = monthRecords.filter(
(r) => (r.leave_type as string) === "holiday",
).length;
const adjustedFund = Math.max(0, (workingDays - holidayDays) * 8);
const adjustedFund = Math.max(0, fund);
const leaveHours = vacationHours + sickHours;
const covered = worked + leaveHours;
const remaining = Math.max(0, adjustedFund - covered);
@@ -266,7 +263,7 @@ export async function getStatus(userId: number) {
const monthlyFund = {
month_name: `${MONTH_NAMES[m]} ${y}`,
fund: adjustedFund,
business_days: workingDays - holidayDays,
business_days: workingDays,
worked,
covered,
remaining,
@@ -390,7 +387,11 @@ export async function updateAddress(
punchAction: string,
) {
const latest = await prisma.attendance.findFirst({
where: { user_id: userId },
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
if (!latest) return { error: "Nenalezen záznam" };
@@ -574,7 +575,6 @@ export async function getWorkfund(year: number) {
let worked = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayDays = 0;
for (const rec of recs) {
const lt = (rec.leave_type as string) || "work";
@@ -591,8 +591,6 @@ export async function getWorkfund(year: number) {
vacationHours += Number(rec.leave_hours) || 8;
} else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8;
} else if (lt === "holiday") {
holidayDays++;
}
}
@@ -1224,6 +1222,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
0,
),
);
const duplicate = await prisma.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
});
if (duplicate) {
return { error: "Pro zvolené datumy již existují záznamy docházky" };
}
await prisma.attendance.create({
data: {
user_id: userId,
@@ -1438,10 +1442,36 @@ export async function createAttendance(
data: CreateAttendanceData,
authUserId: number,
) {
const userId = data.user_id ?? authUserId;
const shiftDate = new Date(data.shift_date);
const startOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate(),
);
const endOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate() + 1,
);
const duplicate = await prisma.attendance.findFirst({
where: {
user_id: userId,
shift_date: { gte: startOfDay, lt: endOfDay },
},
});
if (duplicate) {
return {
error: "Pro zvolené datumy již existují záznamy docházky",
status: 400,
};
}
const record = await prisma.attendance.create({
data: {
user_id: data.user_id ?? authUserId,
shift_date: new Date(data.shift_date),
user_id: userId,
shift_date: shiftDate,
arrival_time: data.arrival_time ? new Date(data.arrival_time) : null,
arrival_lat: data.arrival_lat ?? null,
arrival_lng: data.arrival_lng ?? null,

View File

@@ -109,32 +109,42 @@ export async function login(
}
if (!user.is_active) {
return { type: "error", message: "Účet je deaktivován", status: 403 };
request.log.warn(`Login failed for deactivated user: ${username}`);
return {
type: "error",
message: "Neplatné přihlašovací údaje",
status: 401,
};
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
request.log.warn(`Login failed for locked user: ${username}`);
return {
type: "error",
message: "Účet je dočasně uzamčen. Zkuste to později.",
status: 429,
message: "Neplatné přihlašovací údaje",
status: 401,
};
}
const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) {
const settings = await getSystemSettings();
const attempts = (user.failed_login_attempts ?? 0) + 1;
const updateData: Record<string, unknown> = {
failed_login_attempts: attempts,
};
await prisma.users.update({
where: { id: user.id },
data: { failed_login_attempts: { increment: 1 } },
});
if (attempts >= settings.max_login_attempts) {
updateData.locked_until = new Date(
Date.now() + settings.lockout_minutes * 60_000,
);
if ((user.failed_login_attempts ?? 0) + 1 >= settings.max_login_attempts) {
await prisma.users.update({
where: { id: user.id },
data: {
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
),
},
});
}
await prisma.users.update({ where: { id: user.id }, data: updateData });
return {
type: "error",
message: "Neplatné přihlašovací údaje",
@@ -151,6 +161,17 @@ export async function login(
},
});
const companySettings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
});
if (companySettings?.require_2fa && !user.totp_enabled) {
return {
type: "error",
message: "Dvoufázové ověření je povinné",
status: 403,
};
}
if (user.totp_enabled) {
const loginToken = crypto.randomBytes(32).toString("hex");
const tokenHash = hashToken(loginToken);
@@ -222,60 +243,69 @@ export async function refreshAccessToken(
> {
const tokenHash = hashToken(refreshTokenRaw);
const storedToken = await prisma.refresh_tokens.findUnique({
where: { token_hash: tokenHash },
});
return prisma.$transaction(async (tx) => {
const tokens = await tx.$queryRaw<
Array<{
id: number;
user_id: number;
expires_at: Date;
replaced_at: Date | null;
remember_me: boolean | null;
}>
>`
SELECT id, user_id, expires_at, replaced_at, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
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 };
}
const authData = await loadAuthData(storedToken.user_id);
if (!authData) {
return { type: "error", message: "Uživatel nenalezen", status: 401 };
}
const newRefreshTokenRaw = generateRefreshToken();
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
const newRefreshTokenRaw = generateRefreshToken();
const newRefreshTokenHash = hashToken(newRefreshTokenRaw);
const expiresIn = storedToken.remember_me
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
const expiresIn = storedToken.remember_me
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
await prisma.$transaction([
prisma.refresh_tokens.update({
await tx.refresh_tokens.update({
where: { id: storedToken.id },
data: { replaced_at: new Date(), replaced_by_hash: newRefreshTokenHash },
}),
prisma.refresh_tokens.create({
});
await tx.refresh_tokens.create({
data: {
user_id: storedToken.user_id,
token_hash: newRefreshTokenHash,
expires_at: new Date(Date.now() + expiresIn * 1000),
remember_me: storedToken.remember_me,
remember_me: storedToken.remember_me ?? false,
ip_address: request.ip,
user_agent: request.headers["user-agent"] ?? null,
},
}),
]);
});
const accessToken = generateAccessToken({
id: authData.userId,
username: authData.username,
roleName: authData.roleName,
const accessToken = generateAccessToken({
id: authData.userId,
username: authData.username,
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> {

View File

@@ -37,7 +37,7 @@ async function fetchRatesForDate(
} catch (err) {
console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache["today"]) return rateCache["today"];
return { CZK: 1, EUR: 25, USD: 22, GBP: 28 };
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
}
}
@@ -50,7 +50,7 @@ export async function toCzk(
if (currency === "CZK") return amount;
const rates = await fetchRatesForDate(date);
const rate = rates[currency];
if (!rate) return amount;
if (!rate) throw new Error(`Neznámá měna: ${currency}`);
return Math.round(amount * rate * 100) / 100;
}
@@ -61,5 +61,7 @@ export async function getRate(
): Promise<number> {
if (currency === "CZK") return 1;
const rates = await fetchRatesForDate(date);
return rates[currency] || 1;
const rate = rates[currency];
if (!rate) throw new Error(`Neznámá měna: ${currency}`);
return rate;
}

View File

@@ -48,11 +48,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
const createdInvoices = await prisma.invoices.findMany({
where: {
status: { in: ["issued", "overdue"] },
due_date: { not: null },
due_date: { gte: today, lte: in3days },
},
include: {
select: {
id: true,
invoice_number: true,
due_date: true,
currency: true,
customers: { select: { name: true } },
invoice_items: true,
invoice_items: { select: { quantity: true, unit_price: true } },
},
});
@@ -105,7 +109,15 @@ export async function checkInvoiceAlerts(): Promise<void> {
const receivedInvoices = await prisma.received_invoices.findMany({
where: {
status: "unpaid",
due_date: { not: null },
due_date: { gte: today, lte: in3days },
},
select: {
id: true,
invoice_number: true,
supplier_name: true,
amount: true,
currency: true,
due_date: true,
},
});

View File

@@ -55,10 +55,9 @@ function computeInvoiceTotals(
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)
);
const vat =
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
return s + Math.round(vat * 100) / 100;
}, 0)
: 0;
return {
@@ -156,6 +155,31 @@ export {
previewInvoiceNumber as getNextInvoiceNumberPreview,
} from "./numbering.service";
function invoiceTotalWithVat(inv: {
apply_vat: boolean | null;
vat_rate: { toNumber(): number } | null;
currency: string | null;
invoice_items: Array<{
quantity: { toNumber(): number } | null;
unit_price: { toNumber(): number } | null;
vat_rate: { toNumber(): number } | null;
}>;
}) {
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);
const lineVat =
base * ((Number(i.vat_rate) || Number(inv.vat_rate) || 21) / 100);
return s + Math.round(lineVat * 100) / 100;
}, 0)
: 0;
return sub + vat;
}
export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const now = new Date();
const year = queryYear || now.getFullYear();
@@ -163,29 +187,35 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59);
const startOfYear = new Date(year, 0, 1);
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
const allInvoices = await prisma.invoices.findMany({
include: { invoice_items: true },
});
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
prisma.invoices.findMany({
where: {
issue_date: { gte: monthStart, lte: monthEnd },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "issued",
issue_date: { gte: startOfYear, lte: endOfYear },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "overdue",
issue_date: { gte: startOfYear, lte: endOfYear },
},
include: { invoice_items: true },
}),
]);
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)
);
}, 0)
: 0;
return sub + vat;
};
const aggregateByCurrency = (invoices: typeof allInvoices) => {
const aggregateByCurrency = (
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
) => {
const map: Record<string, number> = {};
for (const inv of invoices) {
const cur = inv.currency || "CZK";
@@ -199,7 +229,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
}));
};
const sumCzk = async (invoices: typeof allInvoices) => {
const sumCzk = async (
invoices: Parameters<typeof invoiceTotalWithVat>[0][],
) => {
let total = 0;
for (const inv of invoices) {
const amount = invoiceTotalWithVat(inv);
@@ -208,14 +240,7 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
return Math.round(total * 100) / 100;
};
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 vatMap: Record<string, number> = {};
for (const inv of monthInvoices) {
@@ -454,8 +479,8 @@ export async function deleteInvoice(id: number) {
await prisma.invoices.delete({ where: { id } });
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
const year = existing.invoice_number
? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear()
: new Date().getFullYear();
await releaseInvoiceNumber(year);

View File

@@ -1,5 +1,6 @@
import fs from "fs";
import path from "path";
import { pipeline } from "stream/promises";
import { config } from "../config/env";
import { localDateStr, localTimeStr } from "../utils/date";
@@ -294,21 +295,21 @@ export class NasFileManager {
public async uploadFile(
projectNumber: string,
subPath: string,
fileBuffer: Buffer,
fileStream: NodeJS.ReadableStream,
fileName: string,
): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
if (
dirPath === null ||
!fs.existsSync(dirPath) ||
!fs.statSync(dirPath).isDirectory()
) {
if (dirPath === null) {
return "Cílová složka neexistuje";
}
if (fileBuffer.length > config.nas.maxUploadSize) {
const maxMb = Math.round(config.nas.maxUploadSize / 1048576);
return `Soubor je příliš velký (max ${maxMb} MB)`;
try {
const stat = await fs.promises.stat(dirPath);
if (!stat.isDirectory()) {
return "Cílová složka neexistuje";
}
} catch {
return "Cílová složka neexistuje";
}
const originalName = path.basename(fileName);
@@ -322,9 +323,22 @@ export class NasFileManager {
return "Tento typ souboru není povolen";
}
const tempPath = path.join(
require("os").tmpdir(),
`upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
const typeResult = await FileType.fromBuffer(fileBuffer);
await pipeline(fileStream, fs.createWriteStream(tempPath));
} catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
try {
const typeResult = await FileType.fromFile(tempPath);
if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
} catch {
@@ -333,19 +347,28 @@ export class NasFileManager {
let destPath = dirPath + "/" + safeName;
if (fs.existsSync(destPath)) {
try {
await fs.promises.stat(destPath);
const base = path.basename(safeName, ext ? "." + ext : "");
let counter = 1;
do {
safeName = base + "_" + counter + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName;
counter++;
} while (fs.existsSync(destPath));
} while (
await fs.promises
.stat(destPath)
.then(() => true)
.catch(() => false)
);
} catch {
// destPath does not exist, continue
}
try {
fs.writeFileSync(destPath, fileBuffer);
await fs.promises.rename(tempPath, destPath);
} catch {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
@@ -381,7 +404,12 @@ export class NasFileManager {
projectNumber: string,
filePath: string,
): Promise<string | null> {
if (filePath === "" || filePath === "/") {
if (
filePath === "" ||
filePath === "/" ||
filePath === "." ||
filePath === "./"
) {
return "Nelze smazat kořenovou složku projektu";
}
@@ -390,22 +418,19 @@ export class NasFileManager {
return "Neplatná cesta";
}
if (!fs.existsSync(fullPath)) {
return "Soubor nebo složka neexistuje";
}
let isDir: boolean;
try {
isDir = fs.lstatSync(fullPath).isDirectory();
const stat = await fs.promises.lstat(fullPath);
isDir = stat.isDirectory();
} catch {
return "Neplatná cesta";
return "Soubor nebo složka neexistuje";
}
try {
if (isDir) {
await fs.promises.rm(fullPath, { recursive: true, force: true });
} else {
fs.unlinkSync(fullPath);
await fs.promises.unlink(fullPath);
}
} catch {
return isDir
@@ -416,12 +441,17 @@ export class NasFileManager {
return null;
}
public moveItem(
public async moveItem(
projectNumber: string,
fromPath: string,
toPath: string,
): string | null {
if (fromPath === "" || fromPath === "/") {
): Promise<string | null> {
if (
fromPath === "" ||
fromPath === "/" ||
fromPath === "." ||
fromPath === "./"
) {
return "Nelze přesunout kořenovou složku";
}
@@ -432,7 +462,9 @@ export class NasFileManager {
return "Neplatná cesta";
}
if (!fs.existsSync(fullFrom)) {
try {
await fs.promises.stat(fullFrom);
} catch {
return "Zdrojový soubor neexistuje";
}
@@ -441,8 +473,13 @@ export class NasFileManager {
fullFrom.replace(/\\/g, "/").toLowerCase() ===
fullTo.replace(/\\/g, "/").toLowerCase();
if (fs.existsSync(fullTo) && !sameFile) {
return "Cílový soubor již existuje";
if (!sameFile) {
try {
await fs.promises.stat(fullTo);
return "Cílový soubor již existuje";
} catch {
// target does not exist, continue
}
}
const targetName = path.basename(toPath);
@@ -451,7 +488,7 @@ export class NasFileManager {
}
try {
fs.renameSync(fullFrom, fullTo);
await fs.promises.rename(fullFrom, fullTo);
} catch (err: unknown) {
if (
err instanceof Error &&
@@ -466,17 +503,22 @@ export class NasFileManager {
return null;
}
public createFolder(
public async createFolder(
projectNumber: string,
subPath: string,
folderName: string,
): string | null {
): Promise<string | null> {
const dirPath = this.resolveProjectPath(projectNumber, subPath);
if (
dirPath === null ||
!fs.existsSync(dirPath) ||
!fs.statSync(dirPath).isDirectory()
) {
if (dirPath === null) {
return "Nadřazená složka neexistuje";
}
try {
const stat = await fs.promises.stat(dirPath);
if (!stat.isDirectory()) {
return "Nadřazená složka neexistuje";
}
} catch {
return "Nadřazená složka neexistuje";
}
@@ -486,12 +528,15 @@ export class NasFileManager {
}
const newPath = dirPath + "/" + safeName;
if (fs.existsSync(newPath)) {
try {
await fs.promises.stat(newPath);
return "Složka s tímto názvem již existuje";
} catch {
// does not exist, continue
}
try {
fs.mkdirSync(newPath, { mode: 0o775 });
await fs.promises.mkdir(newPath, { mode: 0o775 });
} catch {
return "Nepodařilo se vytvořit složku";
}
@@ -572,6 +617,11 @@ export class NasFileManager {
}
const normalized = subPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
// Reject explicit current-directory references (defense-in-depth for destructive ops)
if (normalized === "." || normalized === "./") {
return null;
}
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, "/");
// Verify candidate is within project folder

View File

@@ -68,11 +68,11 @@ class NasFinancialsManager {
if (!dir) return null;
const safeName = this.sanitizeFilename(invoiceNumber) + ".pdf";
const fullPath = path.join(dir, safeName);
const fullPath = this.uniquePath(dir, safeName);
try {
fs.writeFileSync(fullPath, pdfBuffer);
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`;
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
} catch {
return null;
}
@@ -125,12 +125,12 @@ class NasFinancialsManager {
let safeName = this.sanitizeFilename(originalName);
if (!safeName) safeName = "file";
const fullPath = path.join(dir, safeName);
const fullPath = this.uniquePath(dir, safeName);
try {
fs.writeFileSync(fullPath, fileBuffer);
return {
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${safeName}`,
filePath: `${DIR_RECEIVED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`,
};
} catch (e) {
return {

View File

@@ -126,7 +126,7 @@ async function releaseSequence(type: string, year: number) {
}
/** Verify a shared number is not already used by an order or project. */
async function isSharedNumberTaken(number: string): Promise<boolean> {
export async function isSharedNumberTaken(number: string): Promise<boolean> {
const [existingOrder, existingProject] = await Promise.all([
prisma.orders.findFirst({ where: { order_number: number } }),
prisma.projects.findFirst({ where: { project_number: number } }),
@@ -143,13 +143,21 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
}
/** Verify an offer/quotation number is not already used. */
async function isOfferNumberTaken(number: string): Promise<boolean> {
export async function isOfferNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.quotations.findFirst({
where: { quotation_number: number },
});
return !!existing;
}
/** Verify an order number is not already used. */
export async function isOrderNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.orders.findFirst({
where: { order_number: number },
});
return !!existing;
}
/**
* Next offer/quotation number (consumes sequence).
* Verifies uniqueness against the quotations table; retries if taken.

View File

@@ -3,6 +3,7 @@ import {
generateOfferNumber,
previewOfferNumber,
releaseOfferNumber,
isOfferNumberTaken,
} from "./numbering.service";
interface QuotationItemInput {
@@ -142,53 +143,64 @@ export async function createOffer(body: Record<string, any>) {
? String(body.quotation_number)
: await generateOfferNumber();
const quotation = await prisma.quotations.create({
data: {
quotation_number: quotationNumber,
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",
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",
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
? String(body.scope_description)
: null,
},
if (body.quotation_number !== undefined && body.quotation_number !== null) {
const taken = await isOfferNumberTaken(String(body.quotation_number));
if (taken) {
return { error: "Číslo nabídky je již použito", status: 400 } as const;
}
}
return prisma.$transaction(async (tx) => {
const quotation = await tx.quotations.create({
data: {
quotation_number: quotationNumber,
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",
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",
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
? String(body.scope_description)
: null,
},
});
if (Array.isArray(body.items)) {
await tx.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: quotation.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,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: quotation.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return quotation;
});
if (Array.isArray(body.items)) {
await prisma.quotation_items.createMany({
data: (body.items as QuotationItemInput[]).map((item, i) => ({
quotation_id: quotation.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,
})),
});
}
if (Array.isArray(body.sections)) {
await prisma.scope_sections.createMany({
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
quotation_id: quotation.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return quotation;
}
export async function updateOffer(id: number, body: Record<string, any>) {
@@ -204,55 +216,51 @@ export async function updateOffer(id: number, body: Record<string, any>) {
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
}
await prisma.quotations.update({
where: { id },
data: {
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,
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,
modified_at: new Date(),
},
});
const data = {
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,
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,
modified_at: new Date(),
};
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
await prisma.$transaction(async (tx) => {
await tx.quotations.update({ where: { id }, data });
if (Array.isArray(body.items)) {
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
await tx.quotation_items.createMany({
@@ -281,6 +289,8 @@ export async function updateOffer(id: number, body: Record<string, any>) {
});
}
});
} else {
await prisma.quotations.update({ where: { id }, data });
}
return { id, quotation_number: existing.quotation_number };

View File

@@ -3,6 +3,7 @@ import {
generateSharedNumber,
previewSharedNumber,
releaseSharedNumber,
isOrderNumberTaken,
} from "./numbering.service";
interface OrderItemInput {
@@ -290,52 +291,61 @@ export async function createOrder(body: CreateOrderData) {
? String(body.order_number)
: await generateSharedNumber();
const order = await prisma.orders.create({
data: {
order_number: orderNumber,
customer_order_number: body.customer_order_number ?? null,
quotation_id: body.quotation_id ?? null,
customer_id: body.customer_id ?? null,
status: body.status,
currency: body.currency,
language: body.language,
vat_rate: body.vat_rate,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate,
scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null,
notes: body.notes ?? null,
},
if (body.order_number !== undefined && body.order_number !== null) {
const taken = await isOrderNumberTaken(String(body.order_number));
if (taken) {
return { error: "Číslo objednávky je již použito", status: 400 } as const;
}
}
return prisma.$transaction(async (tx) => {
const order = await tx.orders.create({
data: {
order_number: orderNumber,
customer_order_number: body.customer_order_number ?? null,
quotation_id: body.quotation_id ?? null,
customer_id: body.customer_id ?? null,
status: body.status,
currency: body.currency,
language: body.language,
vat_rate: body.vat_rate,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate,
scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null,
notes: body.notes ?? null,
},
});
if (Array.isArray(body.items)) {
await tx.order_items.createMany({
data: body.items.map((item, i) => ({
order_id: order.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,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.order_sections.createMany({
data: body.sections.map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return { id: order.id, order_number: order.order_number };
});
if (Array.isArray(body.items)) {
await prisma.order_items.createMany({
data: body.items.map((item, i) => ({
order_id: order.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,
})),
});
}
if (Array.isArray(body.sections)) {
await prisma.order_sections.createMany({
data: body.sections.map((s, i) => ({
order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return { id: order.id, order_number: order.order_number };
}
interface UpdateOrderData {
@@ -393,24 +403,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
data.apply_vat =
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
await prisma.orders.update({ where: { id }, data });
// 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",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
return {
@@ -419,6 +411,24 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
} as const;
}
await prisma.$transaction(async (tx) => {
await tx.orders.update({ where: { id }, data });
// 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",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await tx.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
if (Array.isArray(body.items)) {
await tx.order_items.deleteMany({ where: { order_id: id } });
await tx.order_items.createMany({
@@ -447,6 +457,24 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
});
}
});
} else {
await prisma.orders.update({ where: { id }, data });
// 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",
};
const projectStatus = statusMap[String(body.status)];
if (projectStatus) {
await prisma.projects.updateMany({
where: { order_id: id },
data: { status: projectStatus },
});
}
}
}
return { data: { id, order_number: existing.order_number } };
@@ -478,17 +506,22 @@ export async function deleteOrder(id: number) {
await prisma.orders.delete({ where: { id } });
const releasedYears = new Set<number>();
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseSharedNumber(year);
releasedYears.add(year);
// Release the linked project's shared number(s) too
for (const p of linkedProjects) {
const pYear = p.created_at
? new Date(p.created_at).getFullYear()
: new Date().getFullYear();
await releaseSharedNumber(pYear);
if (!releasedYears.has(pYear)) {
await releaseSharedNumber(pYear);
releasedYears.add(pYear);
}
}
return { data: { id, order_number: existing.order_number } };

View File

@@ -56,13 +56,13 @@ export async function listProjects(params: ListProjectsParams) {
prisma.projects.count({ where }),
]);
const enriched = projects.map((p) => ({
const enriched = projects.map(({ customers, users, orders, ...p }) => ({
...p,
customer_name: p.customers?.name || null,
responsible_user_name: p.users
? `${p.users.first_name} ${p.users.last_name}`.trim()
customer_name: customers?.name || null,
responsible_user_name: users
? `${users.first_name} ${users.last_name}`.trim()
: null,
order_number: p.orders?.order_number || null,
order_number: orders?.order_number || null,
}));
return { data: enriched, total, page, limit };
@@ -72,10 +72,10 @@ export async function getProject(id: number) {
const project = await prisma.projects.findUnique({
where: { id },
include: {
customers: true,
users: true,
quotations: true,
orders: true,
customers: { select: { id: true, name: true } },
users: { select: { id: true, first_name: true, last_name: true } },
quotations: { select: { id: true, quotation_number: true } },
orders: { select: { id: true, order_number: true, status: true } },
project_notes: { orderBy: { created_at: "desc" } },
},
});
@@ -96,22 +96,33 @@ export async function getProject(id: number) {
};
}
import { isSharedNumberTaken } from "./numbering.service";
export async function createProject(body: Record<string, any>) {
const projectNumber =
body.project_number !== undefined && body.project_number !== null
? String(body.project_number)
: await generateSharedNumber();
if (body.project_number !== undefined && body.project_number !== null) {
const taken = await isSharedNumberTaken(String(body.project_number));
if (taken) {
return { error: "Číslo projektu je již použito", status: 400 };
}
}
const project = await prisma.projects.create({
data: {
project_number: projectNumber,
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,
quotation_id: body.quotation_id ? Number(body.quotation_id) : null,
order_id: body.order_id ? Number(body.order_id) : null,
customer_id: body.customer_id != null ? Number(body.customer_id) : null,
responsible_user_id:
body.responsible_user_id != null
? Number(body.responsible_user_id)
: null,
quotation_id:
body.quotation_id != null ? Number(body.quotation_id) : null,
order_id: body.order_id != null ? Number(body.order_id) : null,
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,
@@ -145,15 +156,18 @@ export async function updateProject(id: number, body: Record<string, any>) {
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;
data.customer_id =
body.customer_id != null ? 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;
data.responsible_user_id =
body.responsible_user_id != null
? Number(body.responsible_user_id)
: null;
if (body.quotation_id !== undefined)
data.quotation_id = body.quotation_id ? Number(body.quotation_id) : null;
data.quotation_id =
body.quotation_id != null ? Number(body.quotation_id) : null;
if (body.order_id !== undefined)
data.order_id = body.order_id ? Number(body.order_id) : null;
data.order_id = body.order_id != null ? Number(body.order_id) : null;
if (body.start_date !== undefined)
data.start_date = body.start_date
? new Date(String(body.start_date))
@@ -161,7 +175,7 @@ export async function updateProject(id: number, body: Record<string, any>) {
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 });
const updated = await prisma.projects.update({ where: { id }, data });
if (
body.name !== undefined &&
@@ -175,7 +189,7 @@ export async function updateProject(id: number, body: Record<string, any>) {
);
}
return existing;
return updated;
}
export async function deleteProject(id: number, deleteFiles: boolean = false) {

View File

@@ -91,7 +91,10 @@ export async function getUser(id: number) {
});
}
export async function createUser(data: CreateUserData) {
export async function createUser(
data: CreateUserData,
callerRoleName?: string,
) {
const username = data.username.trim();
const email = data.email.trim();
const firstName = data.first_name.trim();
@@ -113,6 +116,15 @@ export async function createUser(data: CreateUserData) {
return { error: "E-mail již existuje", status: 409 } as const;
}
if (data.role_id) {
const targetRole = await prisma.roles.findUnique({
where: { id: Number(data.role_id) },
});
if (targetRole?.name === "admin" && callerRoleName !== "admin") {
return { error: "Nelze přiřadit roli admin", status: 403 } as const;
}
}
const passwordHash = await bcrypt.hash(
data.password,
config.security.bcryptCost,
@@ -133,12 +145,71 @@ export async function createUser(data: CreateUserData) {
return { user } as const;
}
export async function updateUser(id: number, body: UpdateUserData) {
export async function updateUser(
id: number,
body: UpdateUserData,
callerRoleName?: string,
) {
const existing = await prisma.users.findUnique({ where: { id } });
if (!existing) {
return { error: "Uživatel nenalezen", status: 404 } as const;
}
if (body.role_id !== undefined) {
if (body.role_id) {
const targetRole = await prisma.roles.findUnique({
where: { id: Number(body.role_id) },
});
if (targetRole?.name === "admin" && callerRoleName !== "admin") {
return { error: "Nelze přiřadit roli admin", status: 403 } as const;
}
}
if (
existing.role_id !== null &&
Number(body.role_id) !== existing.role_id
) {
const currentRole = await prisma.roles.findUnique({
where: { id: existing.role_id },
});
if (currentRole?.name === "admin") {
const adminRole = await prisma.roles.findFirst({
where: { name: "admin" },
});
if (adminRole) {
const activeAdminCount = await prisma.users.count({
where: { role_id: adminRole.id, is_active: true },
});
if (activeAdminCount <= 1) {
return {
error: "Nelze odebrat roli poslednímu aktivnímu administrátorovi",
status: 400,
} as const;
}
}
}
}
}
if (body.is_active !== undefined && !body.is_active) {
if (existing.role_id !== null && existing.is_active) {
const adminRole = await prisma.roles.findFirst({
where: { name: "admin" },
});
if (adminRole && existing.role_id === adminRole.id) {
const activeAdminCount = await prisma.users.count({
where: { role_id: adminRole.id, is_active: true },
});
if (activeAdminCount <= 1) {
return {
error: "Nelze deaktivovat posledního aktivního administrátora",
status: 400,
} as const;
}
}
}
}
const data: Record<string, unknown> = {};
if (body.username !== undefined) {