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:
26
backend/src/auth/google.ts
Normal file
26
backend/src/auth/google.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import { env } from "../env";
|
||||
|
||||
const client = new OAuth2Client(env.googleClientId);
|
||||
|
||||
export interface GoogleProfile {
|
||||
email: string;
|
||||
name: string | null;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export async function verifyGoogleIdToken(idToken: string): Promise<GoogleProfile> {
|
||||
const ticket = await client.verifyIdToken({
|
||||
idToken,
|
||||
audience: env.googleClientId,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload?.email) {
|
||||
throw new Error("Google token payload is missing an email");
|
||||
}
|
||||
return {
|
||||
email: payload.email.toLowerCase(),
|
||||
name: payload.name ?? null,
|
||||
emailVerified: payload.email_verified ?? false,
|
||||
};
|
||||
}
|
||||
28
backend/src/auth/middleware.ts
Normal file
28
backend/src/auth/middleware.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { readSession } from "./session";
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = readSession(req);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.user?.role !== "admin") {
|
||||
res.status(403).json({ error: "Admin access required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireEmployee(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.user?.employeeId == null) {
|
||||
res.status(403).json({ error: "No employee profile linked to this account" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
32
backend/src/auth/session.ts
Normal file
32
backend/src/auth/session.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Request, Response } from "express";
|
||||
import { env } from "../env";
|
||||
import type { SessionUser } from "../types";
|
||||
|
||||
export const SESSION_COOKIE = "eatme_session";
|
||||
const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12h
|
||||
|
||||
export function issueSession(res: Response, user: SessionUser): void {
|
||||
const token = jwt.sign(user, env.jwtSecret, { expiresIn: SESSION_TTL_SECONDS });
|
||||
res.cookie(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: env.cookieSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_TTL_SECONDS * 1000,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSession(res: Response): void {
|
||||
res.clearCookie(SESSION_COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
export function readSession(req: Request): SessionUser | null {
|
||||
const token = req.cookies?.[SESSION_COOKIE];
|
||||
if (!token) return null;
|
||||
try {
|
||||
return jwt.verify(token, env.jwtSecret) as SessionUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
30
backend/src/db/index.ts
Normal file
30
backend/src/db/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "../env";
|
||||
|
||||
fs.mkdirSync(path.dirname(env.dbPath), { recursive: true });
|
||||
|
||||
export const db = new Database(env.dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
type TEXT NOT NULL CHECK (type IN ('clock_in', 'clock_out', 'break_start', 'break_end')),
|
||||
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_employee_ts
|
||||
ON attendance_events (employee_id, ts);
|
||||
`);
|
||||
22
backend/src/env.ts
Normal file
22
backend/src/env.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import "dotenv/config";
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT ?? 4000),
|
||||
googleClientId: required("GOOGLE_CLIENT_ID"),
|
||||
adminEmails: required("ADMIN_EMAILS")
|
||||
.split(",")
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
jwtSecret: required("JWT_SECRET"),
|
||||
dbPath: process.env.DB_PATH ?? "./data/eatme.db",
|
||||
corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
||||
cookieSecure: process.env.COOKIE_SECURE === "true",
|
||||
};
|
||||
43
backend/src/index.ts
Normal file
43
backend/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import "./db"; // ensure schema is created before handling requests
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { env } from "./env";
|
||||
import { authRouter } from "./routes/auth";
|
||||
import { attendanceRouter } from "./routes/attendance";
|
||||
import { adminRouter } from "./routes/admin";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: env.corsOrigin, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
app.get("/api/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/attendance", attendanceRouter);
|
||||
app.use("/api/admin", adminRouter);
|
||||
|
||||
// In the production Docker image the built frontend is copied next to this
|
||||
// bundle as dist/public. Serve it (with an SPA fallback) when present; in dev
|
||||
// the frontend runs on its own Vite server, so this is simply skipped.
|
||||
const publicDir = path.join(__dirname, "public");
|
||||
if (fs.existsSync(publicDir)) {
|
||||
app.use(express.static(publicDir));
|
||||
app.get(/^(?!\/api\/).*/, (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`EatMe API listening on http://localhost:${env.port}`);
|
||||
});
|
||||
109
backend/src/routes/admin.ts
Normal file
109
backend/src/routes/admin.ts
Normal 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
|
||||
});
|
||||
51
backend/src/routes/attendance.ts
Normal file
51
backend/src/routes/attendance.ts
Normal 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 });
|
||||
});
|
||||
64
backend/src/routes/auth.ts
Normal file
64
backend/src/routes/auth.ts
Normal 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 });
|
||||
});
|
||||
134
backend/src/services/attendance.ts
Normal file
134
backend/src/services/attendance.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { db } from "../db";
|
||||
import type { AttendanceEvent, EventType } from "../types";
|
||||
|
||||
export type LiveStatus = "clocked_out" | "working" | "on_break";
|
||||
|
||||
const NEXT_ALLOWED: Record<LiveStatus, EventType[]> = {
|
||||
clocked_out: ["clock_in"],
|
||||
working: ["break_start", "clock_out"],
|
||||
on_break: ["break_end"],
|
||||
};
|
||||
|
||||
function statusAfter(lastType: EventType | null): LiveStatus {
|
||||
switch (lastType) {
|
||||
case null:
|
||||
case "clock_out":
|
||||
return "clocked_out";
|
||||
case "clock_in":
|
||||
case "break_end":
|
||||
return "working";
|
||||
case "break_start":
|
||||
return "on_break";
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastEvent(employeeId: number): AttendanceEvent | undefined {
|
||||
return db
|
||||
.prepare<[number], AttendanceEvent>(
|
||||
"SELECT * FROM attendance_events WHERE employee_id = ? ORDER BY ts DESC, id DESC LIMIT 1"
|
||||
)
|
||||
.get(employeeId);
|
||||
}
|
||||
|
||||
export function getLiveStatus(employeeId: number): LiveStatus {
|
||||
const last = getLastEvent(employeeId);
|
||||
return statusAfter(last?.type ?? null);
|
||||
}
|
||||
|
||||
export class InvalidTransitionError extends Error {
|
||||
constructor(public readonly current: LiveStatus, public readonly attempted: EventType) {
|
||||
super(`Cannot record "${attempted}" while status is "${current}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export function recordEvent(employeeId: number, type: EventType): AttendanceEvent {
|
||||
const current = getLiveStatus(employeeId);
|
||||
if (!NEXT_ALLOWED[current].includes(type)) {
|
||||
throw new InvalidTransitionError(current, type);
|
||||
}
|
||||
const result = db
|
||||
.prepare("INSERT INTO attendance_events (employee_id, type) VALUES (?, ?)")
|
||||
.run(employeeId, type);
|
||||
return db
|
||||
.prepare<[number], AttendanceEvent>("SELECT * FROM attendance_events WHERE id = ?")
|
||||
.get(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
export function eventsInRange(employeeId: number, fromIso: string, toIso: string): AttendanceEvent[] {
|
||||
return db
|
||||
.prepare<[number, string, string], AttendanceEvent>(
|
||||
`SELECT * FROM attendance_events
|
||||
WHERE employee_id = ? AND ts >= ? AND ts < ?
|
||||
ORDER BY ts ASC, id ASC`
|
||||
)
|
||||
.all(employeeId, fromIso, toIso);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
clockIn: string;
|
||||
clockOut: string | null;
|
||||
breaks: { start: string; end: string | null }[];
|
||||
workedMs: number;
|
||||
breakMs: number;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a chronological event list and pairs them into shifts. Any session still
|
||||
* open at the end of the range (no clock_out yet) is included with open: true so
|
||||
* "currently working" shows up in today's view, but callers doing historical
|
||||
* totals should treat open sessions' worked time as provisional (measured up to `now`).
|
||||
*/
|
||||
export function buildSessions(events: AttendanceEvent[], now: Date): Session[] {
|
||||
const sessions: Session[] = [];
|
||||
let current: Session | null = null;
|
||||
|
||||
for (const event of events) {
|
||||
const ts = event.ts;
|
||||
if (event.type === "clock_in") {
|
||||
current = { clockIn: ts, clockOut: null, breaks: [], workedMs: 0, breakMs: 0, open: true };
|
||||
sessions.push(current);
|
||||
} else if (!current) {
|
||||
// Orphaned event (e.g. data edited manually) — ignore rather than crash.
|
||||
continue;
|
||||
} else if (event.type === "break_start") {
|
||||
current.breaks.push({ start: ts, end: null });
|
||||
} else if (event.type === "break_end") {
|
||||
const openBreak = current.breaks.find((b) => b.end === null);
|
||||
if (openBreak) openBreak.end = ts;
|
||||
} else if (event.type === "clock_out") {
|
||||
current.clockOut = ts;
|
||||
current.open = false;
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
const end = session.clockOut ? new Date(session.clockOut) : now;
|
||||
const start = new Date(session.clockIn);
|
||||
session.breakMs = session.breaks.reduce((sum, b) => {
|
||||
const bEnd = b.end ? new Date(b.end) : now;
|
||||
return sum + Math.max(0, bEnd.getTime() - new Date(b.start).getTime());
|
||||
}, 0);
|
||||
session.workedMs = Math.max(0, end.getTime() - start.getTime() - session.breakMs);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
sessions: Session[];
|
||||
totalWorkedMs: number;
|
||||
totalBreakMs: number;
|
||||
shiftCount: number;
|
||||
}
|
||||
|
||||
export function summarize(events: AttendanceEvent[], now: Date): StatsSummary {
|
||||
const sessions = buildSessions(events, now);
|
||||
return {
|
||||
sessions,
|
||||
totalWorkedMs: sessions.reduce((sum, s) => sum + s.workedMs, 0),
|
||||
totalBreakMs: sessions.reduce((sum, s) => sum + s.breakMs, 0),
|
||||
shiftCount: sessions.length,
|
||||
};
|
||||
}
|
||||
52
backend/src/services/employees.ts
Normal file
52
backend/src/services/employees.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { db } from "../db";
|
||||
import type { Employee } from "../types";
|
||||
|
||||
export function findActiveEmployeeByEmail(email: string): Employee | undefined {
|
||||
return db
|
||||
.prepare<[string], Employee>(
|
||||
"SELECT * FROM employees WHERE email = ? AND active = 1"
|
||||
)
|
||||
.get(email.toLowerCase());
|
||||
}
|
||||
|
||||
export function findEmployeeById(id: number): Employee | undefined {
|
||||
return db.prepare<[number], Employee>("SELECT * FROM employees WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
export function listEmployees(): Employee[] {
|
||||
return db
|
||||
.prepare<[], Employee>("SELECT * FROM employees ORDER BY active DESC, email ASC")
|
||||
.all();
|
||||
}
|
||||
|
||||
/** Fills in the display name the first time we see a Google profile for this employee. */
|
||||
export function touchEmployeeName(id: number, name: string | null): void {
|
||||
if (!name) return;
|
||||
db.prepare("UPDATE employees SET name = ? WHERE id = ? AND name IS NULL").run(name, id);
|
||||
}
|
||||
|
||||
export function addEmployee(email: string, name: string | null): Employee {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
const existing = db
|
||||
.prepare<[string], Employee>("SELECT * FROM employees WHERE email = ?")
|
||||
.get(normalized);
|
||||
|
||||
if (existing) {
|
||||
db.prepare("UPDATE employees SET active = 1, name = COALESCE(?, name) WHERE id = ?").run(
|
||||
name,
|
||||
existing.id
|
||||
);
|
||||
return findEmployeeById(existing.id)!;
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare("INSERT INTO employees (email, name) VALUES (?, ?)")
|
||||
.run(normalized, name);
|
||||
return findEmployeeById(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
/** Soft delete: revokes login access but keeps attendance history for stats. */
|
||||
export function deactivateEmployee(id: number): boolean {
|
||||
const result = db.prepare("UPDATE employees SET active = 0 WHERE id = ?").run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
33
backend/src/types.ts
Normal file
33
backend/src/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type EventType = "clock_in" | "clock_out" | "break_start" | "break_end";
|
||||
|
||||
export interface Employee {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
active: 0 | 1;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AttendanceEvent {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
type: EventType;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export type Role = "admin" | "employee";
|
||||
|
||||
export interface SessionUser {
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
employeeId: number | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: SessionUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
backend/src/util/csv.ts
Normal file
35
backend/src/util/csv.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Session } from "../services/attendance";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("cs-CZ");
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.round(ms / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function escapeCsvField(value: string): string {
|
||||
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
}
|
||||
|
||||
export function sessionsToCsv(sessions: Session[]): string {
|
||||
const header = ["Datum", "Příchod", "Odchod", "Pauza (h:mm)", "Odpracováno (h:mm)"];
|
||||
const rows = [...sessions]
|
||||
.sort((a, b) => a.clockIn.localeCompare(b.clockIn))
|
||||
.map((s) => [
|
||||
formatDate(s.clockIn),
|
||||
formatTime(s.clockIn),
|
||||
s.clockOut ? formatTime(s.clockOut) : "probíhá",
|
||||
formatDuration(s.breakMs),
|
||||
formatDuration(s.workedMs),
|
||||
]);
|
||||
|
||||
return [header, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\r\n");
|
||||
}
|
||||
33
backend/src/util/dateRange.ts
Normal file
33
backend/src/util/dateRange.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Range {
|
||||
fromIso: string;
|
||||
toIso: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses attendance query params into an [from, to) ISO range. Priority:
|
||||
* 1. explicit ?from=YYYY-MM-DD&to=YYYY-MM-DD (to is inclusive of that whole day)
|
||||
* 2. ?month=YYYY-MM — that whole calendar month
|
||||
* 3. default — the current calendar month
|
||||
*/
|
||||
export function parseRange(query: Record<string, unknown>): Range {
|
||||
const now = new Date();
|
||||
|
||||
const from = typeof query.from === "string" && query.from ? new Date(query.from) : null;
|
||||
const toRaw = typeof query.to === "string" && query.to ? new Date(query.to) : null;
|
||||
|
||||
if (from && toRaw) {
|
||||
const to = new Date(toRaw);
|
||||
to.setDate(to.getDate() + 1); // make "to" inclusive of that whole day
|
||||
return { fromIso: from.toISOString(), toIso: to.toISOString() };
|
||||
}
|
||||
|
||||
const month = typeof query.month === "string" && /^\d{4}-\d{2}$/.test(query.month) ? query.month : null;
|
||||
const [year, monthIndex] = month
|
||||
? [Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1]
|
||||
: [now.getFullYear(), now.getMonth()];
|
||||
|
||||
const monthStart = new Date(year, monthIndex, 1);
|
||||
const monthEnd = new Date(year, monthIndex + 1, 1);
|
||||
|
||||
return { fromIso: monthStart.toISOString(), toIso: monthEnd.toISOString() };
|
||||
}
|
||||
Reference in New Issue
Block a user