diff --git a/src/routes/admin/plan.ts b/src/routes/admin/plan.ts new file mode 100644 index 0000000..066a571 --- /dev/null +++ b/src/routes/admin/plan.ts @@ -0,0 +1,312 @@ +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 { logAudit } from "../../services/audit"; +import { + CreatePlanEntrySchema, + UpdatePlanEntrySchema, + CreatePlanOverrideSchema, + UpdatePlanOverrideSchema, + PlanRangeQuerySchema, + PlanGridQuerySchema, +} from "../../schemas/plan.schema"; +import { + resolveGrid, + listPlanUsers, + createEntry, + updateEntry, + deleteEntry, + createOverride, + updateOverride, + deleteOverride, + listEntries, + listOverrides, +} from "../../services/plan.service"; + +const MAX_RANGE_DAYS = 92; + +function isAdminLike(authData: any): boolean { + return authData?.role === "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); + }, + ); + + // --- 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: force ? "force-edit past date" : undefined, + }); + return success(reply, result.data, 201, "Plán vytvořen"); + }, + ); + + // --- 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: force ? "force-edit past date" : undefined, + }); + 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: force ? "force-edit past date" : undefined, + }); + 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); + + // If the create replaced an existing active override, log the soft-delete first. + if (result.replacedData) { + await logAudit({ + request, + authData: request.authData, + action: "delete", + entityType: "work_plan_override", + entityId: (result.replacedData as any).id, + oldValues: result.replacedData as Record, + description: "auto-soft-delete before replace", + }); + } + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "work_plan_override", + entityId: (result.data as any).id, + newValues: result.data, + description: force ? "force-edit past date" : undefined, + }); + 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: force ? "force-edit past date" : undefined, + }); + 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: force ? "force-edit past date" : undefined, + }); + return success(reply, { ok: true }, 200, "Přepsání smazáno"); + }, + ); +}