Files
app/src/services/plan.service.ts
BOHA 379725a096 fix(plan): correct admin role check (roleName not role) + drop stale category casts
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>
2026-06-06 02:16:23 +02:00

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" }],
});
}