feat(plan): entry CRUD with past-date lock and audit-data return

This commit is contained in:
BOHA
2026-06-05 12:19:21 +02:00
parent 74113035ae
commit 795d4d5af9
2 changed files with 273 additions and 0 deletions

View File

@@ -271,3 +271,136 @@ export function assertNotPastDate(
}
return null;
}
// ---------------------------------------------------------------------------
// Entry CRUD
// ---------------------------------------------------------------------------
/**
* 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).
* On failure returns `{ error, status }`.
*/
export type Result<T> =
| { data: T; oldData: unknown | null }
| { error: string; status: number };
/** Convert a YYYY-MM-DD string to a Date at midnight UTC. */
function toDateOnly(dateStr: string): Date {
return new Date(dateStr + "T00:00:00.000Z");
}
/** Create a work_plan_entries row. Past dates require force=true. */
export async function createEntry(
input: {
user_id: number;
date_from: string;
date_to: string;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
force: boolean,
): Promise<
Result<{
id: number;
user_id: number;
date_from: Date;
date_to: Date;
project_id: number | null;
category: string;
note: string;
created_by: number;
created_at: Date | null;
updated_at: Date | null;
is_deleted: boolean | null;
}>
> {
// Range check
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
}
// Past-date lock on the from-date (the earliest editable point)
const lock = assertNotPastDate(input.date_from, force);
if (lock) return lock;
const created = await prisma.work_plan_entries.create({
data: {
user_id: input.user_id,
date_from: toDateOnly(input.date_from),
date_to: toDateOnly(input.date_to),
project_id: input.project_id ?? null,
category: input.category as any,
note: input.note,
created_by: actorUserId,
},
});
return { data: created, oldData: null };
}
/** Update a work_plan_entries row. Partial update. */
export async function updateEntry(
id: number,
input: {
date_from?: string;
date_to?: string;
project_id?: number | null;
category?: string;
note?: string;
},
actorUserId: number,
force: boolean,
): Promise<Result<unknown>> {
const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
if (!existing || existing.is_deleted) {
return { error: "Záznam nenalezen", status: 404 };
}
// Past-date lock on the existing or new from-date
const fromStr =
input.date_from ?? existing.date_from.toISOString().slice(0, 10);
const lock = assertNotPastDate(fromStr, force);
if (lock) return lock;
if (input.date_from && input.date_to && input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
}
const updated = await prisma.work_plan_entries.update({
where: { id },
data: {
date_from: input.date_from ? toDateOnly(input.date_from) : undefined,
date_to: input.date_to ? toDateOnly(input.date_to) : undefined,
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_entries row. */
export async function deleteEntry(
id: number,
actorUserId: number,
force: boolean,
): Promise<Result<{ ok: true }>> {
const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
if (!existing || existing.is_deleted) {
return { error: "Záznam nenalezen", status: 404 };
}
const fromStr = existing.date_from.toISOString().slice(0, 10);
const lock = assertNotPastDate(fromStr, force);
if (lock) return lock;
await prisma.work_plan_entries.update({
where: { id },
data: { is_deleted: true },
});
return { data: { ok: true }, oldData: existing };
}