fix(dashboard,auth): today-window on @db.Date + query-cache cleared across logins

Two dashboard bugs reported after clock-in:

1. 'Dochazka dnes' / 'Pritomni dnes' showed everyone absent even after
   refresh: attendance.shift_date is @db.Date and Prisma truncates filter
   Dates to their UTC date part, so the local-midnight boundaries
   (new Date(y,m,d) = 22:00/23:00Z of the previous day) queried
   [yesterday, today) and matched zero of today's punches. Boundaries are
   now UTC-midnight instants of the local (Prague) calendar day.
   Reproduced empirically against real rows; route-level regression test
   added (dashboard.test.ts).

2. After logout + login as a different user, cached dashboards/buttons from
   the previous user were served: query keys are user-agnostic and the
   React Query cache outlives the session. logout(), login() and the 2FA
   verify path now clear the whole cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 11:28:45 +02:00
parent 8ee5a443ef
commit 74ce24e3fa
3 changed files with 119 additions and 6 deletions

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import dashboardRoutes from "../routes/admin/dashboard";
// ---------------------------------------------------------------------------
// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window.
//
// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its
// UTC date part. The dashboard used local-midnight boundaries
// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under
// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches
// matched zero rows and everyone showed as absent. The boundaries must be
// UTC-midnight instants of the LOCAL calendar day.
//
// Unlike the other suites this one has to use the REAL current date (the
// route computes "today" internally), so fixtures are tracked by id and
// deleted in afterAll.
// ---------------------------------------------------------------------------
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
const createdIds: number[] = [];
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" });
return a;
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
if (createdIds.length) {
await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } });
}
if (app) await app.close();
});
describe("GET /api/admin/dashboard — today's attendance window", () => {
it("counts a punched-in user as present (local-noon @db.Date row)", async () => {
const now = new Date();
// The attendance regime stores shift_date at LOCAL NOON (so the UTC date
// part equals the Prague calendar date) — same as punchAction does.
const row = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
12,
0,
0,
),
leave_type: "work",
arrival_time: now,
departure_time: null,
notes: "dashboard_window_test",
},
});
createdIds.push(row.id);
const res = await app.inject({
method: "GET",
url: "/api/admin/dashboard",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const attendance = body.data.attendance;
expect(attendance).toBeTruthy();
const me = attendance.users.find(
(u: { user_id: number }) => u.user_id === adminUserId,
);
expect(me).toBeTruthy();
expect(me.status).toBe("in");
expect(attendance.present_today).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -9,6 +9,7 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api"; import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
import { queryClient } from "../lib/queryClient";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
remember, remember,
}; };
} }
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
// so anything cached from a previously logged-in user would be
// served to this one — drop the whole cache on every login.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
@@ -288,6 +293,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
); );
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
// Same reason as in login(): the cache may hold the previous
// user's data under user-agnostic keys.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
@@ -327,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;
} }
// The React Query cache outlives the session — without this, the next
// user to log in on this browser sees the previous user's cached
// dashboards/lists until each query refetches.
queryClient.clear();
} }
}, [getAccessTokenFn]); }, [getAccessTokenFn]);

View File

@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
): Promise<void> { ): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const now = new Date(); const now = new Date();
// shift_date is @db.Date: Prisma truncates a filter Date to its UTC date
// part, so these boundaries MUST be UTC-midnight instants of the LOCAL
// (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is
// 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to
// [yesterday, today) — the "Docházka dnes" card then matches zero rows.
const todayStart = new Date( const todayStart = new Date(
now.getFullYear(), Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
now.getMonth(),
now.getDate(),
); );
const todayEnd = new Date( const todayEnd = new Date(
now.getFullYear(), Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
now.getMonth(),
now.getDate() + 1,
); );
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);