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:
BOHA
2026-04-28 08:40:38 +02:00
parent 7f07032bf2
commit d7c7fbad88
52 changed files with 927 additions and 573 deletions

View File

@@ -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,
);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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]),
};
}

View File

@@ -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. */

View File

@@ -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) {

View File

@@ -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

View File

@@ -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;
}