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)
|
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)
|
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")
|
@@index([shift_date], map: "idx_wpo_date")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
createEntry,
|
createEntry,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
deleteEntry,
|
deleteEntry,
|
||||||
|
createOverride,
|
||||||
|
updateOverride,
|
||||||
|
deleteOverride,
|
||||||
} from "../services/plan.service";
|
} from "../services/plan.service";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -353,3 +356,137 @@ describe("plan.service.deleteEntry", () => {
|
|||||||
expect(stillThere?.is_deleted).toBe(true);
|
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 }`
|
* Standard service result envelope. On success returns `{ data, oldData }`
|
||||||
* 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; oldData: unknown | null }
|
| { data: T; oldData: unknown | null; replacedData?: unknown | null }
|
||||||
| { error: string; status: number };
|
| { error: string; status: number };
|
||||||
|
|
||||||
/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
|
/** 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 };
|
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