DELETE /plan/categories/:id now hard-deletes the row, guarded: refuses when any live entry/override uses the key (suggests hide instead) and keeps >=1 active category. Modal gains a per-row Smazat with inline confirm. Hiding stays via PATCH is_active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
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";
|
|
import {
|
|
CreatePlanCategorySchema,
|
|
UpdatePlanCategorySchema,
|
|
} from "../../schemas/planCategory.schema";
|
|
import {
|
|
listPlanCategories,
|
|
createPlanCategory,
|
|
updatePlanCategory,
|
|
deletePlanCategory,
|
|
} from "../../services/planCategory.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);
|
|
},
|
|
);
|
|
|
|
// --- 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");
|
|
},
|
|
);
|
|
|
|
// --- 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);
|
|
|
|
// 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: result.replacedDescription,
|
|
});
|
|
}
|
|
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");
|
|
},
|
|
);
|
|
}
|