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:
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user