fix: 2026-06-09 full-codebase audit hardening

Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -29,6 +29,9 @@ type AttendanceWithRelations = Prisma.attendanceGetPayload<{
const VALID_LEAVE_TYPES = ["work", "vacation", "sick", "unpaid"] as const;
/** Default annual vacation allowance (hours) when a user has no leave_balances row. */
const DEFAULT_VACATION_TOTAL = 160;
const MONTH_NAMES = [
"Leden",
"Únor",
@@ -82,7 +85,10 @@ async function lockUserRow(
tx: Prisma.TransactionClient,
userId: number,
): Promise<void> {
await tx.$executeRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
// Must be $queryRaw (not $executeRaw): $executeRaw is for write statements
// and may not reliably acquire/hold the row lock for a bare SELECT ... FOR
// UPDATE. $queryRaw runs it as a query so the lock is actually taken.
await tx.$queryRaw`SELECT id FROM ${Prisma.raw("users")} WHERE id = ${userId} FOR UPDATE`;
}
// ── Interfaces ───────────────────────────────────────────────────────
@@ -184,49 +190,58 @@ export async function getStatus(userId: number) {
const todayStart = new Date(y, m, d, 0, 0, 0);
const todayEnd = new Date(y, m, d, 23, 59, 59);
// 1) Ongoing shift (no departure)
const ongoingShift = await prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
});
// Monthly fund range (used by query #4)
const monthStart = new Date(y, m, 1);
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
// 2) Today's completed shifts
const todayShiftsRaw = await prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: todayStart, lte: todayEnd },
departure_time: { not: null },
},
include: {
attendance_project_logs: { orderBy: { started_at: "asc" } },
},
orderBy: { arrival_time: "asc" },
});
// Queries 1-4 are independent of one another → run them in parallel.
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
await Promise.all([
// 1) Ongoing shift (no departure)
prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
orderBy: { created_at: "desc" },
}),
// 2) Today's completed shifts
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: todayStart, lte: todayEnd },
departure_time: { not: null },
},
include: {
attendance_project_logs: { orderBy: { started_at: "asc" } },
},
orderBy: { arrival_time: "asc" },
}),
// 3) Leave balance
prisma.leave_balances.findFirst({
where: { user_id: userId, year: y },
}),
// 4) Monthly fund records
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: monthStart, lte: monthEnd },
},
}),
]);
// 3) Leave balance
const balance = await prisma.leave_balances.findFirst({
where: { user_id: userId, year: y },
});
const leaveBalance = {
vacation_total: balance ? Number(balance.vacation_total) : 160,
vacation_total: balance
? Number(balance.vacation_total)
: DEFAULT_VACATION_TOTAL,
vacation_used: balance ? Number(balance.vacation_used) : 0,
vacation_remaining: balance
? Number(balance.vacation_total) - Number(balance.vacation_used)
: 160,
: DEFAULT_VACATION_TOTAL,
sick_used: balance ? Number(balance.sick_used) : 0,
};
// 4) Monthly fund
const monthStart = new Date(y, m, 1);
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
const monthRecords = await prisma.attendance.findMany({
where: { user_id: userId, shift_date: { gte: monthStart, lte: monthEnd } },
});
const workingDays = getBusinessDaysInMonth(y, m);
const fund = workingDays * 8;
@@ -487,11 +502,19 @@ export async function getBalances(year: number) {
sick_used: number;
}
> = {};
// Single query for every user's balance for the year (was an N+1 loop).
const balanceRows = await prisma.leave_balances.findMany({
where: { year, user_id: { in: users.map((u) => u.id) } },
});
// Keep the first row seen per user (matches the old per-user findFirst).
const balanceByUser = new Map<number, (typeof balanceRows)[number]>();
for (const row of balanceRows) {
if (!balanceByUser.has(row.user_id)) balanceByUser.set(row.user_id, row);
}
for (const u of users) {
const lb = await prisma.leave_balances.findFirst({
where: { user_id: u.id, year },
});
const vTotal = lb ? Number(lb.vacation_total) : 160;
const lb = balanceByUser.get(u.id);
const vTotal = lb ? Number(lb.vacation_total) : DEFAULT_VACATION_TOTAL;
const vUsed = lb ? Number(lb.vacation_used) : 0;
const sUsed = lb ? Number(lb.sick_used) : 0;
balances[String(u.id)] = {
@@ -947,9 +970,10 @@ export async function getPrintData(
for (const bal of balanceRecords) {
const uid = String(bal.user_id);
leaveBalances[uid] = {
vacation_total: Number(bal.vacation_total) || 160,
vacation_total: Number(bal.vacation_total) || DEFAULT_VACATION_TOTAL,
vacation_remaining:
(Number(bal.vacation_total) || 160) - (Number(bal.vacation_used) || 0),
(Number(bal.vacation_total) || DEFAULT_VACATION_TOTAL) -
(Number(bal.vacation_used) || 0),
};
}
@@ -1102,7 +1126,7 @@ export async function handleBalances(data: BalancesData) {
create: {
user_id: data.user_id,
year: yr,
vacation_total: Number(data.vacation_total) || 160,
vacation_total: Number(data.vacation_total) || DEFAULT_VACATION_TOTAL,
vacation_used: Number(data.vacation_used) || 0,
sick_used: Number(data.sick_used) || 0,
},
@@ -1117,7 +1141,7 @@ export async function handleBalances(data: BalancesData) {
create: {
user_id: data.user_id,
year: yr,
vacation_total: 160,
vacation_total: DEFAULT_VACATION_TOTAL,
vacation_used: 0,
sick_used: 0,
},
@@ -1215,15 +1239,20 @@ export async function createLeave(data: LeaveData, authUserId: number) {
const start = new Date(dateFrom);
const end = new Date(dateTo);
const perDayHours = data.leave_hours ? Number(data.leave_hours) : 8;
let created = 0;
try {
await prisma.$transaction(async (tx) => {
// A leave range can span a year boundary (e.g. Dec → Jan). Each day's
// hours must be booked against ITS OWN year's balance, so accumulate
// per calendar year rather than attributing the whole range to the
// start year.
const hoursByYear = new Map<number, number>();
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(),
@@ -1243,46 +1272,47 @@ export async function createLeave(data: LeaveData, authUserId: number) {
user_id: userId,
shift_date: shiftDate,
leave_type: leaveType,
leave_hours: data.leave_hours ? Number(data.leave_hours) : 8,
leave_hours: perDayHours,
notes: data.notes ? String(data.notes) : null,
},
});
created++;
const dayYear = current.getFullYear();
hoursByYear.set(
dayYear,
(hoursByYear.get(dayYear) ?? 0) + perDayHours,
);
}
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 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 (leaveType === "vacation" || leaveType === "sick") {
const updateField =
leaveType === "vacation" ? "vacation_used" : "sick_used";
for (const [year, yearHours] of hoursByYear) {
if (yearHours <= 0) continue;
const existingBalance = await tx.leave_balances.findFirst({
where: { user_id: userId, year },
});
if (existingBalance) {
await tx.leave_balances.update({
where: { id: existingBalance.id },
data: {
[updateField]: Number(existingBalance[updateField]) + yearHours,
updated_at: new Date(),
},
});
} else {
await tx.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: DEFAULT_VACATION_TOTAL,
vacation_used: leaveType === "vacation" ? yearHours : 0,
sick_used: leaveType === "sick" ? yearHours : 0,
},
});
}
}
}
});
@@ -1691,10 +1721,25 @@ export async function updateAttendance(
return { id };
}
export async function deleteAttendance(id: number) {
export async function deleteAttendance(
id: number,
requesterId?: number,
isAdmin?: boolean,
) {
const existing = await prisma.attendance.findUnique({ where: { id } });
if (!existing) return { error: "Záznam nenalezen" };
// Ownership is enforced ONLY when a requester is supplied, keeping the
// signature backward compatible (existing callers pass nothing and skip
// the check, behaving exactly as before).
if (
requesterId !== undefined &&
!isAdmin &&
existing.user_id !== requesterId
) {
return { error: "Nemáte oprávnění smazat tento záznam", status: 403 };
}
await prisma.attendance.delete({ where: { id } });
return { success: true };
}