feat(plan): per-cell record cap (3) + additive overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,20 @@ beforeEach(async () => {
|
|||||||
await prisma.work_plan_overrides.deleteMany({
|
await prisma.work_plan_overrides.deleteMany({
|
||||||
where: { note: { contains: N } },
|
where: { note: { contains: N } },
|
||||||
});
|
});
|
||||||
|
// Also clean up empty-note rows on the specific test dates used by the
|
||||||
|
// "allows an empty note" tests. These rows are not caught by the prefix
|
||||||
|
// filter above, and would cause the per-cell cap check to reject them
|
||||||
|
// once three accumulate across runs.
|
||||||
|
await prisma.work_plan_entries.deleteMany({
|
||||||
|
where: {
|
||||||
|
date_from: {
|
||||||
|
in: [
|
||||||
|
new Date("2099-07-15T00:00:00.000Z"),
|
||||||
|
new Date("2097-08-05T00:00:00.000Z"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -341,6 +355,36 @@ describe("plan.service.createEntry", () => {
|
|||||||
expect("error" in result).toBe(true);
|
expect("error" in result).toBe(true);
|
||||||
if ("error" in result) expect(result.status).toBe(403);
|
if ("error" in result) expect(result.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a 4th entry covering a day already at the cap", async () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await createEntry(
|
||||||
|
{
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: "2099-07-20",
|
||||||
|
date_to: "2099-07-20",
|
||||||
|
category: "work",
|
||||||
|
note: `${N}e${i}`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("data" in r).toBe(true);
|
||||||
|
}
|
||||||
|
const fourth = await createEntry(
|
||||||
|
{
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: "2099-07-20",
|
||||||
|
date_to: "2099-07-20",
|
||||||
|
category: "work",
|
||||||
|
note: `${N}e3`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("error" in fourth).toBe(true);
|
||||||
|
if ("error" in fourth) expect(fourth.status).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -439,7 +483,7 @@ describe("plan.service.deleteEntry", () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("plan.service.createOverride", () => {
|
describe("plan.service.createOverride", () => {
|
||||||
it("creates an override and returns { data, oldData: null, replacedData: null }", async () => {
|
it("creates an override and returns { data, oldData: null }", async () => {
|
||||||
const result = await createOverride(
|
const result = await createOverride(
|
||||||
{
|
{
|
||||||
user_id: adminUserId,
|
user_id: adminUserId,
|
||||||
@@ -454,42 +498,45 @@ describe("plan.service.createOverride", () => {
|
|||||||
if ("data" in result) {
|
if ("data" in result) {
|
||||||
expect(result.data.note).toBe(`${N}day off`);
|
expect(result.data.note).toBe(`${N}day off`);
|
||||||
expect(result.oldData).toBeNull();
|
expect(result.oldData).toBeNull();
|
||||||
expect(result.replacedData).toBeNull();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("soft-deletes the existing override and reports it in replacedData", async () => {
|
it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
|
||||||
await createOverride(
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await createOverride(
|
||||||
|
{
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: "2099-10-02",
|
||||||
|
category: "leave",
|
||||||
|
note: `${N}o${i}`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("data" in r).toBe(true);
|
||||||
|
}
|
||||||
|
// A 4th record on the same day is rejected by the cap.
|
||||||
|
const fourth = await createOverride(
|
||||||
{
|
{
|
||||||
user_id: adminUserId,
|
user_id: adminUserId,
|
||||||
shift_date: "2099-10-02",
|
shift_date: "2099-10-02",
|
||||||
category: "leave",
|
category: "leave",
|
||||||
note: `${N}first`,
|
note: `${N}o3`,
|
||||||
},
|
},
|
||||||
adminUserId,
|
adminUserId,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const second = await createOverride(
|
expect("error" in fourth).toBe(true);
|
||||||
{
|
if ("error" in fourth) expect(fourth.status).toBe(400);
|
||||||
|
// All three earlier overrides remain active — no replace happened.
|
||||||
|
const active = await prisma.work_plan_overrides.findMany({
|
||||||
|
where: {
|
||||||
user_id: adminUserId,
|
user_id: adminUserId,
|
||||||
shift_date: "2099-10-02",
|
shift_date: new Date("2099-10-02"),
|
||||||
category: "sick",
|
is_deleted: false,
|
||||||
note: `${N}second`,
|
|
||||||
},
|
},
|
||||||
adminUserId,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect("data" in second).toBe(true);
|
|
||||||
if ("data" in second) {
|
|
||||||
expect((second.replacedData as any)?.note).toBe(`${N}first`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const all = await prisma.work_plan_overrides.findMany({
|
|
||||||
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
|
|
||||||
});
|
});
|
||||||
// The first should be soft-deleted, the second active
|
expect(active.length).toBe(3);
|
||||||
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
|
|
||||||
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a past date without force", async () => {
|
it("rejects a past date without force", async () => {
|
||||||
|
|||||||
@@ -328,18 +328,6 @@ export default async function planRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
if ("error" in result) return error(reply, result.error, result.status);
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
|
||||||
// If the create replaced an existing active override, log the soft-delete first.
|
|
||||||
if (result.replacedData) {
|
|
||||||
await logAudit({
|
|
||||||
request,
|
|
||||||
authData: request.authData,
|
|
||||||
action: "delete",
|
|
||||||
entityType: "work_plan_override",
|
|
||||||
entityId: (result.replacedData as any).id,
|
|
||||||
oldValues: result.replacedData as Record<string, unknown>,
|
|
||||||
description: result.replacedDescription,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
|
|||||||
@@ -377,22 +377,14 @@ export function assertNotPastDate(
|
|||||||
* where `oldData` is null for creates and the pre-update snapshot for
|
* where `oldData` is null for creates and the pre-update snapshot for
|
||||||
* updates / deletes (so the route handler can call `logAudit` with it).
|
* 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 }`.
|
* On failure returns `{ error, status }`.
|
||||||
*/
|
*/
|
||||||
export type Result<T> =
|
export type Result<T> =
|
||||||
| {
|
| {
|
||||||
data: T;
|
data: T;
|
||||||
oldData: unknown | null;
|
oldData: unknown | null;
|
||||||
replacedData?: unknown | null;
|
|
||||||
/** Human-readable Czech subject for the audit-log row. */
|
/** Human-readable Czech subject for the audit-log row. */
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Audit description for the `replacedData` soft-delete row, if any. */
|
|
||||||
replacedDescription?: string;
|
|
||||||
}
|
}
|
||||||
| { error: string; status: number };
|
| { error: string; status: number };
|
||||||
|
|
||||||
@@ -401,6 +393,46 @@ function toDateOnly(dateStr: string): Date {
|
|||||||
return new Date(dateStr + "T00:00:00.000Z");
|
return new Date(dateStr + "T00:00:00.000Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maximum number of records (entries OR overrides) shown per cell per day. */
|
||||||
|
export const MAX_RECORDS_PER_CELL = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns { error, status } if creating an entry over [dateFromStr, dateToStr]
|
||||||
|
* would push any single day past MAX_RECORDS_PER_CELL active entries for the
|
||||||
|
* user. Names the first offending date. Counts entries regardless of whether an
|
||||||
|
* override currently hides them (simpler and predictable).
|
||||||
|
*/
|
||||||
|
async function assertEntryCapAvailable(
|
||||||
|
userId: number,
|
||||||
|
dateFromStr: string,
|
||||||
|
dateToStr: string,
|
||||||
|
): Promise<{ error: string; status: number } | null> {
|
||||||
|
const from = toDateOnly(dateFromStr);
|
||||||
|
const to = toDateOnly(dateToStr);
|
||||||
|
const existing = await prisma.work_plan_entries.findMany({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
is_deleted: false,
|
||||||
|
date_from: { lte: to },
|
||||||
|
date_to: { gte: from },
|
||||||
|
},
|
||||||
|
select: { date_from: true, date_to: true },
|
||||||
|
});
|
||||||
|
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
|
const day = new Date(d);
|
||||||
|
const count = existing.filter(
|
||||||
|
(e) => e.date_from <= day && e.date_to >= day,
|
||||||
|
).length;
|
||||||
|
if (count >= MAX_RECORDS_PER_CELL) {
|
||||||
|
return {
|
||||||
|
error: `Na den ${day.toISOString().slice(0, 10)} jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a work_plan_entries row. Past dates require force=true. */
|
/** Create a work_plan_entries row. Past dates require force=true. */
|
||||||
export async function createEntry(
|
export async function createEntry(
|
||||||
input: {
|
input: {
|
||||||
@@ -442,6 +474,13 @@ export async function createEntry(
|
|||||||
const catErr = await assertActiveCategory(input.category);
|
const catErr = await assertActiveCategory(input.category);
|
||||||
if (catErr) return catErr;
|
if (catErr) return catErr;
|
||||||
|
|
||||||
|
const capErr = await assertEntryCapAvailable(
|
||||||
|
input.user_id,
|
||||||
|
input.date_from,
|
||||||
|
input.date_to,
|
||||||
|
);
|
||||||
|
if (capErr) return capErr;
|
||||||
|
|
||||||
const created = await prisma.work_plan_entries.create({
|
const created = await prisma.work_plan_entries.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: input.user_id,
|
user_id: input.user_id,
|
||||||
@@ -576,13 +615,10 @@ export async function deleteEntry(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a work_plan_overrides row. Replaces any active override for the
|
* Create a work_plan_overrides row. Additive: stacks up to
|
||||||
* same (user_id, shift_date) — soft-deletes the old one. Past dates
|
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
|
||||||
* require force=true.
|
* past the cap returns { error, status: 400 }. Past dates require force=true.
|
||||||
*
|
* Returns { data, oldData: null, description }.
|
||||||
* 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(
|
export async function createOverride(
|
||||||
input: {
|
input: {
|
||||||
@@ -601,49 +637,41 @@ export async function createOverride(
|
|||||||
const catErr = await assertActiveCategory(input.category);
|
const catErr = await assertActiveCategory(input.category);
|
||||||
if (catErr) return catErr;
|
if (catErr) return catErr;
|
||||||
|
|
||||||
// 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);
|
const date = toDateOnly(input.shift_date);
|
||||||
|
|
||||||
const { created, replaced } = await prisma.$transaction(async (tx) => {
|
// Additive up to MAX_RECORDS_PER_CELL. (This previously REPLACED the existing
|
||||||
const existing = await tx.work_plan_overrides.findFirst({
|
// override; multi-record cells stack instead.) count+create runs in one
|
||||||
where: {
|
// transaction; a concurrent create could in theory add one extra row, but
|
||||||
user_id: input.user_id,
|
// resolve display caps at MAX_RECORDS_PER_CELL so that is benign.
|
||||||
shift_date: date,
|
let created;
|
||||||
is_deleted: false,
|
try {
|
||||||
},
|
created = await prisma.$transaction(async (tx) => {
|
||||||
});
|
const count = await tx.work_plan_overrides.count({
|
||||||
|
where: { user_id: input.user_id, shift_date: date, is_deleted: false },
|
||||||
if (existing) {
|
});
|
||||||
// Lock the row before update so a concurrent createOverride for the
|
if (count >= MAX_RECORDS_PER_CELL) {
|
||||||
// same (user, date) waits and sees the soft-deleted state.
|
throw Object.assign(new Error("cap"), { __cap: true });
|
||||||
await tx.$executeRaw`SELECT id FROM work_plan_overrides WHERE id = ${existing.id} FOR UPDATE`;
|
}
|
||||||
await tx.work_plan_overrides.update({
|
return tx.work_plan_overrides.create({
|
||||||
where: { id: existing.id },
|
data: {
|
||||||
data: { is_deleted: true },
|
user_id: input.user_id,
|
||||||
|
shift_date: date,
|
||||||
|
project_id: input.project_id ?? null,
|
||||||
|
category: input.category,
|
||||||
|
note: input.note,
|
||||||
|
created_by: actorUserId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const createdRow = await tx.work_plan_overrides.create({
|
|
||||||
data: {
|
|
||||||
user_id: input.user_id,
|
|
||||||
shift_date: date,
|
|
||||||
project_id: input.project_id ?? null,
|
|
||||||
category: input.category,
|
|
||||||
note: input.note,
|
|
||||||
created_by: actorUserId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
return { created: createdRow, replaced: existing };
|
if (e && typeof e === "object" && "__cap" in e) {
|
||||||
});
|
return {
|
||||||
|
error: `Na tento den jsou již ${MAX_RECORDS_PER_CELL} záznamy (maximum).`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
const { userName, projectName } = await resolvePlanLabels(
|
const { userName, projectName } = await resolvePlanLabels(
|
||||||
created.user_id,
|
created.user_id,
|
||||||
@@ -658,26 +686,7 @@ export async function createOverride(
|
|||||||
force,
|
force,
|
||||||
});
|
});
|
||||||
|
|
||||||
let replacedDescription: string | undefined;
|
return { data: created, oldData: null, description };
|
||||||
if (replaced) {
|
|
||||||
const r = await resolvePlanLabels(replaced.user_id, replaced.project_id);
|
|
||||||
replacedDescription = buildPlanAuditDescription({
|
|
||||||
userName: r.userName,
|
|
||||||
categoryLabel: await resolveCategoryLabel(replaced.category),
|
|
||||||
projectName: r.projectName,
|
|
||||||
dateFrom: isoDay(replaced.shift_date),
|
|
||||||
dateTo: isoDay(replaced.shift_date),
|
|
||||||
suffix: "nahrazeno novým záznamem",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: created,
|
|
||||||
oldData: null,
|
|
||||||
replacedData: replaced,
|
|
||||||
description,
|
|
||||||
replacedDescription,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update a work_plan_overrides row. */
|
/** Update a work_plan_overrides row. */
|
||||||
|
|||||||
Reference in New Issue
Block a user