import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { requireAuth, requirePermission, requireAnyPermission, } from "../../middleware/auth"; import { success, error, parseId } from "../../utils/response"; import { parseBody } from "../../schemas/common"; import { AuthData } from "../../types"; import { logAudit } from "../../services/audit"; import { CreatePlanEntrySchema, UpdatePlanEntrySchema, CreatePlanOverrideSchema, UpdatePlanOverrideSchema, PlanRangeQuerySchema, PlanGridQuerySchema, BulkPlanEntrySchema, } from "../../schemas/plan.schema"; import { resolveGrid, listPlanUsers, createEntry, updateEntry, deleteEntry, createOverride, updateOverride, deleteOverride, listEntries, listOverrides, bulkCreateEntries, } from "../../services/plan.service"; import { CreatePlanCategorySchema, UpdatePlanCategorySchema, } from "../../schemas/planCategory.schema"; import { listPlanCategories, createPlanCategory, updatePlanCategory, deletePlanCategory, } from "../../services/planCategory.service"; const MAX_RANGE_DAYS = 92; // NOTE: AuthData carries `roleName` (not `role` — that lives on JwtPayload). // The previous `authData.role` read was always undefined, so admins were // wrongly scoped to their own plan rows on the /entries and /overrides // endpoints. Typed (not `any`) so this can't silently regress. function isAdminLike(authData: AuthData | undefined): boolean { return authData?.roleName === "admin"; } function forceFromQuery(query: unknown): boolean { if (!query || typeof query !== "object") return false; return (query as { force?: unknown }).force === "1"; } function daysBetween(from: string, to: string): number { const a = new Date(from + "T00:00:00.000Z").getTime(); const b = new Date(to + "T00:00:00.000Z").getTime(); return Math.round((b - a) / (1000 * 60 * 60 * 24)); } export default async function planRoutes(app: FastifyInstance) { app.addHook("preHandler", requireAuth); // --- GET /plan/users --- app.get( "/users", { preHandler: requireAnyPermission( "attendance.manage", "attendance.record", ), }, async (_request: FastifyRequest, reply: FastifyReply) => { const users = await listPlanUsers(); return success(reply, users); }, ); // --- GET /plan/grid --- app.get( "/grid", { preHandler: requireAnyPermission( "attendance.manage", "attendance.record", ), }, async (request: FastifyRequest, reply: FastifyReply) => { const parsed = PlanGridQuerySchema.safeParse(request.query); if (!parsed.success) { return error(reply, parsed.error.issues[0].message, 400); } const { date_from, date_to, view } = parsed.data; if (daysBetween(date_from, date_to) > MAX_RANGE_DAYS) { return error(reply, "Maximální rozsah je 92 dní", 400); } const users = await listPlanUsers(); const cells = await resolveGrid( users.map((u) => u.id), date_from, date_to, ); return success(reply, { view, date_from, date_to, users, cells }); }, ); // --- GET /plan/entries --- app.get( "/entries", { preHandler: requireAnyPermission( "attendance.manage", "attendance.record", ), }, async (request: FastifyRequest, reply: FastifyReply) => { const parsed = PlanRangeQuerySchema.safeParse(request.query); if (!parsed.success) return error(reply, parsed.error.issues[0].message, 400); const rows = await listEntries( parsed.data, request.authData!.userId, isAdminLike(request.authData), ); return success(reply, rows); }, ); // --- GET /plan/overrides --- app.get( "/overrides", { preHandler: requireAnyPermission( "attendance.manage", "attendance.record", ), }, async (request: FastifyRequest, reply: FastifyReply) => { const parsed = PlanRangeQuerySchema.safeParse(request.query); if (!parsed.success) return error(reply, parsed.error.issues[0].message, 400); const rows = await listOverrides( parsed.data, request.authData!.userId, isAdminLike(request.authData), ); return success(reply, rows); }, ); // --- GET /plan/categories (any planner can read; needed for display) --- app.get( "/categories", { preHandler: requireAnyPermission( "attendance.manage", "attendance.record", ), }, async (_request: FastifyRequest, reply: FastifyReply) => { const cats = await listPlanCategories(true); return success(reply, cats); }, ); // --- POST /plan/categories --- app.post( "/categories", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const body = parseBody(CreatePlanCategorySchema, request.body); if ("error" in body) return error(reply, body.error, 400); const result = await createPlanCategory(body.data!); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "create", entityType: "plan_category", entityId: result.data.id, newValues: result.data, description: `Kategorie: ${result.data.label}`, }); return success(reply, result.data, 201, "Kategorie vytvořena"); }, ); // --- PATCH /plan/categories/:id --- app.patch( "/categories/:id", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const id = parseId((request.params as any).id, reply); if (id === null) return; const body = parseBody(UpdatePlanCategorySchema, request.body); if ("error" in body) return error(reply, body.error, 400); const result = await updatePlanCategory(id, body.data!); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "update", entityType: "plan_category", entityId: id, newValues: result.data, description: `Kategorie: ${result.data.label}`, }); return success(reply, result.data, 200, "Kategorie aktualizována"); }, ); // --- DELETE /plan/categories/:id (hard delete; blocked if in use) --- app.delete( "/categories/:id", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const id = parseId((request.params as any).id, reply); if (id === null) return; const result = await deletePlanCategory(id); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "delete", entityType: "plan_category", entityId: id, description: "Kategorie smazána", }); return success(reply, { ok: true }, 200, "Kategorie smazána"); }, ); // --- POST /plan/entries --- app.post( "/entries", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const body = parseBody(CreatePlanEntrySchema, request.body); if ("error" in body) return error(reply, body.error, 400); const force = forceFromQuery(request.query); const result = await createEntry( body.data!, request.authData!.userId, force, ); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "create", entityType: "work_plan_entry", entityId: (result.data as any).id, newValues: result.data, description: result.description, }); return success(reply, result.data, 201, "Plán vytvořen"); }, ); // --- POST /plan/entries/bulk --- app.post( "/entries/bulk", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const body = parseBody(BulkPlanEntrySchema, request.body); if ("error" in body) return error(reply, body.error, 400); const result = await bulkCreateEntries( body.data!, request.authData!.userId, ); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "create", entityType: "work_plan_entry", description: `Hromadné přiřazení: ${body.data!.category} — ${result.data.users} zaměstnanců, ${body.data!.date_from}–${body.data!.date_to} (${result.data.created_days} dní)`, }); return success(reply, result.data, 201, "Hromadné přiřazení dokončeno"); }, ); // --- PATCH /plan/entries/:id --- app.patch( "/entries/:id", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const id = parseId((request.params as any).id, reply); if (id === null) return; const body = parseBody(UpdatePlanEntrySchema, request.body); if ("error" in body) return error(reply, body.error, 400); const force = forceFromQuery(request.query); const result = await updateEntry( id, body.data!, request.authData!.userId, force, ); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "update", entityType: "work_plan_entry", entityId: id, oldValues: (result.oldData as any) ?? undefined, newValues: result.data as Record, description: result.description, }); return success(reply, result.data, 200, "Plán aktualizován"); }, ); // --- DELETE /plan/entries/:id --- app.delete( "/entries/:id", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const id = parseId((request.params as any).id, reply); if (id === null) return; const force = forceFromQuery(request.query); const result = await deleteEntry(id, request.authData!.userId, force); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "delete", entityType: "work_plan_entry", entityId: id, oldValues: (result.oldData as any) ?? undefined, description: result.description, }); return success(reply, { ok: true }, 200, "Plán smazán"); }, ); // --- POST /plan/overrides --- app.post( "/overrides", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const body = parseBody(CreatePlanOverrideSchema, request.body); if ("error" in body) return error(reply, body.error, 400); const force = forceFromQuery(request.query); const result = await createOverride( body.data!, request.authData!.userId, force, ); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "create", entityType: "work_plan_override", entityId: (result.data as any).id, newValues: result.data, description: result.description, }); return success(reply, result.data, 201, "Přepsání dne vytvořeno"); }, ); // --- PATCH /plan/overrides/:id --- app.patch( "/overrides/:id", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const id = parseId((request.params as any).id, reply); if (id === null) return; const body = parseBody(UpdatePlanOverrideSchema, request.body); if ("error" in body) return error(reply, body.error, 400); const force = forceFromQuery(request.query); const result = await updateOverride( id, body.data!, request.authData!.userId, force, ); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "update", entityType: "work_plan_override", entityId: id, oldValues: (result.oldData as any) ?? undefined, newValues: result.data, description: result.description, }); return success(reply, result.data, 200, "Přepsání aktualizováno"); }, ); // --- DELETE /plan/overrides/:id --- app.delete( "/overrides/:id", { preHandler: requirePermission("attendance.manage") }, async (request: FastifyRequest, reply: FastifyReply) => { const id = parseId((request.params as any).id, reply); if (id === null) return; const force = forceFromQuery(request.query); const result = await deleteOverride(id, request.authData!.userId, force); if ("error" in result) return error(reply, result.error, result.status); await logAudit({ request, authData: request.authData, action: "delete", entityType: "work_plan_override", entityId: id, oldValues: (result.oldData as any) ?? undefined, description: result.description, }); return success(reply, { ok: true }, 200, "Přepsání smazáno"); }, ); }