From 80dc8a5c698e1e37f42aa3d25f158c08a1049605 Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 10:40:46 +0200 Subject: [PATCH] feat(plan): per-cell record cap (3) + additive overrides Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/plan.test.ts | 93 ++++++++++++++++----- src/routes/admin/plan.ts | 12 --- src/services/plan.service.ts | 157 ++++++++++++++++++----------------- 3 files changed, 153 insertions(+), 109 deletions(-) diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index 44e46d7..db71e34 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -58,6 +58,20 @@ beforeEach(async () => { await prisma.work_plan_overrides.deleteMany({ 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 () => { @@ -341,6 +355,36 @@ describe("plan.service.createEntry", () => { expect("error" in result).toBe(true); 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", () => { - 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( { user_id: adminUserId, @@ -454,42 +498,45 @@ describe("plan.service.createOverride", () => { if ("data" in result) { expect(result.data.note).toBe(`${N}day off`); expect(result.oldData).toBeNull(); - expect(result.replacedData).toBeNull(); } }); - it("soft-deletes the existing override and reports it in replacedData", async () => { - await createOverride( + it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => { + 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, shift_date: "2099-10-02", category: "leave", - note: `${N}first`, + note: `${N}o3`, }, adminUserId, 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, - shift_date: "2099-10-02", - category: "sick", - note: `${N}second`, + shift_date: new Date("2099-10-02"), + is_deleted: false, }, - 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(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true); - expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false); + expect(active.length).toBe(3); }); it("rejects a past date without force", async () => { diff --git a/src/routes/admin/plan.ts b/src/routes/admin/plan.ts index 738e009..9205591 100644 --- a/src/routes/admin/plan.ts +++ b/src/routes/admin/plan.ts @@ -328,18 +328,6 @@ export default async function planRoutes(app: FastifyInstance) { ); 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, - description: result.replacedDescription, - }); - } await logAudit({ request, authData: request.authData, diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index 8e82714..8dc66f6 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -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 = | { 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. */