Files
app/src/routes/admin/plan.ts

423 lines
14 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, 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<string, unknown>,
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");
},
);
}