diff --git a/src/lib/pipeline/schemas.ts b/src/lib/pipeline/schemas.ts index 942de78..4ce63ab 100644 --- a/src/lib/pipeline/schemas.ts +++ b/src/lib/pipeline/schemas.ts @@ -18,9 +18,21 @@ export function optionalText(max: number) { .transform((value) => (value ? value : value === undefined ? undefined : null)); } +/** True when YYYY-MM-DD names a day that exists. Date would roll 2026-02-31 over into March. */ +function isCalendarDay(value: string) { + const [year, month, day] = value.split("-").map(Number) as [number, number, number]; + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} + /** Accepts an ISO timestamp or an HTML date-input value (YYYY-MM-DD, interpreted as UTC midnight). */ export const optionalDate = z - .union([z.string().datetime({ offset: true }), z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD"), z.literal(""), z.null()]) + .union([ + z.string().datetime({ offset: true }), + z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD").refine(isCalendarDay, "Enter a date that exists."), + z.literal(""), + z.null(), + ]) .optional() .transform((value) => { if (value === undefined) return undefined; diff --git a/tests/opportunity-pipeline.test.ts b/tests/opportunity-pipeline.test.ts index 2cc9c90..d7a1d86 100644 --- a/tests/opportunity-pipeline.test.ts +++ b/tests/opportunity-pipeline.test.ts @@ -131,6 +131,15 @@ describe("opportunity input validation", () => { expect(parsed.nextActionAt).toEqual(new Date("2026-10-01T00:00:00.000Z")); }); + it("rejects date-only values for days that do not exist", () => { + for (const nextActionAt of ["2026-02-31", "2026-04-31", "2026-13-01", "2026-00-10"]) { + expect(createOpportunitySchema.safeParse({ company: { name: "Acme" }, nextActionAt }).success).toBe(false); + } + expect(createOpportunitySchema.parse({ company: { name: "Acme" }, nextActionAt: "2028-02-29" }).nextActionAt).toEqual( + new Date("2028-02-29T00:00:00.000Z"), + ); + }); + it("requires a version and at least one field for updates, and a known stage for stage changes", () => { expect(updateOpportunitySchema.safeParse({ expectedVersion: 1 }).success).toBe(false); expect(updateOpportunitySchema.safeParse({ priority: "HIGH" }).success).toBe(false);