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