diff --git a/prisma/migrations/20260605122718_drop_wpo_unique_constraint/migration.sql b/prisma/migrations/20260605122718_drop_wpo_unique_constraint/migration.sql new file mode 100644 index 0000000..2ca9d74 --- /dev/null +++ b/prisma/migrations/20260605122718_drop_wpo_unique_constraint/migration.sql @@ -0,0 +1,20 @@ +-- Drop the unique index on (user_id, shift_date) for work_plan_overrides. +-- MySQL unique indexes don't ignore soft-deleted rows, which would prevent +-- createOverride from soft-deleting an existing override and creating a new +-- one for the same (user_id, shift_date). Application code enforces the +-- "at most one active override per (user_id, shift_date)" invariant. +-- The application-level check is wrapped in a transaction; see +-- src/services/plan.service.ts:createOverride. +-- +-- The original index was created in +-- 20260605120000_add_work_plan/migration.sql as `uniq_wpo_user_date`. + +DROP INDEX `uniq_wpo_user_date` ON `work_plan_overrides`; + +-- Add a non-unique index for the (user_id, shift_date) lookup used by +-- createOverride's "is there an active override for this user-day?" query +-- and by resolveCell / resolveGrid. This is a plain index, not a unique +-- one — multiple rows (active and soft-deleted) can share the same +-- (user_id, shift_date) pair. + +CREATE INDEX `idx_wpo_user_date` ON `work_plan_overrides`(`user_id`, `shift_date`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dcc7538..cff5b3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -971,6 +971,6 @@ model work_plan_overrides { projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction) - @@unique([user_id, shift_date], map: "uniq_wpo_user_date") + @@index([user_id, shift_date], map: "idx_wpo_user_date") @@index([shift_date], map: "idx_wpo_date") } diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index e4acee5..bb84c10 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -8,6 +8,9 @@ import { createEntry, updateEntry, deleteEntry, + createOverride, + updateOverride, + deleteOverride, } from "../services/plan.service"; // --------------------------------------------------------------------------- @@ -353,3 +356,137 @@ describe("plan.service.deleteEntry", () => { expect(stillThere?.is_deleted).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// createOverride +// --------------------------------------------------------------------------- + +describe("plan.service.createOverride", () => { + it("creates an override and returns { data, oldData: null, replacedData: null }", async () => { + const result = await createOverride( + { + user_id: adminUserId, + shift_date: "2099-10-01", + category: "leave", + note: `${N}day off`, + }, + adminUserId, + false, + ); + expect("data" in result).toBe(true); + 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( + { + user_id: adminUserId, + shift_date: "2099-10-02", + category: "leave", + note: `${N}first`, + }, + adminUserId, + false, + ); + const second = await createOverride( + { + user_id: adminUserId, + shift_date: "2099-10-02", + category: "sick", + 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(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 () => { + const result = await createOverride( + { + user_id: adminUserId, + shift_date: "2000-01-01", + category: "leave", + note: `${N}past`, + }, + adminUserId, + false, + ); + expect("error" in result).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// updateOverride +// --------------------------------------------------------------------------- + +describe("plan.service.updateOverride", () => { + it("updates the note and returns the old data", async () => { + const created = await createOverride( + { + user_id: adminUserId, + shift_date: "2099-11-01", + category: "leave", + note: `${N}original`, + }, + adminUserId, + false, + ); + if (!("data" in created)) throw new Error("setup"); + const updated = await updateOverride( + created.data.id, + { note: `${N}updated` }, + adminUserId, + false, + ); + expect("data" in updated).toBe(true); + if ("data" in updated) { + expect(updated.data.note).toBe(`${N}updated`); + expect((updated.oldData as any).note).toBe(`${N}original`); + } + }); +}); + +// --------------------------------------------------------------------------- +// deleteOverride +// --------------------------------------------------------------------------- + +describe("plan.service.deleteOverride", () => { + it("soft-deletes the override and returns the old data", async () => { + const created = await createOverride( + { + user_id: adminUserId, + shift_date: "2099-12-01", + category: "leave", + note: `${N}to delete`, + }, + adminUserId, + false, + ); + if (!("data" in created)) throw new Error("setup"); + const result = await deleteOverride(created.data.id, adminUserId, false); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data).toEqual({ ok: true }); + expect((result.oldData as any).note).toBe(`${N}to delete`); + } + const still = await prisma.work_plan_overrides.findUnique({ + where: { id: created.data.id }, + }); + expect(still?.is_deleted).toBe(true); + }); +}); diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index b95f796..9f33848 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -280,10 +280,16 @@ export function assertNotPastDate( * 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 = - | { data: T; oldData: unknown | null } + | { data: T; oldData: unknown | null; replacedData?: unknown | null } | { error: string; status: number }; /** Convert a YYYY-MM-DD string to a Date at midnight UTC. */ @@ -404,3 +410,134 @@ export async function deleteEntry( return { data: { ok: true }, oldData: existing }; } + +// --------------------------------------------------------------------------- +// 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> { + const lock = assertNotPastDate(input.shift_date, force); + if (lock) return lock; + + // 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); + + return prisma.$transaction(async (tx) => { + const existing = await tx.work_plan_overrides.findFirst({ + where: { + user_id: input.user_id, + shift_date: date, + is_deleted: false, + }, + }); + + let replacedData: unknown = null; + 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 }, + }); + replacedData = existing; + } + + const created = await tx.work_plan_overrides.create({ + data: { + user_id: input.user_id, + shift_date: date, + project_id: input.project_id ?? null, + category: input.category as any, + note: input.note, + created_by: actorUserId, + }, + }); + + return { + data: created, + oldData: null, + replacedData, + }; + }); +} + +/** 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> { + 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; + + const updated = await prisma.work_plan_overrides.update({ + where: { id }, + data: { + project_id: input.project_id === undefined ? undefined : input.project_id, + category: input.category as any, + note: input.note, + }, + }); + + return { data: updated, oldData: existing }; +} + +/** Soft-delete a work_plan_overrides row. */ +export async function deleteOverride( + id: number, + actorUserId: number, + force: boolean, +): Promise> { + 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 }, + }); + + return { data: { ok: true }, oldData: existing }; +}