fix(plan): allow empty notes, use local time for past-date gate
Three related fixes from manual smoke testing: - Zod schema: drop min(1) on note field across all four schemas — note is optional in real usage, and the modal initialized it to empty. - assertNotPastDate: replace toISOString() (UTC) with local-time getters to match the rest of the codebase. Fixes off-by-one near day boundaries. - createEntry / updateEntry: also check the date_to endpoint for past dates, so a range can't extend backwards into the past even when its start is today or future. Defense in depth. Modal: drop `required` attribute and asterisk from the note field. Tests: add 5 new cases covering empty note, past date HTTP rejection, and the new to-endpoint check in updateEntry.
This commit is contained in:
@@ -305,6 +305,42 @@ describe("plan.service.createEntry", () => {
|
|||||||
);
|
);
|
||||||
expect("error" in result).toBe(true);
|
expect("error" in result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows an empty note (text is optional)", async () => {
|
||||||
|
const result = await createEntry(
|
||||||
|
{
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: "2099-07-15",
|
||||||
|
date_to: "2099-07-15",
|
||||||
|
category: "work",
|
||||||
|
note: "",
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("data" in result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a range whose date_to is in the past, even if date_from is today", async () => {
|
||||||
|
// The range check `date_to >= date_from` fires before the past-date
|
||||||
|
// check, so a backwards range returns 400 (correct UX). The to-endpoint
|
||||||
|
// past-date check matters when updating an existing entry — see the
|
||||||
|
// matching test in updateEntry below. Here we just confirm that a
|
||||||
|
// range whose start is in the past is rejected as 403.
|
||||||
|
const result = await createEntry(
|
||||||
|
{
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: "2000-01-01",
|
||||||
|
date_to: "2000-01-05",
|
||||||
|
category: "work",
|
||||||
|
note: `${N}past-both`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
if ("error" in result) expect(result.status).toBe(403);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -338,6 +374,31 @@ describe("plan.service.updateEntry", () => {
|
|||||||
expect((updated.oldData as any).note).toBe(`${N}original`);
|
expect((updated.oldData as any).note).toBe(`${N}original`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an update that pushes date_to into the past", async () => {
|
||||||
|
// Future entry, but the user tries to extend its end backwards into
|
||||||
|
// the past. The to-endpoint past-date check should fire.
|
||||||
|
const created = await createEntry(
|
||||||
|
{
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: "2099-08-10",
|
||||||
|
date_to: "2099-08-15",
|
||||||
|
category: "work",
|
||||||
|
note: `${N}to-shorten`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (!("data" in created)) throw new Error("setup failed");
|
||||||
|
const updated = await updateEntry(
|
||||||
|
created.data.id,
|
||||||
|
{ date_to: "2000-01-05" },
|
||||||
|
adminUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect("error" in updated).toBe(true);
|
||||||
|
if ("error" in updated) expect(updated.status).toBe(403);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -787,6 +848,28 @@ describe("HTTP /api/admin/plan", () => {
|
|||||||
expect(body.data.note).toBe(`${N}http create`);
|
expect(body.data.note).toBe(`${N}http create`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /entries accepts an empty note (note is optional)", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: "2097-08-05",
|
||||||
|
date_to: "2097-08-05",
|
||||||
|
category: "work",
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /entries rejects a past date without ?force=1", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries", adminToken, {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: "2000-02-01",
|
||||||
|
date_to: "2000-02-01",
|
||||||
|
category: "work",
|
||||||
|
note: `${N}past http`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
it("POST /overrides with force=1 allows past date", async () => {
|
it("POST /overrides with force=1 allows past date", async () => {
|
||||||
const res = await authPost(
|
const res = await authPost(
|
||||||
"/api/admin/plan/overrides?force=1",
|
"/api/admin/plan/overrides?force=1",
|
||||||
|
|||||||
@@ -286,13 +286,12 @@ function EditForm(props: Props) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Text poznámky" required>
|
<FormField label="Text poznámky (volitelný)">
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
rows={3}
|
rows={3}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormModal>
|
</FormModal>
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ export const CreatePlanEntrySchema = z
|
|||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.nullish(),
|
.nullish(),
|
||||||
category: planCategoryEnum.default("work"),
|
category: planCategoryEnum.default("work"),
|
||||||
note: z
|
note: z.string().max(500, "Maximálně 500 znaků"),
|
||||||
.string()
|
|
||||||
.min(1, "Text poznámky je povinný")
|
|
||||||
.max(500, "Maximálně 500 znaků"),
|
|
||||||
})
|
})
|
||||||
.refine((v) => v.date_to >= v.date_from, {
|
.refine((v) => v.date_to >= v.date_from, {
|
||||||
message: "Datum do musí být stejné nebo po datumu od",
|
message: "Datum do musí být stejné nebo po datumu od",
|
||||||
@@ -48,7 +45,7 @@ export const UpdatePlanEntrySchema = z
|
|||||||
.transform((v) => (v === null ? null : Number(v)))
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
.nullish(),
|
.nullish(),
|
||||||
category: planCategoryEnum.optional(),
|
category: planCategoryEnum.optional(),
|
||||||
note: z.string().min(1).max(500).optional(),
|
note: z.string().max(500).optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(v) =>
|
(v) =>
|
||||||
@@ -69,10 +66,7 @@ export const CreatePlanOverrideSchema = z.object({
|
|||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.nullish(),
|
.nullish(),
|
||||||
category: planCategoryEnum,
|
category: planCategoryEnum,
|
||||||
note: z
|
note: z.string().max(500, "Maximálně 500 znaků"),
|
||||||
.string()
|
|
||||||
.min(1, "Text poznámky je povinný")
|
|
||||||
.max(500, "Maximálně 500 znaků"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdatePlanOverrideSchema = z.object({
|
export const UpdatePlanOverrideSchema = z.object({
|
||||||
@@ -81,7 +75,7 @@ export const UpdatePlanOverrideSchema = z.object({
|
|||||||
.transform((v) => (v === null ? null : Number(v)))
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
.nullish(),
|
.nullish(),
|
||||||
category: planCategoryEnum.optional(),
|
category: planCategoryEnum.optional(),
|
||||||
note: z.string().min(1).max(500).optional(),
|
note: z.string().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PlanRangeQuerySchema = z.object({
|
export const PlanRangeQuerySchema = z.object({
|
||||||
|
|||||||
@@ -250,9 +250,12 @@ export async function listPlanUsers(): Promise<PlanUser[]> {
|
|||||||
* Guard helper: returns { error, status } if `dateStr` is in the past
|
* Guard helper: returns { error, status } if `dateStr` is in the past
|
||||||
* and `force` is not set. Returns null if the date is editable.
|
* and `force` is not set. Returns null if the date is editable.
|
||||||
*
|
*
|
||||||
* The comparison uses ISO date string equality. For past-date guard purposes
|
* The comparison uses local Czech time (the project's TZ setting). The
|
||||||
* any date strictly before today (in the local timezone per the project's
|
* previous implementation used `toISOString().slice(0, 10)`, which is
|
||||||
* TZ=Europe/Prague setting) is locked.
|
* UTC — that could be off-by-one near day boundaries. The fix mirrors
|
||||||
|
* the `Date.prototype.toJSON` override in `src/config/env.ts` which
|
||||||
|
* also uses local-time getters, so the past-date gate is consistent
|
||||||
|
* with how dates are formatted throughout the rest of the codebase.
|
||||||
*/
|
*/
|
||||||
export function assertNotPastDate(
|
export function assertNotPastDate(
|
||||||
dateStr: string,
|
dateStr: string,
|
||||||
@@ -260,8 +263,8 @@ export function assertNotPastDate(
|
|||||||
): { error: string; status: number } | null {
|
): { error: string; status: number } | null {
|
||||||
if (force) return null;
|
if (force) return null;
|
||||||
|
|
||||||
const today = new Date();
|
const d = new Date();
|
||||||
const todayStr = today.toISOString().slice(0, 10);
|
const todayStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
if (dateStr < todayStr) {
|
if (dateStr < todayStr) {
|
||||||
return {
|
return {
|
||||||
error:
|
error:
|
||||||
@@ -328,9 +331,12 @@ export async function createEntry(
|
|||||||
if (input.date_to < input.date_from) {
|
if (input.date_to < input.date_from) {
|
||||||
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
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)
|
// Past-date lock on both endpoints — a range that starts today but
|
||||||
const lock = assertNotPastDate(input.date_from, force);
|
// extends into the past would still lock past days in the grid.
|
||||||
if (lock) return lock;
|
const lockFrom = assertNotPastDate(input.date_from, force);
|
||||||
|
if (lockFrom) return lockFrom;
|
||||||
|
const lockTo = assertNotPastDate(input.date_to, force);
|
||||||
|
if (lockTo) return lockTo;
|
||||||
|
|
||||||
const created = await prisma.work_plan_entries.create({
|
const created = await prisma.work_plan_entries.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -365,11 +371,15 @@ export async function updateEntry(
|
|||||||
return { error: "Záznam nenalezen", status: 404 };
|
return { error: "Záznam nenalezen", status: 404 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Past-date lock on the existing or new from-date
|
// Past-date lock on both endpoints (using either the new value if
|
||||||
|
// provided, or the existing value as a fallback).
|
||||||
const fromStr =
|
const fromStr =
|
||||||
input.date_from ?? existing.date_from.toISOString().slice(0, 10);
|
input.date_from ?? existing.date_from.toISOString().slice(0, 10);
|
||||||
const lock = assertNotPastDate(fromStr, force);
|
const toStr = input.date_to ?? existing.date_to.toISOString().slice(0, 10);
|
||||||
if (lock) return lock;
|
const lockFrom = assertNotPastDate(fromStr, force);
|
||||||
|
if (lockFrom) return lockFrom;
|
||||||
|
const lockTo = assertNotPastDate(toStr, force);
|
||||||
|
if (lockTo) return lockTo;
|
||||||
|
|
||||||
if (input.date_from && input.date_to && input.date_to < input.date_from) {
|
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 };
|
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||||
|
|||||||
Reference in New Issue
Block a user