fix(dates): @db.Date filters built from local midnight queried the wrong day
Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to its UTC date part. Under TZ=Europe/Prague a local-midnight boundary (new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of every @db.Date filter in the codebase. New shared helper utcMidnightOfLocalDay() in src/utils/date.ts. Fixed (all previously off by one day): - attendance.service: getStatus today+month windows; getWorkfund (last day of each month was double-counted across months); getPrintData (monthly print included prev month's last day); listAttendance (admin month view included prev month's last day AND dropped the selected month's last day); createAttendance duplicate/overlap validation (checked only the PREVIOUS day - same-day duplicates were never caught, neighbors falsely rejected) - invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired (due==today+3 was outside the fetched window); window computation extracted as computeAlertWindow() + pure tests - invoices.service: month list/totals filter dropped invoices issued on the month's last day; getInvoiceStats month/year bounds; markOverdueInvoices boundary; auto paid_date could store yesterday during 00:00-02:00 - received-invoices auto paid_date, issued-orders default order_date: same night-window write hazard, now via the helper - attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD) +9 regression tests (month boundaries, same-day duplicate, last-day-of-month invoice in list+totals, alert window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -189,12 +189,16 @@ export async function getStatus(userId: number) {
|
||||
const y = now.getFullYear(),
|
||||
m = now.getMonth(),
|
||||
d = now.getDate();
|
||||
const todayStart = new Date(y, m, d, 0, 0, 0);
|
||||
const todayEnd = new Date(y, m, d, 23, 59, 59);
|
||||
// shift_date is @db.Date: Prisma compares it by its UTC date part, so the
|
||||
// range boundaries must be UTC midnights of the LOCAL calendar day. A local
|
||||
// midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window
|
||||
// one day back. Half-open [gte, lt) ranges.
|
||||
const todayStart = new Date(Date.UTC(y, m, d));
|
||||
const todayEnd = new Date(Date.UTC(y, m, d + 1));
|
||||
|
||||
// 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);
|
||||
const monthStart = new Date(Date.UTC(y, m, 1));
|
||||
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
|
||||
|
||||
// Queries 1-4 are independent of one another → run them in parallel.
|
||||
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
|
||||
@@ -212,7 +216,7 @@ export async function getStatus(userId: number) {
|
||||
prisma.attendance.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
shift_date: { gte: todayStart, lte: todayEnd },
|
||||
shift_date: { gte: todayStart, lt: todayEnd },
|
||||
departure_time: { not: null },
|
||||
},
|
||||
include: {
|
||||
@@ -228,7 +232,7 @@ export async function getStatus(userId: number) {
|
||||
prisma.attendance.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
shift_date: { gte: monthStart, lte: monthEnd },
|
||||
shift_date: { gte: monthStart, lt: monthEnd },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -585,10 +589,13 @@ export async function getWorkfund(year: number) {
|
||||
: bizDays;
|
||||
const fund = bizDays * 8;
|
||||
const fundToDate = bizDaysToDate * 8;
|
||||
const monthStart = new Date(year, m, 1);
|
||||
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
|
||||
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight
|
||||
// month boundaries, half-open range. Local midnights would double-count
|
||||
// each previous month's last day.
|
||||
const monthStart = new Date(Date.UTC(year, m, 1));
|
||||
const monthEnd = new Date(Date.UTC(year, m + 1, 1));
|
||||
const monthRecords = await prisma.attendance.findMany({
|
||||
where: { shift_date: { gte: monthStart, lte: monthEnd } },
|
||||
where: { shift_date: { gte: monthStart, lt: monthEnd } },
|
||||
select: {
|
||||
user_id: true,
|
||||
shift_date: true,
|
||||
@@ -846,13 +853,16 @@ export async function getPrintData(
|
||||
const yr = Number(yearStr);
|
||||
const mo = Number(monthNumStr);
|
||||
|
||||
const monthStart = new Date(yr, mo - 1, 1);
|
||||
const monthEnd = new Date(yr, mo, 0, 23, 59, 59);
|
||||
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight month
|
||||
// boundaries, half-open range (local midnights pulled in the previous
|
||||
// month's last day).
|
||||
const monthStart = new Date(Date.UTC(yr, mo - 1, 1));
|
||||
const monthEnd = new Date(Date.UTC(yr, mo, 1));
|
||||
|
||||
const users = await getAttendanceUsers();
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
shift_date: { gte: monthStart, lte: monthEnd },
|
||||
shift_date: { gte: monthStart, lt: monthEnd },
|
||||
};
|
||||
if (filterUserId) where.user_id = filterUserId;
|
||||
|
||||
@@ -1043,9 +1053,12 @@ export async function listAttendance(params: ListAttendanceParams) {
|
||||
where.user_id = params.userId;
|
||||
}
|
||||
if (params.month && params.year) {
|
||||
// shift_date is @db.Date: Prisma compares by UTC date part, so the month
|
||||
// boundaries must be UTC midnights. Local midnights shifted the window a
|
||||
// day back (included prev-month's last day, dropped this month's last day).
|
||||
where.shift_date = {
|
||||
gte: new Date(params.year, params.month - 1, 1),
|
||||
lt: new Date(params.year, params.month, 1),
|
||||
gte: new Date(Date.UTC(params.year, params.month - 1, 1)),
|
||||
lt: new Date(Date.UTC(params.year, params.month, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1527,15 +1540,25 @@ export async function createAttendance(
|
||||
) {
|
||||
const userId = data.user_id ?? authUserId;
|
||||
const shiftDate = new Date(data.shift_date);
|
||||
// shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the
|
||||
// @db.Date column is compared by UTC date part. The duplicate/overlap
|
||||
// window must therefore be the UTC day [shiftDate, shiftDate+1) — building
|
||||
// it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous
|
||||
// day, so the validation queried ONLY yesterday's records (same-day
|
||||
// overlaps undetected, false duplicates when yesterday had records).
|
||||
const startOfDay = new Date(
|
||||
shiftDate.getFullYear(),
|
||||
shiftDate.getMonth(),
|
||||
shiftDate.getDate(),
|
||||
Date.UTC(
|
||||
shiftDate.getUTCFullYear(),
|
||||
shiftDate.getUTCMonth(),
|
||||
shiftDate.getUTCDate(),
|
||||
),
|
||||
);
|
||||
const endOfDay = new Date(
|
||||
shiftDate.getFullYear(),
|
||||
shiftDate.getMonth(),
|
||||
shiftDate.getDate() + 1,
|
||||
Date.UTC(
|
||||
shiftDate.getUTCFullYear(),
|
||||
shiftDate.getUTCMonth(),
|
||||
shiftDate.getUTCDate() + 1,
|
||||
),
|
||||
);
|
||||
|
||||
// Multiple work shifts per day are allowed — only block when the new
|
||||
|
||||
Reference in New Issue
Block a user