feat(plan): override CRUD with replace-existing behavior
This commit is contained in:
@@ -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