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); } /** * 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 { 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>> { 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> = {}; 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 { 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 = | { 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> { // 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> { 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> { 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> { 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> { 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> { 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> { 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" }], }); }