v1.6.5: fix attendance unique constraint, schema sync, seed script

- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-17 16:21:34 +02:00
parent 1db5060c42
commit cd6d3daf43
75 changed files with 1748 additions and 1169 deletions

View File

@@ -100,7 +100,7 @@ export default async function attendanceRoutes(
// --- action=balances: leave balance overview for all users ---
if (action === "balances") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.balances")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
@@ -110,7 +110,7 @@ export default async function attendanceRoutes(
// --- action=workfund: monthly work fund overview ---
if (action === "workfund") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
@@ -120,7 +120,7 @@ export default async function attendanceRoutes(
// --- action=project_report: monthly project hours ---
if (action === "project_report") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const yr = Number(query.year) || new Date().getFullYear();
@@ -130,7 +130,7 @@ export default async function attendanceRoutes(
// --- action=print: attendance print data for admin ---
if (action === "print") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -145,8 +145,7 @@ export default async function attendanceRoutes(
// --- action=attendance_users: users with attendance.record permission ---
if (action === "attendance_users") {
if (
!authData.permissions.includes("attendance.admin") &&
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.manage") &&
!authData.permissions.includes("attendance.record")
) {
return error(reply, "Nedostatečná oprávnění", 403);
@@ -182,10 +181,7 @@ export default async function attendanceRoutes(
// --- action=project_logs: get project logs for a specific attendance record ---
if (action === "project_logs") {
if (
!authData.permissions.includes("attendance.view") &&
!authData.permissions.includes("attendance.record")
) {
if (!authData.permissions.includes("attendance.record")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
const attendanceId = Number(query.attendance_id);
@@ -200,7 +196,7 @@ export default async function attendanceRoutes(
if (!id) return error(reply, "Missing id", 400);
const record = await attendanceService.getLocationRecord(id);
if (!record) return error(reply, "Záznam nenalezen", 404);
const isAdmin = authData.permissions.includes("attendance.admin");
const isAdmin = authData.permissions.includes("attendance.manage");
if (record.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -209,7 +205,7 @@ export default async function attendanceRoutes(
// --- Default: paginated records list ---
const { page, limit, skip, order } = parsePagination(query);
const isAdmin = authData.permissions.includes("attendance.admin");
const isAdmin = authData.permissions.includes("attendance.manage");
const userId = query.user_id ? Number(query.user_id) : undefined;
const result = await attendanceService.listAttendance({
@@ -285,7 +281,7 @@ export default async function attendanceRoutes(
// --- action=bulk_attendance: bulk fill month ---
if (postQuery.action === "bulk_attendance") {
if (!authData.permissions.includes("attendance.admin")) {
if (!authData.permissions.includes("attendance.manage")) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -328,7 +324,7 @@ export default async function attendanceRoutes(
if (
leaveBody.user_id != null &&
leaveBody.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
!authData.permissions.includes("attendance.manage")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -384,7 +380,7 @@ export default async function attendanceRoutes(
if (
body.user_id != null &&
body.user_id !== authData.userId &&
!authData.permissions.includes("attendance.admin")
!authData.permissions.includes("attendance.manage")
) {
return error(reply, "Nedostatečná oprávnění", 403);
}
@@ -429,7 +425,7 @@ export default async function attendanceRoutes(
// PUT /api/admin/attendance/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.edit") },
{ preHandler: requirePermission("attendance.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -438,7 +434,7 @@ export default async function attendanceRoutes(
const body = parsed.data;
const authData = request.authData!;
const isAdmin = authData.permissions.includes("attendance.admin");
const isAdmin = authData.permissions.includes("attendance.manage");
const result = await attendanceService.updateAttendance(
id,
@@ -481,7 +477,7 @@ export default async function attendanceRoutes(
// DELETE /api/admin/attendance/:id
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("attendance.admin") },
{ preHandler: requirePermission("attendance.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -14,7 +14,7 @@ export default async function bankAccountsRoutes(
): Promise<void> {
fastify.get(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (_request, reply) => {
const accounts = await prisma.bank_accounts.findMany({
orderBy: { position: "asc" },
@@ -25,7 +25,7 @@ export default async function bankAccountsRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (request, reply) => {
const parsed = parseBody(CreateBankAccountSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -59,7 +59,7 @@ export default async function bankAccountsRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -126,7 +126,7 @@ export default async function bankAccountsRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.banking") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -99,7 +99,7 @@ export default async function companySettingsRoutes(
// POST /api/admin/company-settings/logo?variant=light|dark
fastify.post(
"/logo",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.company") },
async (request, reply) => {
const query = request.query as Record<string, string>;
const variant = query.variant === "dark" ? "dark" : "light";
@@ -333,7 +333,7 @@ export default async function companySettingsRoutes(
// GET /api/admin/company-settings/system-info
fastify.get(
"/system-info",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.company") },
async (request, reply) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string };
@@ -404,7 +404,7 @@ export default async function companySettingsRoutes(
fastify.put(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.company") },
async (request, reply) => {
const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);

View File

@@ -126,7 +126,7 @@ export default async function customersRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("customers.manage") },
{ preHandler: requirePermission("customers.create") },
async (request, reply) => {
const parsed = parseBody(CreateCustomerSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -164,7 +164,7 @@ export default async function customersRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("customers.manage") },
{ preHandler: requirePermission("customers.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -239,7 +239,7 @@ export default async function customersRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("customers.manage") },
{ preHandler: requirePermission("customers.delete") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -42,8 +42,8 @@ export default async function dashboardRoutes(
result.my_shift = { has_ongoing: myShift !== null };
}
// Attendance admin — only for attendance.admin
if (has("attendance.admin")) {
// Attendance admin — only for attendance.manage
if (has("attendance.manage")) {
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([
prisma.attendance.findMany({
where: {

View File

@@ -36,6 +36,13 @@ export default async function profileRoutes(
const body = parsed.data;
const userId = request.authData!.userId;
if (body.new_password && !body.current_password) {
return error(reply, "Pro změnu hesla zadejte aktuální heslo", 400);
}
if (body.current_password && !body.new_password) {
return error(reply, "Pro změnu hesla zadejte nové heslo", 400);
}
const data: Record<string, unknown> = {};
if (body.email) {
data.email = String(body.email).trim();

View File

@@ -12,7 +12,7 @@ export default async function rolesRoutes(
// GET /api/admin/roles
fastify.get(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const roles = await prisma.roles.findMany({
include: {
@@ -35,7 +35,7 @@ export default async function rolesRoutes(
// GET /api/admin/roles/permissions
fastify.get(
"/permissions",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (_request, reply) => {
const permissions = await prisma.permissions.findMany({
orderBy: { module: "asc" },
@@ -47,7 +47,7 @@ export default async function rolesRoutes(
// POST /api/admin/roles
fastify.post(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const parsed = parseBody(CreateRoleSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -90,7 +90,7 @@ export default async function rolesRoutes(
// PUT /api/admin/roles/:id
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -142,7 +142,7 @@ export default async function rolesRoutes(
// DELETE /api/admin/roles/:id
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.roles") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -22,7 +22,7 @@ export default async function scopeTemplatesRoutes(
// Legacy ?action= dispatcher for item templates
fastify.get(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const action = query.action ? String(query.action) : null;
@@ -53,7 +53,7 @@ export default async function scopeTemplatesRoutes(
// Item template CRUD via ?action=item
fastify.post(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
@@ -121,7 +121,7 @@ export default async function scopeTemplatesRoutes(
// Item template delete via DELETE ?action=item&id=X
fastify.delete(
"/",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
@@ -140,7 +140,7 @@ export default async function scopeTemplatesRoutes(
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -161,7 +161,7 @@ export default async function scopeTemplatesRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -210,7 +210,7 @@ export default async function scopeTemplatesRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("settings.manage") },
{ preHandler: requirePermission("settings.templates") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -193,7 +193,7 @@ export default async function totpRoutes(
// GET - check if 2FA is required company-wide
fastify.get(
"/required",
{ preHandler: [requireAuth, requirePermission("settings.manage")] },
{ preHandler: requireAuth },
async (request, reply) => {
const settings = await prisma.company_settings.findFirst({
select: { require_2fa: true },
@@ -207,7 +207,7 @@ export default async function totpRoutes(
fastify.post(
"/required",
{
preHandler: [requireAuth, requirePermission("settings.manage")],
preHandler: [requireAuth, requirePermission("settings.company")],
bodyLimit: 10240,
},
async (request, reply) => {

View File

@@ -14,7 +14,12 @@ export default async function tripsRoutes(
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
const isAdmin = authData.permissions.includes("trips.admin");
const hasTripsAccess =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record") ||
authData.permissions.includes("trips.history");
if (!hasTripsAccess) return error(reply, "Nedostatečná oprávnění", 403);
const isAdmin = authData.permissions.includes("trips.manage");
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
@@ -107,7 +112,7 @@ export default async function tripsRoutes(
// GET /api/admin/trips/print — print data for trip report
fastify.get(
"/print",
{ preHandler: requirePermission("trips.admin") },
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const filterUserId = query.user_id ? Number(query.user_id) : null;
@@ -182,7 +187,7 @@ export default async function tripsRoutes(
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
fastify.get<{ Params: { vehicleId: string } }>(
"/last-km/:vehicleId",
{ preHandler: requirePermission("trips.view") },
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const vehicleId = parseInt(request.params.vehicleId, 10);
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
@@ -213,6 +218,11 @@ export default async function tripsRoutes(
const body = parsed.data;
const authData = request.authData!;
const canRecord =
authData.permissions.includes("trips.manage") ||
authData.permissions.includes("trips.record");
if (!canRecord) return error(reply, "Nedostatečná oprávnění", 403);
if (body.end_km < body.start_km) {
return error(reply, "Konečný stav km nesmí být menší než počáteční", 400);
}
@@ -274,7 +284,7 @@ export default async function tripsRoutes(
if (!existing) return error(reply, "Jízda nenalezena", 404);
// Ownership check — same as DELETE handler
const isAdmin = authData.permissions.includes("trips.admin");
const isAdmin = authData.permissions.includes("trips.manage");
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nemáte oprávnění upravit tuto jízdu", 403);
}
@@ -332,7 +342,7 @@ export default async function tripsRoutes(
if (!existing) return error(reply, "Jízda nenalezena", 404);
// Allow users to delete their own trips, admins can delete any
const isAdmin = authData.permissions.includes("trips.admin");
const isAdmin = authData.permissions.includes("trips.manage");
if (existing.user_id !== authData.userId && !isAdmin) {
return error(reply, "Nemáte oprávnění smazat tuto jízdu", 403);
}

View File

@@ -44,7 +44,7 @@ export default async function vehiclesRoutes(
fastify.post(
"/",
{ preHandler: requirePermission("trips.vehicles") },
{ preHandler: requirePermission("vehicles.manage") },
async (request, reply) => {
const parsed = parseBody(CreateVehicleSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -75,7 +75,7 @@ export default async function vehiclesRoutes(
fastify.put<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("trips.vehicles") },
{ preHandler: requirePermission("vehicles.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
@@ -124,7 +124,7 @@ export default async function vehiclesRoutes(
fastify.delete<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("trips.vehicles") },
{ preHandler: requirePermission("vehicles.manage") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;