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

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