import prisma from "../config/database"; import { buildPlanAuditDescription, planCategoryLabel as fallbackCategoryLabel, } from "../utils/planAuditDescription"; /** * Resolve display names for an audit description: the employee's full name * and the project name (or null when there is no project). Used so audit-log * rows show "Jan Novák · … · Projekt ABC" instead of bare numeric ids. * Falls back to "#id" placeholders if a row was concurrently removed. */ async function resolvePlanLabels( userId: number, projectId: number | null, ): Promise<{ userName: string; projectName: string | null }> { const [user, project] = await Promise.all([ prisma.users.findUnique({ where: { id: userId }, select: { first_name: true, last_name: true, username: true }, }), projectId ? prisma.projects.findUnique({ where: { id: projectId }, select: { name: true, project_number: true }, }) : Promise.resolve(null), ]); const userName = user ? `${user.first_name} ${user.last_name}`.trim() || user.username : `uživatel #${userId}`; const projectName = project ? project.name?.trim() || project.project_number || `projekt #${projectId}` : null; return { userName, projectName }; } /** Resolve a category key to its Czech label from the DB, falling back to * the built-in map then the raw key. */ async function resolveCategoryLabel(key: string): Promise { const cat = await prisma.plan_categories.findUnique({ where: { key }, select: { label: true }, }); return cat?.label ?? fallbackCategoryLabel(key); } /** Returns an error result if the category key is not an active category. */ async function assertActiveCategory( key: string, ): Promise<{ error: string; status: number } | null> { const cat = await prisma.plan_categories.findFirst({ where: { key, is_active: true }, select: { id: true }, }); if (!cat) return { error: "Neplatná nebo neaktivní kategorie", status: 400 }; return null; } /** YYYY-MM-DD from a Prisma @db.Date value. Matches how the rest of this * file reads date-only columns (UTC slice), so it is timezone-stable. */ function isoDay(d: Date): string { return d.toISOString().slice(0, 10); } /** Prisma `select` for the joined project, plus a mapper to the cell's * project_number/project_name fields. Used so the grid carries the project * label without a second client-side lookup. */ const projectSelect = { select: { project_number: true, name: true } } as const; function projectFields( p: { project_number: string | null; name: string | null } | null | undefined, ): { project_number: string | null; project_name: string | null } { return { project_number: p?.project_number ?? null, project_name: p?.name ?? null, }; } /** * Resolved plan cell for a single (user, date). Either from a range entry * (work_plan_entries) or from a single-day override (work_plan_overrides). */ export interface ResolvedCell { /** Where the cell came from. */ source: "entry" | "override"; /** ID of the work_plan_entries row, or null when source is "override". */ entryId: number | null; /** ID of the work_plan_overrides row, or null when source is "entry". */ overrideId: number | null; user_id: number; /** The date the cell resolves to (echoes the input, YYYY-MM-DD). */ shift_date: string; project_id: number | null; /** Project number/name resolved server-side from the projects relation, * so the UI never depends on a separately-fetched (and capped/permission- * gated) projects list to display the project. Null when no project. */ project_number: string | null; project_name: string | null; category: string; note: string; /** Original entry range, or null when source is "override". */ rangeFrom: string | null; rangeTo: string | null; } /** * Compute the effective plan cell for (userId, dateStr). * * Precedence: override > entry > null. * Soft-deleted rows are ignored (is_deleted = false filter). * If multiple entries cover the same day, the latest by created_at wins * and a warning is logged. * * dateStr must be a YYYY-MM-DD string. The function builds a JS Date * from it and Prisma handles timezone conversion against the MySQL * @db.Date columns. */ export async function resolveCell( userId: number, dateStr: string, ): Promise { const date = new Date(dateStr); // 1. Single-day override for this exact date const override = await prisma.work_plan_overrides.findFirst({ where: { user_id: userId, shift_date: date, is_deleted: false }, include: { projects: projectSelect }, }); if (override) { return { source: "override", entryId: null, overrideId: override.id, user_id: override.user_id, shift_date: dateStr, project_id: override.project_id, ...projectFields(override.projects), category: override.category, note: override.note, rangeFrom: null, rangeTo: null, }; } // 2. Range entry that covers the day const entries = await prisma.work_plan_entries.findMany({ where: { user_id: userId, date_from: { lte: date }, date_to: { gte: date }, is_deleted: false, }, orderBy: { created_at: "desc" }, include: { projects: projectSelect }, }); if (entries.length === 0) return null; if (entries.length > 1) { // Multiple entries overlap. Use the latest. A persistent audit-log // entry would be ideal here, but logAudit() currently requires a // FastifyRequest — services have no request. Use console.warn as a // stand-in until a service-context audit logger is introduced. console.warn( `[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`, ); } const entry = entries[0]; return { source: "entry", entryId: entry.id, overrideId: null, user_id: entry.user_id, shift_date: dateStr, project_id: entry.project_id, ...projectFields(entry.projects), category: entry.category, note: entry.note, rangeFrom: entry.date_from.toISOString().slice(0, 10), rangeTo: entry.date_to.toISOString().slice(0, 10), }; } /** * Compute the effective plan cell for every (userId, date) in the * given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell | null. * * Implementation: load all entries and overrides for the range in two * queries, then iterate the dates and resolve each cell in O(1) per * (user, date). This avoids N+1 calls to resolveCell. */ export async function resolveGrid( userIds: number[], dateFromStr: string, dateToStr: string, ): Promise>> { const dateFrom = new Date(dateFromStr); const dateTo = new Date(dateToStr); const [entries, overrides] = await Promise.all([ prisma.work_plan_entries.findMany({ where: { user_id: { in: userIds }, is_deleted: false, date_from: { lte: dateTo }, date_to: { gte: dateFrom }, }, orderBy: { created_at: "desc" }, include: { projects: projectSelect }, }), prisma.work_plan_overrides.findMany({ where: { user_id: { in: userIds }, is_deleted: false, shift_date: { gte: dateFrom, lte: dateTo }, }, include: { projects: projectSelect }, }), ]); // Build a date list (inclusive) const dates: string[] = []; for ( let d = new Date(dateFrom); d <= dateTo; d.setUTCDate(d.getUTCDate() + 1) ) { dates.push(d.toISOString().slice(0, 10)); } const result: Record> = {}; for (const uid of userIds) { result[uid] = {}; for (const dateStr of dates) { // Override first const override = overrides.find( (o) => o.user_id === uid && o.shift_date.toISOString().slice(0, 10) === dateStr, ); if (override) { result[uid][dateStr] = { source: "override", entryId: null, overrideId: override.id, user_id: override.user_id, shift_date: dateStr, project_id: override.project_id, ...projectFields(override.projects), category: override.category, note: override.note, rangeFrom: null, rangeTo: null, }; continue; } // First (latest by created_at) matching entry const entry = entries.find( (e) => e.user_id === uid && e.date_from <= new Date(dateStr) && e.date_to >= new Date(dateStr), ); if (entry) { result[uid][dateStr] = { source: "entry", entryId: entry.id, overrideId: null, user_id: entry.user_id, shift_date: dateStr, project_id: entry.project_id, ...projectFields(entry.projects), category: entry.category, note: entry.note, rangeFrom: entry.date_from.toISOString().slice(0, 10), rangeTo: entry.date_to.toISOString().slice(0, 10), }; } else { result[uid][dateStr] = null; } } } return result; } export interface PlanUser { id: number; full_name: string; username: string; role_name: string | null; is_active: boolean; has_attendance_record: boolean; } /** * Return users that should appear as columns in the plan grid: * everyone with the `attendance.record` permission, ordered by * role/team name then full_name. Admins are included by default. * * The `users` model uses the relation field name `roles` (NOT `role`) * for the foreign key to `roles.id`. The roles-to-permissions join * table is `role_permissions`, which has a relation `permissions`. */ export async function listPlanUsers(): Promise { const rows = await prisma.users.findMany({ where: { is_active: true, roles: { role_permissions: { some: { permissions: { name: "attendance.record" } }, }, }, }, include: { roles: { select: { name: true }, }, }, orderBy: [ { roles: { name: "asc" } }, { last_name: "asc" }, { first_name: "asc" }, ], }); return rows.map((u) => ({ id: u.id, full_name: `${u.first_name} ${u.last_name}`.trim(), username: u.username, role_name: u.roles?.name ?? null, is_active: u.is_active ?? true, has_attendance_record: true, })); } /** * Guard helper: returns { error, status } if `dateStr` is in the past * and `force` is not set. Returns null if the date is editable. * * The comparison uses local Czech time (the project's TZ setting). The * previous implementation used `toISOString().slice(0, 10)`, which is * UTC — that could be off-by-one near day boundaries. The fix mirrors * the `Date.prototype.toJSON` override in `src/config/env.ts` which * also uses local-time getters, so the past-date gate is consistent * with how dates are formatted throughout the rest of the codebase. */ export function assertNotPastDate( dateStr: string, force: boolean, ): { error: string; status: number } | null { if (force) return null; const d = new Date(); const todayStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; if (dateStr < todayStr) { return { error: "Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.", status: 403, }; } return null; } // --------------------------------------------------------------------------- // Entry CRUD // --------------------------------------------------------------------------- /** * Standard service result envelope. On success returns `{ data, oldData }` * where `oldData` is null for creates and the pre-update snapshot for * updates / deletes (so the route handler can call `logAudit` with it). * * The optional `replacedData` field is set only by `createOverride` when * an existing active override for the same (user, date) was soft-deleted * before creating the new one. The route handler uses it to emit a * second audit-log entry for the deletion of the replaced row. * * On failure returns `{ error, status }`. */ export type Result = | { data: T; oldData: unknown | null; replacedData?: unknown | null; /** Human-readable Czech subject for the audit-log row. */ description?: string; /** Audit description for the `replacedData` soft-delete row, if any. */ replacedDescription?: string; } | { error: string; status: number }; /** Convert a YYYY-MM-DD string to a Date at midnight UTC. */ function toDateOnly(dateStr: string): Date { return new Date(dateStr + "T00:00:00.000Z"); } /** Create a work_plan_entries row. Past dates require force=true. */ export async function createEntry( input: { user_id: number; date_from: string; date_to: string; project_id?: number | null; category: string; note: string; }, actorUserId: number, force: boolean, ): Promise< Result<{ id: number; user_id: number; date_from: Date; date_to: Date; project_id: number | null; category: string; note: string; created_by: number; created_at: Date | null; updated_at: Date | null; is_deleted: boolean | null; }> > { // Range check if (input.date_to < input.date_from) { return { error: "Datum do musí být stejné nebo po datumu od", status: 400 }; } // Past-date lock on both endpoints — a range that starts today but // extends into the past would still lock past days in the grid. const lockFrom = assertNotPastDate(input.date_from, force); if (lockFrom) return lockFrom; const lockTo = assertNotPastDate(input.date_to, force); if (lockTo) return lockTo; const catErr = await assertActiveCategory(input.category); if (catErr) return catErr; const created = await prisma.work_plan_entries.create({ data: { user_id: input.user_id, date_from: toDateOnly(input.date_from), date_to: toDateOnly(input.date_to), project_id: input.project_id ?? null, category: input.category, note: input.note, created_by: actorUserId, }, }); const { userName, projectName } = await resolvePlanLabels( created.user_id, created.project_id, ); const description = buildPlanAuditDescription({ userName, categoryLabel: await resolveCategoryLabel(created.category), projectName, dateFrom: input.date_from, dateTo: input.date_to, force, }); return { data: created, oldData: null, description }; } /** Update a work_plan_entries row. Partial update. */ export async function updateEntry( id: number, input: { date_from?: string; date_to?: string; project_id?: number | null; category?: string; note?: string; }, actorUserId: number, force: boolean, ): Promise> { const existing = await prisma.work_plan_entries.findUnique({ where: { id } }); if (!existing || existing.is_deleted) { return { error: "Záznam nenalezen", status: 404 }; } // Past-date lock on both endpoints (using either the new value if // provided, or the existing value as a fallback). const fromStr = input.date_from ?? existing.date_from.toISOString().slice(0, 10); const toStr = input.date_to ?? existing.date_to.toISOString().slice(0, 10); const lockFrom = assertNotPastDate(fromStr, force); if (lockFrom) return lockFrom; const lockTo = assertNotPastDate(toStr, force); if (lockTo) return lockTo; if (input.date_from && input.date_to && input.date_to < input.date_from) { return { error: "Datum do musí být stejné nebo po datumu od", status: 400 }; } // Only validate the category when it actually changes — so editing just the // note of an entry whose category was later retired/hidden still saves. if (input.category && input.category !== existing.category) { const catErr = await assertActiveCategory(input.category); if (catErr) return catErr; } const updated = await prisma.work_plan_entries.update({ where: { id }, data: { date_from: input.date_from ? toDateOnly(input.date_from) : undefined, date_to: input.date_to ? toDateOnly(input.date_to) : undefined, project_id: input.project_id === undefined ? undefined : input.project_id, category: input.category, note: input.note, }, }); const { userName, projectName } = await resolvePlanLabels( updated.user_id, updated.project_id, ); const description = buildPlanAuditDescription({ userName, categoryLabel: await resolveCategoryLabel(updated.category), projectName, dateFrom: isoDay(updated.date_from), dateTo: isoDay(updated.date_to), force, }); return { data: updated, oldData: existing, description }; } /** Soft-delete a work_plan_entries row. */ export async function deleteEntry( id: number, actorUserId: number, force: boolean, ): Promise> { const existing = await prisma.work_plan_entries.findUnique({ where: { id } }); if (!existing || existing.is_deleted) { return { error: "Záznam nenalezen", status: 404 }; } const fromStr = existing.date_from.toISOString().slice(0, 10); const lock = assertNotPastDate(fromStr, force); if (lock) return lock; await prisma.work_plan_entries.update({ where: { id }, data: { is_deleted: true }, }); const { userName, projectName } = await resolvePlanLabels( existing.user_id, existing.project_id, ); const description = buildPlanAuditDescription({ userName, categoryLabel: await resolveCategoryLabel(existing.category), projectName, dateFrom: isoDay(existing.date_from), dateTo: isoDay(existing.date_to), force, }); return { data: { ok: true }, oldData: existing, description }; } // --------------------------------------------------------------------------- // Override CRUD // --------------------------------------------------------------------------- /** * Create a work_plan_overrides row. Replaces any active override for the * same (user_id, shift_date) — soft-deletes the old one. Past dates * require force=true. * * Returns { data, oldData: null, replacedData: existing_or_null } so the * route handler can emit two audit log rows: one for the soft-deleted * previous override, and one for the new one. */ export async function createOverride( input: { user_id: number; shift_date: string; project_id?: number | null; category: string; note: string; }, actorUserId: number, force: boolean, ): Promise> { const lock = assertNotPastDate(input.shift_date, force); if (lock) return lock; const catErr = await assertActiveCategory(input.category); if (catErr) return catErr; // Application-level enforcement of "at most one active override per // (user_id, shift_date)". The previous DB unique constraint was dropped // (see migration 20260605122718_drop_wpo_unique_constraint) because // MySQL unique indexes don't ignore soft-deleted rows, which would have // blocked legitimate soft-delete-then-create replacement. // // Race protection: wrap the soft-delete + create in a transaction with // SELECT ... FOR UPDATE on the colliding row so two concurrent requests // can't both create active overrides for the same user-day. const date = toDateOnly(input.shift_date); const { created, replaced } = await prisma.$transaction(async (tx) => { const existing = await tx.work_plan_overrides.findFirst({ where: { user_id: input.user_id, shift_date: date, is_deleted: false, }, }); if (existing) { // Lock the row before update so a concurrent createOverride for the // same (user, date) waits and sees the soft-deleted state. await tx.$executeRaw`SELECT id FROM work_plan_overrides WHERE id = ${existing.id} FOR UPDATE`; await tx.work_plan_overrides.update({ where: { id: existing.id }, data: { is_deleted: true }, }); } const createdRow = await tx.work_plan_overrides.create({ data: { user_id: input.user_id, shift_date: date, project_id: input.project_id ?? null, category: input.category, note: input.note, created_by: actorUserId, }, }); return { created: createdRow, replaced: existing }; }); const { userName, projectName } = await resolvePlanLabels( created.user_id, created.project_id, ); const description = buildPlanAuditDescription({ userName, categoryLabel: await resolveCategoryLabel(created.category), projectName, dateFrom: input.shift_date, dateTo: input.shift_date, force, }); let replacedDescription: string | undefined; if (replaced) { const r = await resolvePlanLabels(replaced.user_id, replaced.project_id); replacedDescription = buildPlanAuditDescription({ userName: r.userName, categoryLabel: await resolveCategoryLabel(replaced.category), projectName: r.projectName, dateFrom: isoDay(replaced.shift_date), dateTo: isoDay(replaced.shift_date), suffix: "nahrazeno novým záznamem", }); } return { data: created, oldData: null, replacedData: replaced, description, replacedDescription, }; } /** Update a work_plan_overrides row. */ export async function updateOverride( id: number, input: { project_id?: number | null; category?: string; note?: string }, actorUserId: number, force: boolean, ): Promise> { const existing = await prisma.work_plan_overrides.findUnique({ where: { id }, }); if (!existing || existing.is_deleted) { return { error: "Záznam nenalezen", status: 404 }; } const dateStr = existing.shift_date.toISOString().slice(0, 10); const lock = assertNotPastDate(dateStr, force); if (lock) return lock; // Only validate the category when it actually changes (see updateEntry). if (input.category && input.category !== existing.category) { const catErr = await assertActiveCategory(input.category); if (catErr) return catErr; } const updated = await prisma.work_plan_overrides.update({ where: { id }, data: { project_id: input.project_id === undefined ? undefined : input.project_id, category: input.category, note: input.note, }, }); const { userName, projectName } = await resolvePlanLabels( updated.user_id, updated.project_id, ); const description = buildPlanAuditDescription({ userName, categoryLabel: await resolveCategoryLabel(updated.category), projectName, dateFrom: isoDay(updated.shift_date), dateTo: isoDay(updated.shift_date), force, }); return { data: updated, oldData: existing, description }; } /** Soft-delete a work_plan_overrides row. */ export async function deleteOverride( id: number, actorUserId: number, force: boolean, ): Promise> { const existing = await prisma.work_plan_overrides.findUnique({ where: { id }, }); if (!existing || existing.is_deleted) { return { error: "Záznam nenalezen", status: 404 }; } const dateStr = existing.shift_date.toISOString().slice(0, 10); const lock = assertNotPastDate(dateStr, force); if (lock) return lock; await prisma.work_plan_overrides.update({ where: { id }, data: { is_deleted: true }, }); const { userName, projectName } = await resolvePlanLabels( existing.user_id, existing.project_id, ); const description = buildPlanAuditDescription({ userName, categoryLabel: await resolveCategoryLabel(existing.category), projectName, dateFrom: isoDay(existing.shift_date), dateTo: isoDay(existing.shift_date), force, }); return { data: { ok: true }, oldData: existing, description }; } // --------------------------------------------------------------------------- // Raw-row list endpoints // --------------------------------------------------------------------------- /** List raw work_plan_entries rows in a date range, excluding soft-deleted. * When isAdmin is false, the user_id filter is forced to the actor's own id * (so a non-admin cannot read another user's plan via the user_id query param). */ export async function listEntries( query: { user_id?: number; date_from: string; date_to: string }, actorUserId: number, isAdmin: boolean, ) { const effectiveUserId = isAdmin ? query.user_id : actorUserId; return prisma.work_plan_entries.findMany({ where: { user_id: effectiveUserId, is_deleted: false, date_from: { lte: toDateOnly(query.date_to) }, date_to: { gte: toDateOnly(query.date_from) }, }, orderBy: [{ date_from: "asc" }, { id: "asc" }], }); } /** List raw work_plan_overrides rows in a date range, excluding soft-deleted. * Same employee-scoping rules as listEntries. */ export async function listOverrides( query: { user_id?: number; date_from: string; date_to: string }, actorUserId: number, isAdmin: boolean, ) { const effectiveUserId = isAdmin ? query.user_id : actorUserId; return prisma.work_plan_overrides.findMany({ where: { user_id: effectiveUserId, is_deleted: false, shift_date: { gte: toDateOnly(query.date_from), lte: toDateOnly(query.date_to), }, }, orderBy: [{ shift_date: "asc" }, { id: "asc" }], }); }