Initial commit: EatMe attendance tracker

Google-SSO PWA for bistro employee clock-in/out, admin employee
management, and stats with CSV export. Express + SQLite backend,
React + Zustand frontend in the light-mono-tui design language.
Multi-stage Dockerfile, compose.yaml for image-based deploys, nginx
reverse-proxy template, and an OKF documentation bundle in docs/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michal Pemcak
2026-08-12 11:57:55 +02:00
commit fffcb73ea4
90 changed files with 3411 additions and 0 deletions

109
backend/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,109 @@
import { Router } from "express";
import { z } from "zod";
import { requireAdmin, requireAuth } from "../auth/middleware";
import {
addEmployee,
deactivateEmployee,
findEmployeeById,
listEmployees,
} from "../services/employees";
import { eventsInRange, summarize } from "../services/attendance";
import { parseRange } from "../util/dateRange";
import { sessionsToCsv } from "../util/csv";
export const adminRouter = Router();
adminRouter.use(requireAuth, requireAdmin);
adminRouter.get("/employees", (_req, res) => {
res.json({ employees: listEmployees() });
});
const addEmployeeSchema = z.object({
email: z.string().email(),
name: z.string().trim().min(1).optional(),
});
adminRouter.post("/employees", (req, res) => {
const parsed = addEmployeeSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Valid email is required" });
return;
}
const employee = addEmployee(parsed.data.email, parsed.data.name ?? null);
res.status(201).json({ employee });
});
adminRouter.delete("/employees/:id", (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
res.status(400).json({ error: "Invalid employee id" });
return;
}
const removed = deactivateEmployee(id);
if (!removed) {
res.status(404).json({ error: "Employee not found" });
return;
}
res.status(204).end();
});
adminRouter.get("/stats", (req, res) => {
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
const now = new Date();
const employees = listEmployees();
const perEmployee = employees.map((employee) => {
const events = eventsInRange(employee.id, fromIso, toIso);
const stats = summarize(events, now);
return {
employee,
totalWorkedMs: stats.totalWorkedMs,
totalBreakMs: stats.totalBreakMs,
shiftCount: stats.shiftCount,
};
});
const totals = perEmployee.reduce(
(acc, e) => ({
totalWorkedMs: acc.totalWorkedMs + e.totalWorkedMs,
totalBreakMs: acc.totalBreakMs + e.totalBreakMs,
shiftCount: acc.shiftCount + e.shiftCount,
}),
{ totalWorkedMs: 0, totalBreakMs: 0, shiftCount: 0 }
);
res.json({ range: { from: fromIso, to: toIso }, totals, employees: perEmployee });
});
adminRouter.get("/stats/:id", (req, res) => {
const id = Number(req.params.id);
const employee = Number.isInteger(id) ? findEmployeeById(id) : undefined;
if (!employee) {
res.status(404).json({ error: "Employee not found" });
return;
}
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
const events = eventsInRange(employee.id, fromIso, toIso);
const stats = summarize(events, new Date());
res.json({ employee, range: { from: fromIso, to: toIso }, ...stats });
});
adminRouter.get("/stats/:id/export", (req, res) => {
const id = Number(req.params.id);
const employee = Number.isInteger(id) ? findEmployeeById(id) : undefined;
if (!employee) {
res.status(404).json({ error: "Employee not found" });
return;
}
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
const events = eventsInRange(employee.id, fromIso, toIso);
const { sessions } = summarize(events, new Date());
const csv = sessionsToCsv(sessions);
const period = fromIso.slice(0, 7);
const safeName = employee.email.replace(/[^a-z0-9.@-]/gi, "_");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${safeName}_${period}.csv"`);
res.send("" + csv); // BOM so Excel opens the Czech diacritics as UTF-8
});

View File

@@ -0,0 +1,51 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth, requireEmployee } from "../auth/middleware";
import {
InvalidTransitionError,
eventsInRange,
getLiveStatus,
recordEvent,
summarize,
} from "../services/attendance";
import { parseRange } from "../util/dateRange";
export const attendanceRouter = Router();
attendanceRouter.use(requireAuth, requireEmployee);
const eventSchema = z.object({
type: z.enum(["clock_in", "clock_out", "break_start", "break_end"]),
});
attendanceRouter.post("/event", (req, res) => {
const parsed = eventSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid event type" });
return;
}
const employeeId = req.user!.employeeId!;
try {
const event = recordEvent(employeeId, parsed.data.type);
res.status(201).json({ event, status: getLiveStatus(employeeId) });
} catch (err) {
if (err instanceof InvalidTransitionError) {
res.status(409).json({ error: err.message, status: err.current });
return;
}
throw err;
}
});
attendanceRouter.get("/state", (req, res) => {
const employeeId = req.user!.employeeId!;
res.json({ status: getLiveStatus(employeeId) });
});
attendanceRouter.get("/me", (req, res) => {
const employeeId = req.user!.employeeId!;
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
const events = eventsInRange(employeeId, fromIso, toIso);
const stats = summarize(events, new Date());
res.json({ range: { from: fromIso, to: toIso }, ...stats });
});

View File

@@ -0,0 +1,64 @@
import { Router } from "express";
import { z } from "zod";
import { verifyGoogleIdToken } from "../auth/google";
import { clearSession, issueSession } from "../auth/session";
import { requireAuth } from "../auth/middleware";
import { env } from "../env";
import { findActiveEmployeeByEmail, touchEmployeeName } from "../services/employees";
import type { SessionUser } from "../types";
export const authRouter = Router();
const loginSchema = z.object({ credential: z.string().min(1) });
authRouter.post("/google", async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Missing Google credential" });
return;
}
let profile;
try {
profile = await verifyGoogleIdToken(parsed.data.credential);
} catch {
res.status(401).json({ error: "Invalid Google token" });
return;
}
if (!profile.emailVerified) {
res.status(403).json({ error: "Google account email is not verified" });
return;
}
const isAdmin = env.adminEmails.includes(profile.email);
const employee = findActiveEmployeeByEmail(profile.email);
if (!isAdmin && !employee) {
res.status(403).json({ error: "This account is not registered as an EatMe employee" });
return;
}
if (employee) {
touchEmployeeName(employee.id, profile.name);
}
const user: SessionUser = {
email: profile.email,
name: profile.name,
role: isAdmin ? "admin" : "employee",
employeeId: employee?.id ?? null,
};
issueSession(res, user);
res.json({ user });
});
authRouter.post("/logout", (_req, res) => {
clearSession(res);
res.status(204).end();
});
authRouter.get("/me", requireAuth, (req, res) => {
res.json({ user: req.user });
});