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:
100
src/__tests__/dashboard.test.ts
Normal file
100
src/__tests__/dashboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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);
|
||||
setUser(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();
|
||||
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);
|
||||
setUser(mapUser(data.data.user));
|
||||
cachedUserRef.current = mapUser(data.data.user);
|
||||
@@ -327,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
clearTimeout(refreshTimeoutRef.current);
|
||||
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]);
|
||||
|
||||
|
||||
@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
|
||||
): Promise<void> {
|
||||
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
|
||||
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(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
|
||||
);
|
||||
const todayEnd = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() + 1,
|
||||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
|
||||
);
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
|
||||
Reference in New Issue
Block a user