feat(plan): override CRUD with replace-existing behavior
This commit is contained in:
@@ -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`);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user