Files
app/src/routes/admin/dashboard.ts
BOHA 74ce24e3fa 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>
2026-06-10 11:28:45 +02:00

331 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth } from "../../middleware/auth";
import { success } from "../../utils/response";
import { localTimeStr } from "../../utils/date";
import { toCzk } from "../../services/exchange-rates";
import { resolveCell } from "../../services/plan.service";
export default async function dashboardRoutes(
fastify: FastifyInstance,
): 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(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
);
const todayEnd = new Date(
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);
const authData = request.authData!;
const userId = authData.userId;
const perms = authData.permissions;
const has = (p: string) => perms.includes(p);
const result: Record<string, unknown> = {};
// My shift — always available for authenticated users with attendance.record
if (has("attendance.record")) {
const myShift = await prisma.attendance.findFirst({
where: {
user_id: userId,
arrival_time: { not: null },
departure_time: null,
},
orderBy: { created_at: "desc" },
});
result.my_shift = { has_ongoing: myShift !== null };
}
// Today's work plan (personal) — powers the "Vaše dnešní zařazení" card.
// resolveCell returns 03 records (override-beats-range layering); an empty
// array means the user has plan access but nothing is scheduled today. Each
// record's category key is enriched with its label + colour so the widget
// needs no extra query. todayStr uses local (Europe/Prague) calendar date —
// the plan's @db.Date day.
if (has("attendance.record") || has("attendance.manage")) {
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const cells = await resolveCell(userId, todayStr);
// Resolve all category labels in one query (was a per-cell N+1) then map.
const categoryKeys = [...new Set(cells.map((c) => c.category))];
const categories = categoryKeys.length
? await prisma.plan_categories.findMany({
where: { key: { in: categoryKeys } },
select: { key: true, label: true, color: true },
})
: [];
const categoryMap = new Map(categories.map((c) => [c.key, c]));
result.today_plan = cells.map((cell) => {
const cat = categoryMap.get(cell.category);
return {
...cell,
category_label: cat?.label ?? cell.category,
category_color: cat?.color ?? null,
};
});
}
// Attendance admin — only for attendance.manage
if (has("attendance.manage")) {
const [todayAttendance, onLeaveToday, activeUsers] = await Promise.all([
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
OR: [{ leave_type: null }, { leave_type: "work" }],
},
include: {
users: { select: { id: true, first_name: true, last_name: true } },
},
orderBy: { arrival_time: "asc" },
}),
prisma.attendance.findMany({
where: {
shift_date: { gte: todayStart, lt: todayEnd },
leave_type: { in: ["vacation", "sick", "unpaid"] },
},
include: {
users: { select: { id: true, first_name: true, last_name: true } },
},
}),
prisma.users.findMany({
where: { is_active: true },
select: { id: true, first_name: true, last_name: true },
orderBy: [{ last_name: "asc" }, { first_name: "asc" }],
}),
]);
const usersCount = activeUsers.length;
const userAttendanceMap = new Map<number, (typeof todayAttendance)[0]>();
for (const a of todayAttendance) {
const existing = userAttendanceMap.get(a.users.id);
if (
!existing ||
(a.arrival_time &&
existing.arrival_time &&
a.arrival_time > existing.arrival_time)
) {
userAttendanceMap.set(a.users.id, a);
}
}
let presentCount = 0;
const attendanceUsers: Array<{
user_id: number;
name: string;
initials: string;
status: string;
arrived_at: string | null;
leave_type?: string;
}> = [];
for (const a of userAttendanceMap.values()) {
const user = a.users;
const firstInitial = user.first_name?.charAt(0) ?? "";
const lastInitial = user.last_name?.charAt(0) ?? "";
let status = "out";
if (a.arrival_time) {
if (a.departure_time) status = "out";
else if (a.break_start && !a.break_end) status = "away";
else {
status = "in";
presentCount++;
}
}
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials: `${firstInitial}${lastInitial}`.toUpperCase(),
status,
arrived_at: a.arrival_time ? localTimeStr(a.arrival_time) : null,
});
}
const leaveUserIds = new Set<number>();
for (const a of onLeaveToday) {
if (leaveUserIds.has(a.users.id)) continue;
leaveUserIds.add(a.users.id);
const user = a.users;
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials:
`${user.first_name?.charAt(0) ?? ""}${user.last_name?.charAt(0) ?? ""}`.toUpperCase(),
status: "leave",
arrived_at: null,
leave_type: (a.leave_type as string) || "vacation",
});
}
// Show every active employee — those with no attendance record today
// appear as "absent" so the card reflects the whole team, not just those
// who clocked in or are on leave.
const recordedUserIds = new Set<number>([
...userAttendanceMap.keys(),
...leaveUserIds,
]);
for (const user of activeUsers) {
if (recordedUserIds.has(user.id)) continue;
attendanceUsers.push({
user_id: user.id,
name: `${user.first_name} ${user.last_name}`,
initials:
`${user.first_name?.charAt(0) ?? ""}${user.last_name?.charAt(0) ?? ""}`.toUpperCase(),
status: "absent",
arrived_at: null,
});
}
result.attendance = {
present_today: presentCount,
total_active: usersCount,
on_leave: leaveUserIds.size,
users: attendanceUsers,
};
result.users_count = usersCount;
}
// Offers — only for offers.view
if (has("offers.view")) {
const [openCount, convertedCount, expiredCount, createdThisMonth] =
await Promise.all([
prisma.quotations.count({ where: { status: "active" } }),
prisma.quotations.count({ where: { status: "ordered" } }),
prisma.quotations.count({ where: { status: "invalidated" } }),
prisma.quotations.count({
where: {
// Drafts aren't real offers — keep them out of the count.
status: { not: "draft" },
created_at: { gte: monthStart, lt: monthEnd },
},
}),
]);
result.offers = {
open_count: openCount,
converted_count: convertedCount,
expired_count: expiredCount,
created_this_month: createdThisMonth,
};
}
// Projects — only for projects.view
if (has("projects.view")) {
const [activeCount, activeList] = await Promise.all([
prisma.projects.count({ where: { status: "aktivni" } }),
prisma.projects.findMany({
where: { status: "aktivni" },
include: { customers: { select: { name: true } } },
orderBy: { created_at: "desc" },
take: 5,
}),
]);
result.active_projects = activeCount;
result.projects = {
active_projects: activeList.map((p) => ({
id: p.id,
name: p.name ?? "",
customer_name: p.customers?.name ?? null,
})),
};
}
// Invoices — only for invoices.view
if (has("invoices.view")) {
// $queryRaw template literal interpolation with Date objects fails on
// MySQL when Date.toJSON is overridden — pass explicit date strings instead.
const monthStartStr = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, "0")}-01`;
const monthEndStr = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, "0")}-01`;
const [unpaidCount, revenueAgg] = await Promise.all([
prisma.invoices.count({ where: { status: "issued" } }),
prisma.$queryRaw<
Array<{ currency: string | null; total: string | number | null }>
>`
SELECT i.currency, SUM(ii.quantity * ii.unit_price) as total
FROM invoices i
JOIN invoice_items ii ON i.id = ii.invoice_id
WHERE i.issue_date >= ${monthStartStr} AND i.issue_date < ${monthEndStr}
AND i.status <> 'draft'
GROUP BY i.currency
`,
]);
const revenueByCurrency: Record<string, number> = {};
for (const row of revenueAgg) {
const currency = row.currency || "CZK";
const amount = Number(row.total) || 0;
revenueByCurrency[currency] =
(revenueByCurrency[currency] || 0) + amount;
}
const revenueConversions = await Promise.all(
Object.entries(revenueByCurrency).map(async ([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
czk: await toCzk(Math.round(amount * 100) / 100, currency),
})),
);
result.invoices = {
revenue_this_month: revenueConversions.map(({ amount, currency }) => ({
amount,
currency,
})),
unpaid_count: unpaidCount,
revenue_czk:
Math.round(
revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100,
) / 100,
};
}
// Orders — only for orders.view
if (has("orders.view")) {
result.pending_orders = await prisma.orders.count({
where: { status: "prijata" },
});
}
// Leave pending — only for attendance.approve
if (has("attendance.approve")) {
const count = await prisma.leave_requests.count({
where: { status: "pending" },
});
result.leave_pending = { count };
}
// Recent activity — only for settings.audit (admin)
if (has("settings.audit")) {
const logs = await prisma.audit_logs.findMany({
orderBy: { created_at: "desc" },
take: 8,
where: { action: { in: ["create", "update", "delete", "login"] } },
select: {
id: true,
action: true,
entity_type: true,
description: true,
username: true,
created_at: true,
},
});
result.recent_activity = logs.map((log) => ({
id: log.id,
action: log.action,
entity_type: log.entity_type ?? "",
description: log.description ?? "",
username: log.username ?? null,
created_at: log.created_at,
}));
}
return success(reply, result);
});
}