Files
app/src/services/plan.service.ts

952 lines
29 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);
}
/** 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 },
orderBy: { created_at: "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,
},
orderBy: { created_at: "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 },
},
orderBy: { created_at: "desc" },
include: { projects: projectSelect },
}),
prisma.work_plan_overrides.findMany({
where: {
user_id: { in: userIds },
is_deleted: false,
shift_date: { gte: dateFrom, lte: dateTo },
},
orderBy: { created_at: "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,
): 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 },
},
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;
}
/** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry(
input: {
user_id: number;
date_from: string;
date_to: string;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
force: boolean,
): Promise<
Result<{
id: number;
user_id: number;
date_from: Date;
date_to: Date;
project_id: number | null;
category: string;
note: string;
created_by: number;
created_at: Date | null;
updated_at: Date | null;
is_deleted: boolean | null;
}>
> {
// Range check
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
}
// Past-date lock on both endpoints — a range that starts today but
// extends into the past would still lock past days in the grid.
const lockFrom = assertNotPastDate(input.date_from, force);
if (lockFrom) return lockFrom;
const lockTo = assertNotPastDate(input.date_to, force);
if (lockTo) return lockTo;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
const 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,
},
});
const { userName, projectName } = await resolvePlanLabels(
created.user_id,
created.project_id,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(created.category),
projectName,
dateFrom: input.date_from,
dateTo: input.date_to,
force,
});
return { data: created, oldData: null, description };
}
/** Update a work_plan_entries row. Partial update. */
export async function updateEntry(
id: number,
input: {
date_from?: string;
date_to?: string;
project_id?: number | null;
category?: string;
note?: string;
},
actorUserId: number,
force: boolean,
): Promise<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 };
}
// Only validate the category when it actually changes — so editing just the
// note of an entry whose category was later retired/hidden still saves.
if (input.category && input.category !== existing.category) {
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
}
const updated = await prisma.work_plan_entries.update({
where: { id },
data: {
date_from: input.date_from ? toDateOnly(input.date_from) : undefined,
date_to: input.date_to ? toDateOnly(input.date_to) : undefined,
project_id: input.project_id === undefined ? undefined : input.project_id,
category: input.category,
note: input.note,
},
});
const { userName, projectName } = await resolvePlanLabels(
updated.user_id,
updated.project_id,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(updated.category),
projectName,
dateFrom: isoDay(updated.date_from),
dateTo: isoDay(updated.date_to),
force,
});
return { data: updated, oldData: existing, description };
}
/** Soft-delete a work_plan_entries row. */
export async function deleteEntry(
id: number,
actorUserId: number,
force: boolean,
): Promise<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 } = await resolvePlanLabels(
existing.user_id,
existing.project_id,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(existing.category),
projectName,
dateFrom: isoDay(existing.date_from),
dateTo: isoDay(existing.date_to),
force,
});
return { data: { ok: true }, oldData: existing, description };
}
// ---------------------------------------------------------------------------
// Override CRUD
// ---------------------------------------------------------------------------
/**
* Create a work_plan_overrides row. 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 } = await resolvePlanLabels(
created.user_id,
created.project_id,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(created.category),
projectName,
dateFrom: input.shift_date,
dateTo: input.shift_date,
force,
});
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 } = await resolvePlanLabels(
updated.user_id,
updated.project_id,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(updated.category),
projectName,
dateFrom: isoDay(updated.shift_date),
dateTo: isoDay(updated.shift_date),
force,
});
return { data: updated, oldData: existing, description };
}
/** Soft-delete a work_plan_overrides row. */
export async function deleteOverride(
id: number,
actorUserId: number,
force: boolean,
): Promise<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 } = await resolvePlanLabels(
existing.user_id,
existing.project_id,
);
const description = buildPlanAuditDescription({
userName,
categoryLabel: await resolveCategoryLabel(existing.category),
projectName,
dateFrom: isoDay(existing.shift_date),
dateTo: isoDay(existing.shift_date),
force,
});
return { data: { ok: true }, oldData: existing, description };
}
// ---------------------------------------------------------------------------
// 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);
// 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;
const res = await createEntry(
{
user_id: userId,
date_from: segFrom,
date_to: segTo,
project_id: input.project_id ?? null,
category: input.category,
note: input.note,
},
actorUserId,
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" }],
});
}