feat(plan): REST routes for plan endpoints

This commit is contained in:
BOHA
2026-06-05 12:40:25 +02:00
parent ef404e4362
commit 4d8fd62961

312
src/routes/admin/plan.ts Normal file
View File

@@ -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<string, unknown>,
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<string, unknown>,
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");
},
);
}