feat(plan): per-cell record cap (3) + additive overrides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 10:40:46 +02:00
parent 2cfa28dc47
commit 80dc8a5c69
3 changed files with 153 additions and 109 deletions

View File

@@ -377,22 +377,14 @@ export function assertNotPastDate(
* 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 };
@@ -401,6 +393,46 @@ 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: {
@@ -442,6 +474,13 @@ export async function createEntry(
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,
@@ -576,13 +615,10 @@ export async function deleteEntry(
// ---------------------------------------------------------------------------
/**
* 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.
* 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: {
@@ -601,49 +637,41 @@ export async function createOverride(
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 },
// 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,
},
});
}
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 };
});
} 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,
@@ -658,26 +686,7 @@ export async function createOverride(
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,
};
return { data: created, oldData: null, description };
}
/** Update a work_plan_overrides row. */