isAdminLike read authData.role, which doesn't exist on AuthData (it's roleName; role is on JwtPayload), so admins were always self-scoped on /entries and /overrides. Typed the param (no more any). Also removed 4 'category: input.category as any' casts now that category is a String column. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
812 lines
25 KiB
TypeScript
812 lines
25 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);
|
|
}
|
|
|
|
/** Returns an error result if the category key is not an active category. */
|
|
async function assertActiveCategory(
|
|
key: string,
|
|
): Promise<{ error: string; status: number } | null> {
|
|
const cat = await prisma.plan_categories.findFirst({
|
|
where: { key, is_active: true },
|
|
select: { id: true },
|
|
});
|
|
if (!cat) return { error: "Neplatná nebo neaktivní kategorie", status: 400 };
|
|
return null;
|
|
}
|
|
|
|
/** YYYY-MM-DD from a Prisma @db.Date value. Matches how the rest of this
|
|
* file reads date-only columns (UTC slice), so it is timezone-stable. */
|
|
function isoDay(d: Date): string {
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
/** Prisma `select` for the joined project, plus a mapper to the cell's
|
|
* project_number/project_name fields. Used so the grid carries the project
|
|
* label without a second client-side lookup. */
|
|
const projectSelect = { select: { project_number: true, name: true } } as const;
|
|
|
|
function projectFields(
|
|
p: { project_number: string | null; name: string | null } | null | undefined,
|
|
): { project_number: string | null; project_name: string | null } {
|
|
return {
|
|
project_number: p?.project_number ?? null,
|
|
project_name: p?.name ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolved plan cell for a single (user, date). Either from a range entry
|
|
* (work_plan_entries) or from a single-day override (work_plan_overrides).
|
|
*/
|
|
export interface ResolvedCell {
|
|
/** Where the cell came from. */
|
|
source: "entry" | "override";
|
|
/** ID of the work_plan_entries row, or null when source is "override". */
|
|
entryId: number | null;
|
|
/** ID of the work_plan_overrides row, or null when source is "entry". */
|
|
overrideId: number | null;
|
|
user_id: number;
|
|
/** The date the cell resolves to (echoes the input, YYYY-MM-DD). */
|
|
shift_date: string;
|
|
project_id: number | null;
|
|
/** Project number/name resolved server-side from the projects relation,
|
|
* so the UI never depends on a separately-fetched (and capped/permission-
|
|
* gated) projects list to display the project. Null when no project. */
|
|
project_number: string | null;
|
|
project_name: string | null;
|
|
category: string;
|
|
note: string;
|
|
/** Original entry range, or null when source is "override". */
|
|
rangeFrom: string | null;
|
|
rangeTo: string | null;
|
|
}
|
|
|
|
/**
|
|
* Compute the effective plan cell for (userId, dateStr).
|
|
*
|
|
* Precedence: override > entry > null.
|
|
* Soft-deleted rows are ignored (is_deleted = false filter).
|
|
* If multiple entries cover the same day, the latest by created_at wins
|
|
* and a warning is logged.
|
|
*
|
|
* dateStr must be a YYYY-MM-DD string. The function builds a JS Date
|
|
* from it and Prisma handles timezone conversion against the MySQL
|
|
* @db.Date columns.
|
|
*/
|
|
export async function resolveCell(
|
|
userId: number,
|
|
dateStr: string,
|
|
): Promise<ResolvedCell | null> {
|
|
const date = new Date(dateStr);
|
|
|
|
// 1. Single-day override for this exact date
|
|
const override = await prisma.work_plan_overrides.findFirst({
|
|
where: { user_id: userId, shift_date: date, is_deleted: false },
|
|
include: { projects: projectSelect },
|
|
});
|
|
if (override) {
|
|
return {
|
|
source: "override",
|
|
entryId: null,
|
|
overrideId: override.id,
|
|
user_id: override.user_id,
|
|
shift_date: dateStr,
|
|
project_id: override.project_id,
|
|
...projectFields(override.projects),
|
|
category: override.category,
|
|
note: override.note,
|
|
rangeFrom: null,
|
|
rangeTo: null,
|
|
};
|
|
}
|
|
|
|
// 2. Range entry that covers the day
|
|
const entries = await prisma.work_plan_entries.findMany({
|
|
where: {
|
|
user_id: userId,
|
|
date_from: { lte: date },
|
|
date_to: { gte: date },
|
|
is_deleted: false,
|
|
},
|
|
orderBy: { created_at: "desc" },
|
|
include: { projects: projectSelect },
|
|
});
|
|
|
|
if (entries.length === 0) return null;
|
|
|
|
if (entries.length > 1) {
|
|
// Multiple entries overlap. Use the latest. A persistent audit-log
|
|
// entry would be ideal here, but logAudit() currently requires a
|
|
// FastifyRequest — services have no request. Use console.warn as a
|
|
// stand-in until a service-context audit logger is introduced.
|
|
console.warn(
|
|
`[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`,
|
|
);
|
|
}
|
|
|
|
const entry = entries[0];
|
|
return {
|
|
source: "entry",
|
|
entryId: entry.id,
|
|
overrideId: null,
|
|
user_id: entry.user_id,
|
|
shift_date: dateStr,
|
|
project_id: entry.project_id,
|
|
...projectFields(entry.projects),
|
|
category: entry.category,
|
|
note: entry.note,
|
|
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
|
rangeTo: entry.date_to.toISOString().slice(0, 10),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute the effective plan cell for every (userId, date) in the
|
|
* given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell | null.
|
|
*
|
|
* Implementation: load all entries and overrides for the range in two
|
|
* queries, then iterate the dates and resolve each cell in O(1) per
|
|
* (user, date). This avoids N+1 calls to resolveCell.
|
|
*/
|
|
export async function resolveGrid(
|
|
userIds: number[],
|
|
dateFromStr: string,
|
|
dateToStr: string,
|
|
): Promise<Record<number, Record<string, ResolvedCell | null>>> {
|
|
const dateFrom = new Date(dateFromStr);
|
|
const dateTo = new Date(dateToStr);
|
|
|
|
const [entries, overrides] = await Promise.all([
|
|
prisma.work_plan_entries.findMany({
|
|
where: {
|
|
user_id: { in: userIds },
|
|
is_deleted: false,
|
|
date_from: { lte: dateTo },
|
|
date_to: { gte: dateFrom },
|
|
},
|
|
orderBy: { created_at: "desc" },
|
|
include: { projects: projectSelect },
|
|
}),
|
|
prisma.work_plan_overrides.findMany({
|
|
where: {
|
|
user_id: { in: userIds },
|
|
is_deleted: false,
|
|
shift_date: { gte: dateFrom, lte: dateTo },
|
|
},
|
|
include: { projects: projectSelect },
|
|
}),
|
|
]);
|
|
|
|
// Build a date list (inclusive)
|
|
const dates: string[] = [];
|
|
for (
|
|
let d = new Date(dateFrom);
|
|
d <= dateTo;
|
|
d.setUTCDate(d.getUTCDate() + 1)
|
|
) {
|
|
dates.push(d.toISOString().slice(0, 10));
|
|
}
|
|
|
|
const result: Record<number, Record<string, ResolvedCell | null>> = {};
|
|
for (const uid of userIds) {
|
|
result[uid] = {};
|
|
for (const dateStr of dates) {
|
|
// Override first
|
|
const override = overrides.find(
|
|
(o) =>
|
|
o.user_id === uid &&
|
|
o.shift_date.toISOString().slice(0, 10) === dateStr,
|
|
);
|
|
if (override) {
|
|
result[uid][dateStr] = {
|
|
source: "override",
|
|
entryId: null,
|
|
overrideId: override.id,
|
|
user_id: override.user_id,
|
|
shift_date: dateStr,
|
|
project_id: override.project_id,
|
|
...projectFields(override.projects),
|
|
category: override.category,
|
|
note: override.note,
|
|
rangeFrom: null,
|
|
rangeTo: null,
|
|
};
|
|
continue;
|
|
}
|
|
// First (latest by created_at) matching entry
|
|
const entry = entries.find(
|
|
(e) =>
|
|
e.user_id === uid &&
|
|
e.date_from <= new Date(dateStr) &&
|
|
e.date_to >= new Date(dateStr),
|
|
);
|
|
if (entry) {
|
|
result[uid][dateStr] = {
|
|
source: "entry",
|
|
entryId: entry.id,
|
|
overrideId: null,
|
|
user_id: entry.user_id,
|
|
shift_date: dateStr,
|
|
project_id: entry.project_id,
|
|
...projectFields(entry.projects),
|
|
category: entry.category,
|
|
note: entry.note,
|
|
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
|
rangeTo: entry.date_to.toISOString().slice(0, 10),
|
|
};
|
|
} else {
|
|
result[uid][dateStr] = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export interface PlanUser {
|
|
id: number;
|
|
full_name: string;
|
|
username: string;
|
|
role_name: string | null;
|
|
is_active: boolean;
|
|
has_attendance_record: boolean;
|
|
}
|
|
|
|
/**
|
|
* Return users that should appear as columns in the plan grid:
|
|
* everyone with the `attendance.record` permission, ordered by
|
|
* role/team name then full_name. Admins are included by default.
|
|
*
|
|
* The `users` model uses the relation field name `roles` (NOT `role`)
|
|
* for the foreign key to `roles.id`. The roles-to-permissions join
|
|
* table is `role_permissions`, which has a relation `permissions`.
|
|
*/
|
|
export async function listPlanUsers(): Promise<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).
|
|
*
|
|
* The optional `replacedData` field is set only by `createOverride` when
|
|
* an existing active override for the same (user, date) was soft-deleted
|
|
* before creating the new one. The route handler uses it to emit a
|
|
* second audit-log entry for the deletion of the replaced row.
|
|
*
|
|
* On failure returns `{ error, status }`.
|
|
*/
|
|
export type Result<T> =
|
|
| {
|
|
data: T;
|
|
oldData: unknown | null;
|
|
replacedData?: unknown | null;
|
|
/** Human-readable Czech subject for the audit-log row. */
|
|
description?: string;
|
|
/** Audit description for the `replacedData` soft-delete row, if any. */
|
|
replacedDescription?: string;
|
|
}
|
|
| { error: string; status: number };
|
|
|
|
/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
|
|
function toDateOnly(dateStr: string): Date {
|
|
return new Date(dateStr + "T00:00:00.000Z");
|
|
}
|
|
|
|
/** Create a work_plan_entries row. Past dates require force=true. */
|
|
export async function createEntry(
|
|
input: {
|
|
user_id: number;
|
|
date_from: string;
|
|
date_to: string;
|
|
project_id?: number | null;
|
|
category: string;
|
|
note: string;
|
|
},
|
|
actorUserId: number,
|
|
force: boolean,
|
|
): Promise<
|
|
Result<{
|
|
id: number;
|
|
user_id: number;
|
|
date_from: Date;
|
|
date_to: Date;
|
|
project_id: number | null;
|
|
category: string;
|
|
note: string;
|
|
created_by: number;
|
|
created_at: Date | null;
|
|
updated_at: Date | null;
|
|
is_deleted: boolean | null;
|
|
}>
|
|
> {
|
|
// Range check
|
|
if (input.date_to < input.date_from) {
|
|
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
|
}
|
|
// Past-date lock on both endpoints — a range that starts today but
|
|
// extends into the past would still lock past days in the grid.
|
|
const lockFrom = assertNotPastDate(input.date_from, force);
|
|
if (lockFrom) return lockFrom;
|
|
const lockTo = assertNotPastDate(input.date_to, force);
|
|
if (lockTo) return lockTo;
|
|
|
|
const catErr = await assertActiveCategory(input.category);
|
|
if (catErr) return catErr;
|
|
|
|
const created = await prisma.work_plan_entries.create({
|
|
data: {
|
|
user_id: input.user_id,
|
|
date_from: toDateOnly(input.date_from),
|
|
date_to: toDateOnly(input.date_to),
|
|
project_id: input.project_id ?? null,
|
|
category: input.category,
|
|
note: input.note,
|
|
created_by: actorUserId,
|
|
},
|
|
});
|
|
|
|
const { userName, projectName } = await resolvePlanLabels(
|
|
created.user_id,
|
|
created.project_id,
|
|
);
|
|
const description = buildPlanAuditDescription({
|
|
userName,
|
|
categoryLabel: await resolveCategoryLabel(created.category),
|
|
projectName,
|
|
dateFrom: input.date_from,
|
|
dateTo: input.date_to,
|
|
force,
|
|
});
|
|
|
|
return { data: created, oldData: null, description };
|
|
}
|
|
|
|
/** Update a work_plan_entries row. Partial update. */
|
|
export async function updateEntry(
|
|
id: number,
|
|
input: {
|
|
date_from?: string;
|
|
date_to?: string;
|
|
project_id?: number | null;
|
|
category?: string;
|
|
note?: string;
|
|
},
|
|
actorUserId: number,
|
|
force: boolean,
|
|
): Promise<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. Replaces any active override for the
|
|
* same (user_id, shift_date) — soft-deletes the old one. Past dates
|
|
* require force=true.
|
|
*
|
|
* Returns { data, oldData: null, replacedData: existing_or_null } so the
|
|
* route handler can emit two audit log rows: one for the soft-deleted
|
|
* previous override, and one for the new one.
|
|
*/
|
|
export async function createOverride(
|
|
input: {
|
|
user_id: number;
|
|
shift_date: string;
|
|
project_id?: number | null;
|
|
category: string;
|
|
note: string;
|
|
},
|
|
actorUserId: number,
|
|
force: boolean,
|
|
): Promise<Result<any>> {
|
|
const lock = assertNotPastDate(input.shift_date, force);
|
|
if (lock) return lock;
|
|
|
|
const catErr = await assertActiveCategory(input.category);
|
|
if (catErr) return catErr;
|
|
|
|
// Application-level enforcement of "at most one active override per
|
|
// (user_id, shift_date)". The previous DB unique constraint was dropped
|
|
// (see migration 20260605122718_drop_wpo_unique_constraint) because
|
|
// MySQL unique indexes don't ignore soft-deleted rows, which would have
|
|
// blocked legitimate soft-delete-then-create replacement.
|
|
//
|
|
// Race protection: wrap the soft-delete + create in a transaction with
|
|
// SELECT ... FOR UPDATE on the colliding row so two concurrent requests
|
|
// can't both create active overrides for the same user-day.
|
|
const date = toDateOnly(input.shift_date);
|
|
|
|
const { created, replaced } = await prisma.$transaction(async (tx) => {
|
|
const existing = await tx.work_plan_overrides.findFirst({
|
|
where: {
|
|
user_id: input.user_id,
|
|
shift_date: date,
|
|
is_deleted: false,
|
|
},
|
|
});
|
|
|
|
if (existing) {
|
|
// Lock the row before update so a concurrent createOverride for the
|
|
// same (user, date) waits and sees the soft-deleted state.
|
|
await tx.$executeRaw`SELECT id FROM work_plan_overrides WHERE id = ${existing.id} FOR UPDATE`;
|
|
await tx.work_plan_overrides.update({
|
|
where: { id: existing.id },
|
|
data: { is_deleted: true },
|
|
});
|
|
}
|
|
|
|
const createdRow = await tx.work_plan_overrides.create({
|
|
data: {
|
|
user_id: input.user_id,
|
|
shift_date: date,
|
|
project_id: input.project_id ?? null,
|
|
category: input.category,
|
|
note: input.note,
|
|
created_by: actorUserId,
|
|
},
|
|
});
|
|
|
|
return { created: createdRow, replaced: existing };
|
|
});
|
|
|
|
const { userName, projectName } = await resolvePlanLabels(
|
|
created.user_id,
|
|
created.project_id,
|
|
);
|
|
const description = buildPlanAuditDescription({
|
|
userName,
|
|
categoryLabel: await resolveCategoryLabel(created.category),
|
|
projectName,
|
|
dateFrom: input.shift_date,
|
|
dateTo: input.shift_date,
|
|
force,
|
|
});
|
|
|
|
let replacedDescription: string | undefined;
|
|
if (replaced) {
|
|
const r = await resolvePlanLabels(replaced.user_id, replaced.project_id);
|
|
replacedDescription = buildPlanAuditDescription({
|
|
userName: r.userName,
|
|
categoryLabel: await resolveCategoryLabel(replaced.category),
|
|
projectName: r.projectName,
|
|
dateFrom: isoDay(replaced.shift_date),
|
|
dateTo: isoDay(replaced.shift_date),
|
|
suffix: "nahrazeno novým záznamem",
|
|
});
|
|
}
|
|
|
|
return {
|
|
data: created,
|
|
oldData: null,
|
|
replacedData: replaced,
|
|
description,
|
|
replacedDescription,
|
|
};
|
|
}
|
|
|
|
/** Update a work_plan_overrides row. */
|
|
export async function updateOverride(
|
|
id: number,
|
|
input: { project_id?: number | null; category?: string; note?: string },
|
|
actorUserId: number,
|
|
force: boolean,
|
|
): Promise<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 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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" }],
|
|
});
|
|
}
|