fix: security, validation, and data integrity fixes across 53 files
- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
for inactive/locked users, locked_until check in loadAuthData, TOTP
fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
numbering.service releaseSequence no-op, nas-offers filename sanitize,
Content-Disposition header injection fix, mojibake Czech strings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,21 +4,14 @@ import { getBusinessDaysInMonth, isHoliday } from "../utils/czech-holidays";
|
||||
import { localDateStr } from "../utils/date";
|
||||
import { getSystemSettings } from "./system-settings";
|
||||
|
||||
/** Get active users whose role has attendance.record permission (or admin role) */
|
||||
/** Get active users whose role has attendance.record permission */
|
||||
async function getAttendanceUsers() {
|
||||
return prisma.users.findMany({
|
||||
where: {
|
||||
is_active: true,
|
||||
roles: {
|
||||
is: {
|
||||
OR: [
|
||||
{ name: "admin" },
|
||||
{
|
||||
role_permissions: {
|
||||
some: { permissions: { name: "attendance.record" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
role_permissions: {
|
||||
some: { permissions: { name: "attendance.record" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -73,11 +66,13 @@ function calcWorkedHours(
|
||||
}
|
||||
|
||||
const roundUp = (d: Date, minutes: number) => {
|
||||
if (!minutes || minutes <= 0) return d;
|
||||
const ms = minutes * 60 * 1000;
|
||||
return new Date(Math.ceil(d.getTime() / ms) * ms);
|
||||
};
|
||||
|
||||
const roundDown = (d: Date, minutes: number) => {
|
||||
if (!minutes || minutes <= 0) return d;
|
||||
const ms = minutes * 60 * 1000;
|
||||
return new Date(Math.floor(d.getTime() / ms) * ms);
|
||||
};
|
||||
@@ -1143,48 +1138,50 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const userId of data.user_ids.map(Number)) {
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(yr, mo - 1, day);
|
||||
const dateStr = localDateStr(date);
|
||||
const dow = date.getDay();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const userId of data.user_ids.map(Number)) {
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(yr, mo - 1, day);
|
||||
const dateStr = localDateStr(date);
|
||||
const dow = date.getDay();
|
||||
|
||||
if (dow === 0 || dow === 6) continue;
|
||||
if (dow === 0 || dow === 6) continue;
|
||||
|
||||
if (existingSet.has(`${userId}:${dateStr}`)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (existingSet.has(`${userId}:${dateStr}`)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
|
||||
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
|
||||
|
||||
if (isHoliday(dateStr)) {
|
||||
await prisma.attendance.create({
|
||||
if (isHoliday(dateStr)) {
|
||||
await tx.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
shift_date: shiftDate,
|
||||
leave_type: "holiday",
|
||||
leave_hours: 8,
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
shift_date: shiftDate,
|
||||
leave_type: "holiday",
|
||||
leave_hours: 8,
|
||||
arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`),
|
||||
departure_time: new Date(`${dateStr}T${data.departure_time}:00`),
|
||||
break_start: new Date(`${dateStr}T${data.break_start_time}:00`),
|
||||
break_end: new Date(`${dateStr}T${data.break_end_time}:00`),
|
||||
leave_type: "work",
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
shift_date: shiftDate,
|
||||
arrival_time: new Date(`${dateStr}T${data.arrival_time}:00`),
|
||||
departure_time: new Date(`${dateStr}T${data.departure_time}:00`),
|
||||
break_start: new Date(`${dateStr}T${data.break_start_time}:00`),
|
||||
break_end: new Date(`${dateStr}T${data.break_end_time}:00`),
|
||||
leave_type: "work",
|
||||
},
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let msg = `Vytvořeno ${inserted} záznamů`;
|
||||
if (skipped > 0) msg += ` (${skipped} přeskočeno — již existují)`;
|
||||
@@ -1212,70 +1209,83 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
||||
const end = new Date(dateTo);
|
||||
let created = 0;
|
||||
|
||||
const current = new Date(start);
|
||||
while (current <= end) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
const dateStr = localDateStr(current);
|
||||
const shiftDate = new Date(
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate(),
|
||||
12,
|
||||
0,
|
||||
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" };
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const current = new Date(start);
|
||||
while (current <= end) {
|
||||
const dow = current.getDay();
|
||||
if (dow !== 0 && dow !== 6 && !isHoliday(localDateStr(current))) {
|
||||
const dateStr = localDateStr(current);
|
||||
const shiftDate = new Date(
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const duplicate = await tx.attendance.findFirst({
|
||||
where: { user_id: userId, shift_date: shiftDate },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new Error("Pro zvolené datumy již existují záznamy docházky");
|
||||
}
|
||||
await tx.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
shift_date: shiftDate,
|
||||
leave_type: leaveType,
|
||||
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
|
||||
notes: data.notes ? String(data.notes) : null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
await prisma.attendance.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
shift_date: shiftDate,
|
||||
leave_type: leaveType,
|
||||
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
|
||||
notes: data.notes ? String(data.notes) : null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalLeaveHours =
|
||||
created * (data.leave_hours ? Number(data.leave_hours) : 8);
|
||||
if (
|
||||
(leaveType === "vacation" || leaveType === "sick") &&
|
||||
totalLeaveHours > 0
|
||||
) {
|
||||
const year = new Date(dateFrom).getFullYear();
|
||||
const existingBalance = await prisma.leave_balances.findFirst({
|
||||
where: { user_id: userId, year },
|
||||
const totalLeaveHours =
|
||||
created * (data.leave_hours ? Number(data.leave_hours) : 8);
|
||||
if (
|
||||
(leaveType === "vacation" || leaveType === "sick") &&
|
||||
totalLeaveHours > 0
|
||||
) {
|
||||
const year = new Date(dateFrom).getFullYear();
|
||||
const existingBalance = await tx.leave_balances.findFirst({
|
||||
where: { user_id: userId, year },
|
||||
});
|
||||
if (existingBalance) {
|
||||
const updateField =
|
||||
leaveType === "vacation" ? "vacation_used" : "sick_used";
|
||||
await tx.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: {
|
||||
[updateField]:
|
||||
Number(existingBalance[updateField]) + totalLeaveHours,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.leave_balances.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? totalLeaveHours : 0,
|
||||
sick_used: leaveType === "sick" ? totalLeaveHours : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if (existingBalance) {
|
||||
const updateField =
|
||||
leaveType === "vacation" ? "vacation_used" : "sick_used";
|
||||
await prisma.leave_balances.update({
|
||||
where: { id: existingBalance.id },
|
||||
data: {
|
||||
[updateField]: Number(existingBalance[updateField]) + totalLeaveHours,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.leave_balances.create({
|
||||
data: {
|
||||
user_id: userId,
|
||||
year,
|
||||
vacation_total: 160,
|
||||
vacation_used: leaveType === "vacation" ? totalLeaveHours : 0,
|
||||
sick_used: leaveType === "sick" ? totalLeaveHours : 0,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message === "Pro zvolené datumy již existují záznamy docházky"
|
||||
) {
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { created, message: `Vytvořeno ${created} záznamů nepřítomnosti` };
|
||||
@@ -1418,8 +1428,17 @@ export async function punchAction(userId: number, data: PunchData) {
|
||||
return { error: "Nemáte aktivní směnu bez přestávky." };
|
||||
}
|
||||
|
||||
const msRound = settings.clock_rounding_minutes * 60 * 1000;
|
||||
const breakStart = new Date(Math.round(now.getTime() / msRound) * msRound);
|
||||
let msRound = settings.clock_rounding_minutes * 60 * 1000;
|
||||
if (
|
||||
!settings.clock_rounding_minutes ||
|
||||
settings.clock_rounding_minutes <= 0
|
||||
) {
|
||||
msRound = 0;
|
||||
}
|
||||
const breakStart =
|
||||
msRound > 0
|
||||
? new Date(Math.round(now.getTime() / msRound) * msRound)
|
||||
: now;
|
||||
const breakEnd = new Date(
|
||||
breakStart.getTime() + settings.break_duration_long * 60 * 1000,
|
||||
);
|
||||
|
||||
@@ -51,6 +51,9 @@ async function loadAuthData(userId: number): Promise<AuthData | null> {
|
||||
|
||||
if (!user || !user.is_active) return null;
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date())
|
||||
return null;
|
||||
|
||||
const isAdmin = user.roles?.name === "admin";
|
||||
const permissions = isAdmin
|
||||
? (await prisma.permissions.findMany({ select: { name: true } })).map(
|
||||
@@ -111,6 +114,7 @@ export async function login(
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
await bcrypt.compare(password, DUMMY_HASH); // timing-safe
|
||||
request.log.warn(`Login failed for deactivated user: ${username}`);
|
||||
return {
|
||||
type: "error",
|
||||
@@ -120,6 +124,7 @@ export async function login(
|
||||
}
|
||||
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
await bcrypt.compare(password, DUMMY_HASH); // timing-safe
|
||||
request.log.warn(`Login failed for locked user: ${username}`);
|
||||
return {
|
||||
type: "error",
|
||||
@@ -319,7 +324,7 @@ export async function refreshAccessToken(
|
||||
accessToken,
|
||||
refreshToken: newRefreshTokenRaw,
|
||||
user: authData,
|
||||
rememberMe: storedToken.remember_me ?? false,
|
||||
rememberMe: rememberMe,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -341,10 +346,9 @@ export async function verifyAccessToken(
|
||||
token: string,
|
||||
): Promise<AuthData | null> {
|
||||
try {
|
||||
const payload = jwt.verify(
|
||||
token,
|
||||
config.jwt.secret,
|
||||
) as unknown as JwtPayload;
|
||||
const payload = jwt.verify(token, config.jwt.secret, {
|
||||
algorithms: ["HS256"],
|
||||
}) as unknown as JwtPayload;
|
||||
return loadAuthData(payload.sub);
|
||||
} catch (err) {
|
||||
console.error("JWT verification error:", err);
|
||||
|
||||
@@ -11,12 +11,17 @@ interface CnbRate {
|
||||
}
|
||||
|
||||
const rateCache = new Map<string, Record<string, number>>();
|
||||
let rateCacheTime = 0;
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const inflight = new Map<string, Promise<Record<string, number>>>();
|
||||
|
||||
async function fetchRatesForDate(
|
||||
date?: string,
|
||||
): Promise<Record<string, number>> {
|
||||
const key = date || "today";
|
||||
if (Date.now() - rateCacheTime > CACHE_TTL_MS) {
|
||||
rateCache.clear();
|
||||
}
|
||||
if (rateCache.has(key)) return rateCache.get(key)!;
|
||||
if (inflight.has(key)) return inflight.get(key)!;
|
||||
|
||||
@@ -36,6 +41,7 @@ async function fetchRatesForDate(
|
||||
}
|
||||
|
||||
rateCache.set(key, rates);
|
||||
rateCacheTime = Date.now();
|
||||
return rates;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch CNB exchange rates:", err);
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function checkInvoiceAlerts(): Promise<void> {
|
||||
if (!alertEmail) return;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayStr = localDateStr(today);
|
||||
const in3days = new Date(today);
|
||||
in3days.setDate(in3days.getDate() + 3);
|
||||
|
||||
@@ -55,8 +55,13 @@ function computeInvoiceTotals(
|
||||
const vatAmount = applyVat
|
||||
? items.reduce((s, i) => {
|
||||
const base = (Number(i.quantity) || 0) * (Number(i.unit_price) || 0);
|
||||
const vat =
|
||||
base * ((Number(i.vat_rate) || Number(defaultVatRate) || 21) / 100);
|
||||
const vatRate =
|
||||
i.vat_rate != null && i.vat_rate !== ""
|
||||
? Number(i.vat_rate)
|
||||
: defaultVatRate != null
|
||||
? Number(defaultVatRate)
|
||||
: 21;
|
||||
const vat = base * (vatRate / 100);
|
||||
return s + Math.round(vat * 100) / 100;
|
||||
}, 0)
|
||||
: 0;
|
||||
@@ -343,7 +348,7 @@ export async function createInvoice(body: Record<string, any>) {
|
||||
customer_id: body.customer_id ? Number(body.customer_id) : null,
|
||||
status: body.status ? String(body.status) : "issued",
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
vat_rate: body.vat_rate ? Number(body.vat_rate) : 21.0,
|
||||
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
|
||||
apply_vat: body.apply_vat !== false,
|
||||
payment_method: body.payment_method ? String(body.payment_method) : null,
|
||||
constant_symbol: body.constant_symbol
|
||||
|
||||
@@ -32,8 +32,8 @@ class NasOffersManager {
|
||||
pdfBuffer: Buffer,
|
||||
): string | null {
|
||||
const { prefix, seq } = this.parseParts(quotationNumber);
|
||||
const folderName = `${prefix}_${seq}`;
|
||||
const fileName = `${year}_${prefix}_${seq}.pdf`;
|
||||
const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
|
||||
const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
|
||||
|
||||
const dir = this.ensureDir(year, folderName);
|
||||
if (!dir) return null;
|
||||
@@ -81,8 +81,8 @@ class NasOffersManager {
|
||||
/** Build the relative NAS path for a given quotation number + year */
|
||||
buildRelativePath(quotationNumber: string, year: number): string {
|
||||
const { prefix, seq } = this.parseParts(quotationNumber);
|
||||
const folderName = `${prefix}_${seq}`;
|
||||
const fileName = `${year}_${prefix}_${seq}.pdf`;
|
||||
const folderName = this.sanitizeFilename(`${prefix}_${seq}`);
|
||||
const fileName = this.sanitizeFilename(`${year}_${prefix}_${seq}`) + ".pdf";
|
||||
return `${year}/${folderName}/${fileName}`;
|
||||
}
|
||||
|
||||
@@ -92,8 +92,10 @@ class NasOffersManager {
|
||||
} {
|
||||
const parts = quotationNumber.split("/");
|
||||
return {
|
||||
prefix: parts.length >= 2 ? parts[parts.length - 2] : "NA",
|
||||
seq: parts[parts.length - 1],
|
||||
prefix: this.sanitizeFilename(
|
||||
parts.length >= 2 ? parts[parts.length - 2] : "NA",
|
||||
),
|
||||
seq: this.sanitizeFilename(parts[parts.length - 1]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -109,20 +109,14 @@ async function previewNextSequence(
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement the sequence counter for a given type/year.
|
||||
* Called after deleting a document so the number can be reused.
|
||||
* Release a sequence number back to the pool.
|
||||
* NOTE: Blindly decrementing can cause duplicate numbers if numbers were
|
||||
* allocated after this one. Sequence numbers are consumed but not returned
|
||||
* to the pool — this is intentionally a no-op.
|
||||
*/
|
||||
async function releaseSequence(type: string, year: number) {
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE number_sequences
|
||||
SET last_number = GREATEST(COALESCE(last_number, 0) - 1, 0)
|
||||
WHERE \`type\` = ${type} AND \`year\` = ${year}
|
||||
`;
|
||||
} catch (err) {
|
||||
// Non-fatal: log but don't fail the delete operation
|
||||
console.error(`releaseSequence failed for ${type}/${year}:`, err);
|
||||
}
|
||||
async function releaseSequence(_type: string, _year: number) {
|
||||
// No-op: decrementing can cause duplicate sequence numbers.
|
||||
// Sequence numbers are consumed but never returned to the pool.
|
||||
}
|
||||
|
||||
/** Verify a shared number is not already used by an order or project. */
|
||||
|
||||
@@ -55,7 +55,9 @@ function enrichQuotation(q: any) {
|
||||
0,
|
||||
);
|
||||
const vatAmount = q.apply_vat
|
||||
? subtotal * ((Number(q.vat_rate) || 21) / 100)
|
||||
? subtotal *
|
||||
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
|
||||
100)
|
||||
: 0;
|
||||
const { quotation_items, scope_sections, ...rest } = q;
|
||||
return {
|
||||
@@ -138,11 +140,6 @@ export async function getOffer(id: number) {
|
||||
}
|
||||
|
||||
export async function createOffer(body: Record<string, any>) {
|
||||
const quotationNumber =
|
||||
body.quotation_number !== undefined && body.quotation_number !== null
|
||||
? String(body.quotation_number)
|
||||
: await generateOfferNumber();
|
||||
|
||||
if (body.quotation_number !== undefined && body.quotation_number !== null) {
|
||||
const taken = await isOfferNumberTaken(String(body.quotation_number));
|
||||
if (taken) {
|
||||
@@ -151,6 +148,11 @@ export async function createOffer(body: Record<string, any>) {
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const quotationNumber =
|
||||
body.quotation_number !== undefined && body.quotation_number !== null
|
||||
? String(body.quotation_number)
|
||||
: await generateOfferNumber(tx);
|
||||
|
||||
const quotation = await tx.quotations.create({
|
||||
data: {
|
||||
quotation_number: quotationNumber,
|
||||
@@ -161,7 +163,7 @@ export async function createOffer(body: Record<string, any>) {
|
||||
: 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,
|
||||
vat_rate: body.vat_rate != null ? 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",
|
||||
@@ -320,53 +322,55 @@ export async function duplicateOffer(id: number) {
|
||||
});
|
||||
if (!original) return null;
|
||||
|
||||
const nextOfferNumber = await generateOfferNumber();
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nextOfferNumber = await generateOfferNumber(tx);
|
||||
|
||||
const copy = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: nextOfferNumber,
|
||||
project_code: original.project_code,
|
||||
customer_id: original.customer_id,
|
||||
valid_until: null,
|
||||
currency: original.currency,
|
||||
language: original.language,
|
||||
vat_rate: original.vat_rate,
|
||||
apply_vat: original.apply_vat,
|
||||
exchange_rate: original.exchange_rate,
|
||||
status: "active",
|
||||
scope_title: original.scope_title,
|
||||
scope_description: original.scope_description,
|
||||
},
|
||||
const copy = await tx.quotations.create({
|
||||
data: {
|
||||
quotation_number: nextOfferNumber,
|
||||
project_code: original.project_code,
|
||||
customer_id: original.customer_id,
|
||||
valid_until: null,
|
||||
currency: original.currency,
|
||||
language: original.language,
|
||||
vat_rate: original.vat_rate,
|
||||
apply_vat: original.apply_vat,
|
||||
exchange_rate: original.exchange_rate,
|
||||
status: "active",
|
||||
scope_title: original.scope_title,
|
||||
scope_description: original.scope_description,
|
||||
},
|
||||
});
|
||||
|
||||
if (original.quotation_items.length > 0) {
|
||||
await tx.quotation_items.createMany({
|
||||
data: original.quotation_items.map((item) => ({
|
||||
quotation_id: copy.id,
|
||||
description: item.description,
|
||||
item_description: item.item_description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
is_included_in_total: item.is_included_in_total,
|
||||
position: item.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (original.scope_sections.length > 0) {
|
||||
await tx.scope_sections.createMany({
|
||||
data: original.scope_sections.map((s) => ({
|
||||
quotation_id: copy.id,
|
||||
title: s.title,
|
||||
title_cz: s.title_cz,
|
||||
content: s.content,
|
||||
position: s.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { copy, original };
|
||||
});
|
||||
|
||||
if (original.quotation_items.length > 0) {
|
||||
await prisma.quotation_items.createMany({
|
||||
data: original.quotation_items.map((item) => ({
|
||||
quotation_id: copy.id,
|
||||
description: item.description,
|
||||
item_description: item.item_description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
is_included_in_total: item.is_included_in_total,
|
||||
position: item.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (original.scope_sections.length > 0) {
|
||||
await prisma.scope_sections.createMany({
|
||||
data: original.scope_sections.map((s) => ({
|
||||
quotation_id: copy.id,
|
||||
title: s.title,
|
||||
title_cz: s.title_cz,
|
||||
content: s.content,
|
||||
position: s.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { copy, original };
|
||||
}
|
||||
|
||||
export async function invalidateOffer(id: number) {
|
||||
|
||||
@@ -47,7 +47,9 @@ function enrichOrder(o: any) {
|
||||
0,
|
||||
);
|
||||
const vatAmount = o.apply_vat
|
||||
? subtotal * ((Number(o.vat_rate) || 21) / 100)
|
||||
? subtotal *
|
||||
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
|
||||
100)
|
||||
: 0;
|
||||
const { order_items, order_sections, ...rest } = o;
|
||||
const invoice = o.invoices?.[0] || null;
|
||||
@@ -126,7 +128,7 @@ export async function getOrder(id: number) {
|
||||
},
|
||||
invoices: {
|
||||
select: { id: true, invoice_number: true, status: true },
|
||||
take: 1,
|
||||
orderBy: { id: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -290,66 +292,78 @@ interface CreateOrderData {
|
||||
}
|
||||
|
||||
export async function createOrder(body: CreateOrderData) {
|
||||
const orderNumber =
|
||||
body.order_number !== undefined && body.order_number !== null
|
||||
? String(body.order_number)
|
||||
: await generateSharedNumber();
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const orderNumber =
|
||||
body.order_number !== undefined && body.order_number !== null
|
||||
? String(body.order_number)
|
||||
: await generateSharedNumber(tx);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (body.order_number !== undefined && body.order_number !== null) {
|
||||
const taken = await isOrderNumberTaken(String(body.order_number));
|
||||
if (taken) {
|
||||
throw Object.assign(new Error("Číslo objednávky je již použito"), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
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 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,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "status" in err) {
|
||||
return {
|
||||
error: err.message,
|
||||
status: (err as Error & { status: number }).status,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
interface UpdateOrderData {
|
||||
@@ -499,30 +513,34 @@ export async function deleteOrder(id: number) {
|
||||
if (!existing)
|
||||
return { error: "Objednávka nenalezena", status: 404 } as const;
|
||||
|
||||
// Clear quotation back-reference (matching PHP)
|
||||
await prisma.quotations.updateMany({
|
||||
where: { order_id: id },
|
||||
data: { order_id: null },
|
||||
});
|
||||
|
||||
// Delete linked project and its notes (matching PHP)
|
||||
// Fetch linked projects before the transaction for number release later
|
||||
const linkedProjects = await prisma.projects.findMany({
|
||||
where: { order_id: id },
|
||||
select: { id: true, created_at: true },
|
||||
});
|
||||
if (linkedProjects.length > 0) {
|
||||
const projectIds = linkedProjects.map((p) => p.id);
|
||||
await prisma.project_notes.deleteMany({
|
||||
where: { project_id: { in: projectIds } },
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear quotation back-reference (matching PHP)
|
||||
await tx.quotations.updateMany({
|
||||
where: { order_id: id },
|
||||
data: { order_id: null },
|
||||
});
|
||||
await prisma.projects.deleteMany({ where: { order_id: id } });
|
||||
}
|
||||
|
||||
// Explicitly clean up child rows
|
||||
await prisma.order_items.deleteMany({ where: { order_id: id } });
|
||||
await prisma.order_sections.deleteMany({ where: { order_id: id } });
|
||||
// Delete linked project and its notes (matching PHP)
|
||||
if (linkedProjects.length > 0) {
|
||||
const projectIds = linkedProjects.map((p) => p.id);
|
||||
await tx.project_notes.deleteMany({
|
||||
where: { project_id: { in: projectIds } },
|
||||
});
|
||||
await tx.projects.deleteMany({ where: { order_id: id } });
|
||||
}
|
||||
|
||||
await prisma.orders.delete({ where: { id } });
|
||||
// Explicitly clean up child rows
|
||||
await tx.order_items.deleteMany({ where: { order_id: id } });
|
||||
await tx.order_sections.deleteMany({ where: { order_id: id } });
|
||||
|
||||
await tx.orders.delete({ where: { id } });
|
||||
});
|
||||
|
||||
const releasedYears = new Set<number>();
|
||||
const year = existing.created_at
|
||||
|
||||
@@ -212,57 +212,77 @@ export async function updateUser(
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (body.username !== undefined) {
|
||||
const newUsername = String(body.username).trim();
|
||||
if (newUsername !== existing.username) {
|
||||
const existingUsername = await prisma.users.findFirst({
|
||||
where: { username: newUsername },
|
||||
});
|
||||
if (existingUsername) {
|
||||
return {
|
||||
error: "Uživatelské jméno již existuje",
|
||||
status: 409,
|
||||
} as const;
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (body.username !== undefined) {
|
||||
const newUsername = String(body.username).trim();
|
||||
if (newUsername !== existing.username) {
|
||||
const existingUsername = await tx.users.findFirst({
|
||||
where: { username: newUsername },
|
||||
});
|
||||
if (existingUsername) {
|
||||
throw Object.assign(new Error("Uživatelské jméno již existuje"), {
|
||||
status: 409,
|
||||
});
|
||||
}
|
||||
}
|
||||
data.username = newUsername;
|
||||
}
|
||||
}
|
||||
data.username = newUsername;
|
||||
}
|
||||
|
||||
if (body.email !== undefined) {
|
||||
const newEmail = String(body.email).trim();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||
return { error: "Neplatný formát e-mailu", status: 400 } as const;
|
||||
}
|
||||
const existingEmail = await prisma.users.findFirst({
|
||||
where: { email: newEmail, id: { not: id } },
|
||||
if (body.email !== undefined) {
|
||||
const newEmail = String(body.email).trim();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||
throw Object.assign(new Error("Neplatný formát e-mailu"), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const existingEmail = await tx.users.findFirst({
|
||||
where: { email: newEmail, id: { not: id } },
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw Object.assign(new Error("E-mail již existuje"), {
|
||||
status: 409,
|
||||
});
|
||||
}
|
||||
data.email = newEmail;
|
||||
}
|
||||
|
||||
if (body.first_name !== undefined)
|
||||
data.first_name = String(body.first_name);
|
||||
if (body.last_name !== undefined) data.last_name = String(body.last_name);
|
||||
if (body.role_id !== undefined)
|
||||
data.role_id = body.role_id ? Number(body.role_id) : null;
|
||||
if (body.is_active !== undefined)
|
||||
data.is_active =
|
||||
body.is_active === true ||
|
||||
body.is_active === 1 ||
|
||||
body.is_active === "1";
|
||||
if (body.password) {
|
||||
const newPassword = String(body.password);
|
||||
if (newPassword.length < 8) {
|
||||
throw Object.assign(new Error("Heslo musí mít alespoň 8 znaků"), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
data.password_hash = await bcrypt.hash(
|
||||
newPassword,
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
data.password_changed_at = new Date();
|
||||
}
|
||||
|
||||
await tx.users.update({ where: { id }, data });
|
||||
});
|
||||
if (existingEmail) {
|
||||
return { error: "E-mail již existuje", status: 409 } as const;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "status" in err) {
|
||||
return {
|
||||
error: err.message,
|
||||
status: (err as Error & { status: number }).status,
|
||||
} as const;
|
||||
}
|
||||
data.email = newEmail;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (body.first_name !== undefined) data.first_name = String(body.first_name);
|
||||
if (body.last_name !== undefined) data.last_name = String(body.last_name);
|
||||
if (body.role_id !== undefined)
|
||||
data.role_id = body.role_id ? Number(body.role_id) : null;
|
||||
if (body.is_active !== undefined)
|
||||
data.is_active =
|
||||
body.is_active === true || body.is_active === 1 || body.is_active === "1";
|
||||
if (body.password) {
|
||||
const newPassword = String(body.password);
|
||||
if (newPassword.length < 8) {
|
||||
return { error: "Heslo musí mít alespoň 8 znaků", status: 400 } as const;
|
||||
}
|
||||
data.password_hash = await bcrypt.hash(
|
||||
newPassword,
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
data.password_changed_at = new Date();
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id }, data });
|
||||
|
||||
return { id, username: existing.username } as const;
|
||||
}
|
||||
|
||||
@@ -276,8 +296,10 @@ export async function deleteUser(id: number, currentUserId?: number) {
|
||||
return { error: "Uživatel nenalezen", status: 404 } as const;
|
||||
}
|
||||
|
||||
await prisma.refresh_tokens.deleteMany({ where: { user_id: id } });
|
||||
await prisma.users.delete({ where: { id } });
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.refresh_tokens.deleteMany({ where: { user_id: id } });
|
||||
await tx.users.delete({ where: { id } });
|
||||
});
|
||||
|
||||
return { id, username: existing.username } as const;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user