Files
app/src/services/plan.service.ts
BOHA e11765bf0e fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned)
Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
  failed deficit line rolls back the surplus corrective receipt — retries
  no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
  of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
  (in-transaction, clamped at 0)

High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
  the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
  when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
  (Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
  hours against its own balance (mirrors createLeave)

Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
  500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
  Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
  (recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
  @db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
  /items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
  finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
  settings.company|settings.system; Settings System tab no longer clobbers
  Firma numbering patterns; draft invoices hide the dead PDF button;
  dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
  audit-log + invoice month buckets day-shift fixes

Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.

~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:00:19 +02:00

1045 lines
33 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 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<string> {
const cat = await prisma.plan_categories.findUnique({
where: { key },
select: { label: true },
});
return cat?.label ?? fallbackCategoryLabel(key);
}
/**
* Resolve everything an audit description needs (user name, project name,
* category label) for a single mutation in ONE parallel batch instead of two
* sequential awaits (resolvePlanLabels then resolveCategoryLabel). Same three
* queries, but fired together so the audit lookup adds one round-trip, not two.
* The produced description is byte-for-byte identical to the previous code.
*/
async function resolveAuditLabels(
userId: number,
projectId: number | null,
category: string,
): Promise<{
userName: string;
projectName: string | null;
categoryLabel: string;
}> {
const [{ userName, projectName }, categoryLabel] = await Promise.all([
resolvePlanLabels(userId, projectId),
resolveCategoryLabel(category),
]);
return { userName, projectName, categoryLabel };
}
/** 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 records for (userId, dateStr) as an array of
* 0MAX_RECORDS_PER_CELL records.
*
* Layering: if any override exists for the day, the overrides are returned
* (newest-first) and the range entries are hidden; otherwise the range
* entries covering the day are returned (newest-first). Soft-deleted rows
* are ignored (is_deleted = false filter). The result is capped at
* MAX_RECORDS_PER_CELL.
*
* 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<ResolvedCell[]> {
const date = new Date(dateStr);
const overrides = await prisma.work_plan_overrides.findMany({
where: { user_id: userId, shift_date: date, is_deleted: false },
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
if (overrides.length > 0) {
return overrides.map((o) => ({
source: "override" as const,
entryId: null,
overrideId: o.id,
user_id: o.user_id,
shift_date: dateStr,
project_id: o.project_id,
...projectFields(o.projects),
category: o.category,
note: o.note,
rangeFrom: null,
rangeTo: null,
}));
}
const entries = await prisma.work_plan_entries.findMany({
where: {
user_id: userId,
date_from: { lte: date },
date_to: { gte: date },
is_deleted: false,
},
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
take: MAX_RECORDS_PER_CELL,
include: { projects: projectSelect },
});
return entries.map((entry) => ({
source: "entry" as const,
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 records for every (userId, date) in the
* given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell[]
* (0MAX_RECORDS_PER_CELL records, same layering as resolveCell; empty
* days are []).
*
* 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<Record<number, Record<string, ResolvedCell[]>>> {
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 },
},
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { projects: projectSelect },
}),
prisma.work_plan_overrides.findMany({
where: {
user_id: { in: userIds },
is_deleted: false,
shift_date: { gte: dateFrom, lte: dateTo },
},
// created_at is second-precision, so same-second rows tie — `id` desc is
// the deterministic "newest-first" tiebreak (matches insertion order).
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { projects: projectSelect },
}),
]);
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<number, Record<string, ResolvedCell[]>> = {};
for (const uid of userIds) {
result[uid] = {};
for (const dateStr of dates) {
const day = new Date(dateStr);
const dayOverrides = overrides
.filter(
(o) =>
o.user_id === uid &&
o.shift_date.toISOString().slice(0, 10) === dateStr,
)
.slice(0, MAX_RECORDS_PER_CELL);
if (dayOverrides.length > 0) {
result[uid][dateStr] = dayOverrides.map((o) => ({
source: "override" as const,
entryId: null,
overrideId: o.id,
user_id: o.user_id,
shift_date: dateStr,
project_id: o.project_id,
...projectFields(o.projects),
category: o.category,
note: o.note,
rangeFrom: null,
rangeTo: null,
}));
continue;
}
const dayEntries = entries
.filter(
(e) => e.user_id === uid && e.date_from <= day && e.date_to >= day,
)
.slice(0, MAX_RECORDS_PER_CELL);
result[uid][dateStr] = dayEntries.map((e) => ({
source: "entry" as const,
entryId: e.id,
overrideId: null,
user_id: e.user_id,
shift_date: dateStr,
project_id: e.project_id,
...projectFields(e.projects),
category: e.category,
note: e.note,
rangeFrom: e.date_from.toISOString().slice(0, 10),
rangeTo: e.date_to.toISOString().slice(0, 10),
}));
}
}
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<PlanUser[]> {
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).
*
* On failure returns `{ error, status }`.
*/
export type Result<T> =
| {
data: T;
oldData: unknown | null;
/** Human-readable Czech subject for the audit-log row. */
description?: 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");
}
/** Maximum number of records (entries OR overrides) shown per cell per day. */
export const MAX_RECORDS_PER_CELL = 3;
/**
* Returns { error, status } if creating an entry over [dateFromStr, dateToStr]
* would push any single day past MAX_RECORDS_PER_CELL active entries for the
* user. Names the first offending date. Counts entries regardless of whether an
* override currently hides them (simpler and predictable).
*/
async function assertEntryCapAvailable(
userId: number,
dateFromStr: string,
dateToStr: string,
excludeEntryId?: number,
): Promise<{ error: string; status: number } | null> {
const from = toDateOnly(dateFromStr);
const to = toDateOnly(dateToStr);
const existing = await prisma.work_plan_entries.findMany({
where: {
user_id: userId,
is_deleted: false,
date_from: { lte: to },
date_to: { gte: from },
// On update, the entry being moved must not count against itself —
// otherwise an unmoved at-cap entry would be uneditable.
...(excludeEntryId !== undefined ? { id: { not: excludeEntryId } } : {}),
},
select: { date_from: true, date_to: true },
});
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
const day = new Date(d);
const count = existing.filter(
(e) => e.date_from <= day && e.date_to >= day,
).length;
if (count >= MAX_RECORDS_PER_CELL) {
return {
error: `Na den ${day.toISOString().slice(0, 10)} jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`,
status: 400,
};
}
}
return null;
}
type CreatedEntry = {
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;
};
/**
* Core create logic shared by the public createEntry and bulkCreateEntries.
* `resolveLabels` controls the audit-description lookup: the single-mutation
* route needs it, but bulkCreateEntries discards the per-entry description (it
* builds its own summary), so it skips the 3 label queries per run entirely —
* removing the per-run audit N+1.
*/
async function createEntryCore(
input: {
user_id: number;
date_from: string;
date_to: string;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
force: boolean,
resolveLabels: boolean,
): Promise<Result<CreatedEntry>> {
// 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 capErr = await assertEntryCapAvailable(
input.user_id,
input.date_from,
input.date_to,
);
if (capErr) return capErr;
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,
},
});
if (!resolveLabels) {
return { data: created, oldData: null };
}
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
created.user_id,
created.project_id,
created.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel,
projectName,
dateFrom: input.date_from,
dateTo: input.date_to,
force,
});
return { data: created, oldData: null, description };
}
/** 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<CreatedEntry>> {
return createEntryCore(input, actorUserId, force, true);
}
/** 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<Result<unknown>> {
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 };
}
// Re-check the per-cell cap when the range changes — expanding onto a day
// already holding MAX_RECORDS_PER_CELL active entries would create a 4th
// that the grid (display-capped at 3) silently hides. The create paths
// guard this; the update path must too.
if (input.date_from || input.date_to) {
const capErr = await assertEntryCapAvailable(
existing.user_id,
fromStr,
toStr,
id,
);
if (capErr) return capErr;
}
// 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, categoryLabel } = await resolveAuditLabels(
updated.user_id,
updated.project_id,
updated.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel,
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<Result<{ ok: true }>> {
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, categoryLabel } = await resolveAuditLabels(
existing.user_id,
existing.project_id,
existing.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel,
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. Additive: stacks up to
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
* past the cap returns { error, status: 400 }. Past dates require force=true.
* Returns { data, oldData: null, description }.
*/
export async function createOverride(
input: {
user_id: number;
shift_date: string;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
force: boolean,
): Promise<Result<any>> {
const lock = assertNotPastDate(input.shift_date, force);
if (lock) return lock;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
const date = toDateOnly(input.shift_date);
// Additive up to MAX_RECORDS_PER_CELL. (This previously REPLACED the existing
// override; multi-record cells stack instead.) count+create runs in one
// transaction; a concurrent create could in theory add one extra row, but
// resolve display caps at MAX_RECORDS_PER_CELL so that is benign.
let created;
try {
created = await prisma.$transaction(async (tx) => {
const count = await tx.work_plan_overrides.count({
where: { user_id: input.user_id, shift_date: date, is_deleted: false },
});
if (count >= MAX_RECORDS_PER_CELL) {
throw Object.assign(new Error("cap"), { __cap: true });
}
return 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,
},
});
});
} catch (e) {
if (e && typeof e === "object" && "__cap" in e) {
return {
error: `Na tento den jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`,
status: 400,
};
}
throw e;
}
const { userName, projectName, categoryLabel } = await resolveAuditLabels(
created.user_id,
created.project_id,
created.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel,
projectName,
dateFrom: input.shift_date,
dateTo: input.shift_date,
force,
});
return { data: created, oldData: null, description };
}
/** 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<Result<any>> {
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, categoryLabel } = await resolveAuditLabels(
updated.user_id,
updated.project_id,
updated.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel,
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<Result<{ ok: true }>> {
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, categoryLabel } = await resolveAuditLabels(
existing.user_id,
existing.project_id,
existing.category,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel,
projectName,
dateFrom: isoDay(existing.shift_date),
dateTo: isoDay(existing.shift_date),
force,
});
return { data: { ok: true }, oldData: existing, description };
}
// ---------------------------------------------------------------------------
// Bulk entry creation
// ---------------------------------------------------------------------------
/**
* Bulk-assign a project/category to many employees over a date range. For each
* employee, walk the range, keep the days that pass the weekday filter
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
* group contiguous kept days into runs, and create one range entry per run by
* reusing createEntry (so cap / past-date / category validation stays DRY).
*
* Days that pass the weekday filter but are already at the cap are counted in
* skipped_days (and split the range). Weekend days, when excluded, are out of
* scope and are NOT counted as skipped.
*/
export async function bulkCreateEntries(
input: {
user_ids: number[];
date_from: string;
date_to: string;
include_weekends: boolean;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
): Promise<
Result<{
created_entries: number;
created_days: number;
skipped_days: number;
users: number;
}>
> {
if (input.user_ids.length === 0) {
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
}
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
}
const lockFrom = assertNotPastDate(input.date_from, false);
if (lockFrom) return lockFrom;
const lockTo = assertNotPastDate(input.date_to, false);
if (lockTo) return lockTo;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
const from = toDateOnly(input.date_from);
const to = toDateOnly(input.date_to);
// Bound the work (users × days). Mirror the 92-day cap on GET /plan/grid so
// one bulk call can't fan out into thousands of inserts.
const rangeDays = Math.round((to.getTime() - from.getTime()) / 86400000) + 1;
if (rangeDays > 92) {
return { error: "Maximální rozsah je 92 dní", status: 400 };
}
// The day list for the whole range (UTC midnights), built once.
const days: Date[] = [];
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
days.push(new Date(d));
}
let createdEntries = 0;
let createdDays = 0;
let skippedDays = 0;
for (const userId of input.user_ids) {
// Per-day cap counts from this user's existing active entries.
const existing = await prisma.work_plan_entries.findMany({
where: {
user_id: userId,
is_deleted: false,
date_from: { lte: to },
date_to: { gte: from },
},
select: { date_from: true, date_to: true },
});
const countFor = (day: Date): number =>
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
const eligible: boolean[] = [];
for (const day of days) {
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
const isWeekend = dow === 0 || dow === 6;
if (isWeekend && !input.include_weekends) {
eligible.push(false);
continue;
}
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
skippedDays++;
eligible.push(false);
continue;
}
eligible.push(true);
}
// Group contiguous eligible days into runs; create one range per run.
let runStart = -1;
for (let i = 0; i <= days.length; i++) {
const ok = i < days.length && eligible[i];
if (ok && runStart === -1) {
runStart = i;
} else if (!ok && runStart !== -1) {
const segFrom = days[runStart].toISOString().slice(0, 10);
const segTo = days[i - 1].toISOString().slice(0, 10);
const runDays = i - runStart;
// Skip audit-label resolution: bulk builds its own summary and discards
// each entry's description, so resolving labels per run would be pure
// wasted queries (the per-run audit N+1 the audit flagged).
const res = await createEntryCore(
{
user_id: userId,
date_from: segFrom,
date_to: segTo,
project_id: input.project_id ?? null,
category: input.category,
note: input.note,
},
actorUserId,
false,
false,
);
if ("data" in res) {
createdEntries++;
createdDays += runDays;
} else {
// Rare race (e.g. the cap filled concurrently between the count and
// the create). Treat the run's days as skipped, but log — services
// must not swallow non-fatal failures (CLAUDE.md known-issue #4).
console.warn("[plan.service] bulkCreateEntries: segment skipped", {
userId,
segFrom,
segTo,
error: res.error,
});
skippedDays += runDays;
}
runStart = -1;
}
}
}
return {
data: {
created_entries: createdEntries,
created_days: createdDays,
skipped_days: skippedDays,
users: input.user_ids.length,
},
oldData: null,
};
}
// ---------------------------------------------------------------------------
// 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" }],
});
}