refactor: fix all Low findings from FLAWS_REPORT audit
- Auth: TOTP params from config, JWT error logging, audit log failure logging, replaced_by_hash validation on token rotation - Invoices: remove dead VAT code, consistent PDF permissions, WebP magic-byte detection, deduped exchange-rate fetches - Orders/Offers: multipart limit from config, use paginated() helper, payment method from DB in PDF - Projects: verify project exists before creating note - Attendance: action_type enum validation, consistent local-time shift_date construction, holiday attendance in work fund, trips.view permission on last-km query - Users: paginated() helper usage, remove duplicate dashboard keys, parallel currency conversion, single hashToken implementation - Frontend: memoized customInput, reliable print onload, modal prop standardization (isOpen), ConfirmModal type icons, id===0 key fallback, Login useCallback, CompanySettings ConfirmModal, Attendance timeout cleanup, Dashboard memoization, beforeunload dirty-state warnings on Invoice/Offer/Order detail - Schema: invoice_alert_log timestamp, config/env comment on Date.prototype.toJSON override - Utils: exchange-rate inflight dedup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -577,6 +577,7 @@ export async function getWorkfund(year: number) {
|
||||
let worked = 0;
|
||||
let vacationHours = 0;
|
||||
let sickHours = 0;
|
||||
let holidayHours = 0;
|
||||
|
||||
for (const rec of recs) {
|
||||
const lt = (rec.leave_type as string) || "work";
|
||||
@@ -593,12 +594,14 @@ 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") {
|
||||
holidayHours += Number(rec.leave_hours) || 8;
|
||||
}
|
||||
}
|
||||
|
||||
const userFund = fundToDate;
|
||||
const workedRound = Math.round(worked * 10) / 10;
|
||||
const leaveHours = vacationHours + sickHours;
|
||||
const leaveHours = vacationHours + sickHours + holidayHours;
|
||||
const covered = Math.round((worked + leaveHours) * 10) / 10;
|
||||
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
|
||||
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
|
||||
@@ -1153,7 +1156,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shiftDate = new Date(Date.UTC(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({
|
||||
@@ -1215,14 +1218,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
const dateStr = localDateStr(current);
|
||||
const shiftDate = new Date(
|
||||
Date.UTC(
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
current.getFullYear(),
|
||||
current.getMonth(),
|
||||
current.getDate(),
|
||||
12,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
const duplicate = await prisma.attendance.findFirst({
|
||||
where: { user_id: userId, shift_date: shiftDate },
|
||||
@@ -1287,7 +1288,7 @@ export async function punchAction(userId: number, data: PunchData) {
|
||||
const y = now.getFullYear(),
|
||||
m = now.getMonth(),
|
||||
d = now.getDate();
|
||||
const today = new Date(Date.UTC(y, m, d, 12, 0, 0));
|
||||
const today = new Date(y, m, d, 12, 0, 0);
|
||||
|
||||
const gpsLat =
|
||||
data.latitude != null && data.latitude !== ""
|
||||
|
||||
@@ -54,6 +54,16 @@ export async function logAudit(params: {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to write audit log:", err);
|
||||
console.error(
|
||||
"Failed to write audit log:",
|
||||
{
|
||||
action: params.action,
|
||||
entityType: params.entityType,
|
||||
entityId: params.entityId,
|
||||
description: params.description,
|
||||
userId: params.authData?.userId,
|
||||
},
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@ export async function login(
|
||||
data: {
|
||||
user_id: user.id,
|
||||
token_hash: tokenHash,
|
||||
expires_at: new Date(Date.now() + 5 * 60_000), // 5 minutes
|
||||
expires_at: new Date(
|
||||
Date.now() + config.totp.loginTokenExpiryMinutes * 60_000,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -255,16 +257,18 @@ export async function refreshAccessToken(
|
||||
user_id: number;
|
||||
expires_at: Date;
|
||||
replaced_at: Date | null;
|
||||
replaced_by_hash: string | null;
|
||||
remember_me: boolean | null;
|
||||
}>
|
||||
>`
|
||||
SELECT id, user_id, expires_at, replaced_at, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
|
||||
SELECT id, user_id, expires_at, replaced_at, replaced_by_hash, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
|
||||
`;
|
||||
const storedToken = tokens[0] ?? null;
|
||||
|
||||
if (
|
||||
!storedToken ||
|
||||
storedToken.replaced_at ||
|
||||
storedToken.replaced_by_hash ||
|
||||
new Date(storedToken.expires_at) < new Date()
|
||||
) {
|
||||
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
||||
@@ -335,7 +339,8 @@ export async function verifyAccessToken(
|
||||
config.jwt.secret,
|
||||
) as unknown as JwtPayload;
|
||||
return loadAuthData(payload.sub);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("JWT verification error:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,34 +11,43 @@ interface CnbRate {
|
||||
}
|
||||
|
||||
const rateCache = new Map<string, Record<string, number>>();
|
||||
const inflight = new Map<string, Promise<Record<string, number>>>();
|
||||
|
||||
async function fetchRatesForDate(
|
||||
date?: string,
|
||||
): Promise<Record<string, number>> {
|
||||
const key = date || "today";
|
||||
if (rateCache.has(key)) return rateCache.get(key)!;
|
||||
if (inflight.has(key)) return inflight.get(key)!;
|
||||
|
||||
try {
|
||||
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
|
||||
if (date) url += `&date=${date}`;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
|
||||
if (date) url += `&date=${date}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`CNB API: ${response.status}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`CNB API: ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as { rates: CnbRate[] };
|
||||
const rates: Record<string, number> = { CZK: 1 };
|
||||
const data = (await response.json()) as { rates: CnbRate[] };
|
||||
const rates: Record<string, number> = { CZK: 1 };
|
||||
|
||||
for (const r of data.rates) {
|
||||
rates[r.currencyCode] = r.rate / r.amount;
|
||||
for (const r of data.rates) {
|
||||
rates[r.currencyCode] = r.rate / r.amount;
|
||||
}
|
||||
|
||||
rateCache.set(key, rates);
|
||||
return rates;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch CNB exchange rates:", err);
|
||||
if (rateCache.has("today")) return rateCache.get("today")!;
|
||||
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
|
||||
} finally {
|
||||
inflight.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
rateCache.set(key, rates);
|
||||
return rates;
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch CNB exchange rates:", err);
|
||||
if (rateCache.has("today")) return rateCache.get("today")!;
|
||||
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
|
||||
}
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Convert an amount from a given currency to CZK using CNB rates */
|
||||
|
||||
@@ -260,9 +260,6 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
currency,
|
||||
}));
|
||||
let vatCzk = 0;
|
||||
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
|
||||
|
||||
// VAT also needs conversion
|
||||
let vatCzkConverted = 0;
|
||||
for (const [cur, amount] of Object.entries(vatMap)) {
|
||||
|
||||
@@ -220,6 +220,14 @@ export async function createProjectNote(
|
||||
content?: string;
|
||||
},
|
||||
) {
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) {
|
||||
return { error: "Projekt nenalezen" as const, status: 404 };
|
||||
}
|
||||
|
||||
const note = await prisma.project_notes.create({
|
||||
data: {
|
||||
project_id: projectId,
|
||||
|
||||
Reference in New Issue
Block a user