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

@@ -58,6 +58,20 @@ beforeEach(async () => {
await prisma.work_plan_overrides.deleteMany({ await prisma.work_plan_overrides.deleteMany({
where: { note: { contains: N } }, where: { note: { contains: N } },
}); });
// Also clean up empty-note rows on the specific test dates used by the
// "allows an empty note" tests. These rows are not caught by the prefix
// filter above, and would cause the per-cell cap check to reject them
// once three accumulate across runs.
await prisma.work_plan_entries.deleteMany({
where: {
date_from: {
in: [
new Date("2099-07-15T00:00:00.000Z"),
new Date("2097-08-05T00:00:00.000Z"),
],
},
},
});
}); });
afterAll(async () => { afterAll(async () => {
@@ -341,6 +355,36 @@ describe("plan.service.createEntry", () => {
expect("error" in result).toBe(true); expect("error" in result).toBe(true);
if ("error" in result) expect(result.status).toBe(403); if ("error" in result) expect(result.status).toBe(403);
}); });
it("rejects a 4th entry covering a day already at the cap", async () => {
for (let i = 0; i < 3; i++) {
const r = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-20",
date_to: "2099-07-20",
category: "work",
note: `${N}e${i}`,
},
adminUserId,
false,
);
expect("data" in r).toBe(true);
}
const fourth = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-20",
date_to: "2099-07-20",
category: "work",
note: `${N}e3`,
},
adminUserId,
false,
);
expect("error" in fourth).toBe(true);
if ("error" in fourth) expect(fourth.status).toBe(400);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -439,7 +483,7 @@ describe("plan.service.deleteEntry", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("plan.service.createOverride", () => { describe("plan.service.createOverride", () => {
it("creates an override and returns { data, oldData: null, replacedData: null }", async () => { it("creates an override and returns { data, oldData: null }", async () => {
const result = await createOverride( const result = await createOverride(
{ {
user_id: adminUserId, user_id: adminUserId,
@@ -454,42 +498,45 @@ describe("plan.service.createOverride", () => {
if ("data" in result) { if ("data" in result) {
expect(result.data.note).toBe(`${N}day off`); expect(result.data.note).toBe(`${N}day off`);
expect(result.oldData).toBeNull(); expect(result.oldData).toBeNull();
expect(result.replacedData).toBeNull();
} }
}); });
it("soft-deletes the existing override and reports it in replacedData", async () => { it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
await createOverride( for (let i = 0; i < 3; i++) {
const r = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-10-02",
category: "leave",
note: `${N}o${i}`,
},
adminUserId,
false,
);
expect("data" in r).toBe(true);
}
// A 4th record on the same day is rejected by the cap.
const fourth = await createOverride(
{ {
user_id: adminUserId, user_id: adminUserId,
shift_date: "2099-10-02", shift_date: "2099-10-02",
category: "leave", category: "leave",
note: `${N}first`, note: `${N}o3`,
}, },
adminUserId, adminUserId,
false, false,
); );
const second = await createOverride( expect("error" in fourth).toBe(true);
{ if ("error" in fourth) expect(fourth.status).toBe(400);
// All three earlier overrides remain active — no replace happened.
const active = await prisma.work_plan_overrides.findMany({
where: {
user_id: adminUserId, user_id: adminUserId,
shift_date: "2099-10-02", shift_date: new Date("2099-10-02"),
category: "sick", is_deleted: false,
note: `${N}second`,
}, },
adminUserId,
false,
);
expect("data" in second).toBe(true);
if ("data" in second) {
expect((second.replacedData as any)?.note).toBe(`${N}first`);
}
const all = await prisma.work_plan_overrides.findMany({
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
}); });
// The first should be soft-deleted, the second active expect(active.length).toBe(3);
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
}); });
it("rejects a past date without force", async () => { it("rejects a past date without force", async () => {

View File

@@ -328,18 +328,6 @@ export default async function planRoutes(app: FastifyInstance) {
); );
if ("error" in result) return error(reply, result.error, result.status); if ("error" in result) return error(reply, result.error, result.status);
// If the create replaced an existing active override, log the soft-delete first.
if (result.replacedData) {
await logAudit({
request,
authData: request.authData,
action: "delete",
entityType: "work_plan_override",
entityId: (result.replacedData as any).id,
oldValues: result.replacedData as Record<string, unknown>,
description: result.replacedDescription,
});
}
await logAudit({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,

View File

@@ -377,22 +377,14 @@ export function assertNotPastDate(
* where `oldData` is null for creates and the pre-update snapshot for * where `oldData` is null for creates and the pre-update snapshot for
* updates / deletes (so the route handler can call `logAudit` with it). * 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 }`. * On failure returns `{ error, status }`.
*/ */
export type Result<T> = export type Result<T> =
| { | {
data: T; data: T;
oldData: unknown | null; oldData: unknown | null;
replacedData?: unknown | null;
/** Human-readable Czech subject for the audit-log row. */ /** Human-readable Czech subject for the audit-log row. */
description?: string; description?: string;
/** Audit description for the `replacedData` soft-delete row, if any. */
replacedDescription?: string;
} }
| { error: string; status: number }; | { error: string; status: number };
@@ -401,6 +393,46 @@ function toDateOnly(dateStr: string): Date {
return new Date(dateStr + "T00:00:00.000Z"); 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. */ /** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry( export async function createEntry(
input: { input: {
@@ -442,6 +474,13 @@ export async function createEntry(
const catErr = await assertActiveCategory(input.category); const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr; 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({ const created = await prisma.work_plan_entries.create({
data: { data: {
user_id: input.user_id, 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 * Create a work_plan_overrides row. Additive: stacks up to
* same (user_id, shift_date) — soft-deletes the old one. Past dates * MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
* require force=true. * past the cap returns { error, status: 400 }. Past dates require force=true.
* * Returns { data, oldData: null, description }.
* 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( export async function createOverride(
input: { input: {
@@ -601,49 +637,41 @@ export async function createOverride(
const catErr = await assertActiveCategory(input.category); const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr; 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 date = toDateOnly(input.shift_date);
const { created, replaced } = await prisma.$transaction(async (tx) => { // Additive up to MAX_RECORDS_PER_CELL. (This previously REPLACED the existing
const existing = await tx.work_plan_overrides.findFirst({ // override; multi-record cells stack instead.) count+create runs in one
where: { // transaction; a concurrent create could in theory add one extra row, but
user_id: input.user_id, // resolve display caps at MAX_RECORDS_PER_CELL so that is benign.
shift_date: date, let created;
is_deleted: false, 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 (existing) { });
// Lock the row before update so a concurrent createOverride for the if (count >= MAX_RECORDS_PER_CELL) {
// same (user, date) waits and sees the soft-deleted state. throw Object.assign(new Error("cap"), { __cap: true });
await tx.$executeRaw`SELECT id FROM work_plan_overrides WHERE id = ${existing.id} FOR UPDATE`; }
await tx.work_plan_overrides.update({ return tx.work_plan_overrides.create({
where: { id: existing.id }, data: {
data: { is_deleted: true }, 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,
},
}); });
} catch (e) {
return { created: createdRow, replaced: existing }; 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( const { userName, projectName } = await resolvePlanLabels(
created.user_id, created.user_id,
@@ -658,26 +686,7 @@ export async function createOverride(
force, force,
}); });
let replacedDescription: string | undefined; return { data: created, oldData: null, description };
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. */ /** Update a work_plan_overrides row. */