feat(plan): override CRUD with replace-existing behavior

This commit is contained in:
BOHA
2026-06-05 12:30:27 +02:00
parent 795d4d5af9
commit fb022802ae
4 changed files with 296 additions and 2 deletions

View File

@@ -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`);

View File

@@ -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")
}

View File

@@ -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);
});
});

View File

@@ -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<T> =
| { 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<Result<any>> {
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<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;
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<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 },
});
return { data: { ok: true }, oldData: existing };
}