From 795d4d5af92e6255893ca4b1c37b1e58369d3010 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 5 Jun 2026 12:19:21 +0200 Subject: [PATCH] feat(plan): entry CRUD with past-date lock and audit-data return --- src/__tests__/plan.test.ts | 140 +++++++++++++++++++++++++++++++++++ src/services/plan.service.ts | 133 +++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index 364e7e8..e4acee5 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -5,6 +5,9 @@ import { resolveGrid, listPlanUsers, assertNotPastDate, + createEntry, + updateEntry, + deleteEntry, } from "../services/plan.service"; // --------------------------------------------------------------------------- @@ -213,3 +216,140 @@ describe("plan.service.assertNotPastDate", () => { expect(result).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// createEntry +// --------------------------------------------------------------------------- + +describe("plan.service.createEntry", () => { + it("creates an entry and returns { data, oldData: null }", async () => { + const result = await createEntry( + { + user_id: adminUserId, + date_from: "2099-07-01", + date_to: "2099-07-05", + category: "work", + note: `${N}new entry`, + }, + adminUserId, + false, + ); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.note).toBe(`${N}new entry`); + expect(result.oldData).toBeNull(); + } + }); + + it("rejects a past date without force", async () => { + const result = await createEntry( + { + user_id: adminUserId, + date_from: "2000-01-01", + date_to: "2000-01-02", + category: "work", + note: `${N}past`, + }, + adminUserId, + false, + ); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.status).toBe(403); + }); + + it("allows a past date with force", async () => { + const result = await createEntry( + { + user_id: adminUserId, + date_from: "2000-01-01", + date_to: "2000-01-02", + category: "work", + note: `${N}past forced`, + }, + adminUserId, + true, + ); + expect("data" in result).toBe(true); + }); + + it("rejects when date_to < date_from", async () => { + const result = await createEntry( + { + user_id: adminUserId, + date_from: "2099-07-10", + date_to: "2099-07-05", + category: "work", + note: `${N}invalid range`, + }, + adminUserId, + false, + ); + expect("error" in result).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// updateEntry +// --------------------------------------------------------------------------- + +describe("plan.service.updateEntry", () => { + it("updates the note and returns the old data", async () => { + const created = await createEntry( + { + user_id: adminUserId, + date_from: "2099-08-01", + date_to: "2099-08-03", + category: "work", + note: `${N}original`, + }, + adminUserId, + false, + ); + if (!("data" in created)) throw new Error("setup failed"); + + const updated = await updateEntry( + 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`); + } + }); +}); + +// --------------------------------------------------------------------------- +// deleteEntry +// --------------------------------------------------------------------------- + +describe("plan.service.deleteEntry", () => { + it("soft-deletes the row and returns the old data", async () => { + const created = await createEntry( + { + user_id: adminUserId, + date_from: "2099-09-01", + date_to: "2099-09-01", + category: "work", + note: `${N}to delete`, + }, + adminUserId, + false, + ); + if (!("data" in created)) throw new Error("setup failed"); + + const result = await deleteEntry(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 stillThere = await prisma.work_plan_entries.findUnique({ + where: { id: created.data.id }, + }); + expect(stillThere?.is_deleted).toBe(true); + }); +}); diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index c2e4642..b95f796 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -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 = + | { 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> { + 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> { + 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 }; +}