feat(plan): entry CRUD with past-date lock and audit-data return
This commit is contained in:
@@ -5,6 +5,9 @@ import {
|
|||||||
resolveGrid,
|
resolveGrid,
|
||||||
listPlanUsers,
|
listPlanUsers,
|
||||||
assertNotPastDate,
|
assertNotPastDate,
|
||||||
|
createEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
} from "../services/plan.service";
|
} from "../services/plan.service";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -213,3 +216,140 @@ describe("plan.service.assertNotPastDate", () => {
|
|||||||
expect(result).toBeNull();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -271,3 +271,136 @@ export function assertNotPastDate(
|
|||||||
}
|
}
|
||||||
return null;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user