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>
1045 lines
33 KiB
TypeScript
1045 lines
33 KiB
TypeScript
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
|
||
* 0–MAX_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[]
|
||
* (0–MAX_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" }],
|
||
});
|
||
}
|