From 02e10d3fbf663647aa069fd9dec119c4fffe5fbb Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:20:35 +0000 Subject: [PATCH 1/4] feat(events): Add date range validation to event schema Co-authored-by: Jean --- packages/schemas/src/events.ts | 91 ++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/packages/schemas/src/events.ts b/packages/schemas/src/events.ts index 24793867..03af4389 100644 --- a/packages/schemas/src/events.ts +++ b/packages/schemas/src/events.ts @@ -132,6 +132,40 @@ const attendeeSchema = z.object({ additionalGuests: z.number().int().optional(), }); +/** + * Helper function to compare two temporal dates + * Handles PlainDate, Instant, and ZonedDateTime types + * Returns true if start is earlier than end + */ +function isStartBeforeEnd( + start: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, + end: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, +): boolean { + // Convert both to Instant for comparison + let startInstant: Temporal.Instant; + let endInstant: Temporal.Instant; + + if (start instanceof Temporal.Instant) { + startInstant = start; + } else if (start instanceof Temporal.ZonedDateTime) { + startInstant = start.toInstant(); + } else { + // PlainDate - convert to Instant at start of day (UTC) + startInstant = start.toZonedDateTime("UTC").toInstant(); + } + + if (end instanceof Temporal.Instant) { + endInstant = end; + } else if (end instanceof Temporal.ZonedDateTime) { + endInstant = end.toInstant(); + } else { + // PlainDate - convert to Instant at start of day (UTC) + endInstant = end.toZonedDateTime("UTC").toInstant(); + } + + return Temporal.Instant.compare(startInstant, endInstant) < 0; +} + export const recurrenceSchema = z .object({ freq: z @@ -205,33 +239,38 @@ export const recurrenceSchema = z }, ); -export const createEventInputSchema = z.object({ - id: z.string(), - title: z.string().optional(), - start: dateInputSchema, - end: dateInputSchema, - allDay: z.boolean().optional(), - recurrence: recurrenceSchema.optional(), - recurringEventId: z.string().optional(), - description: z.string().optional(), - location: z.string().optional(), - availability: z.enum(["busy", "free"]).optional(), - color: z.string().optional(), - visibility: z - .enum(["default", "public", "private", "confidential"]) - .optional(), - accountId: z.string(), - calendarId: z.string(), - providerId: z.enum(["google", "microsoft"]), - readOnly: z.boolean(), - metadata: z.union([microsoftMetadataSchema, googleMetadataSchema]).optional(), - attendees: z.array(attendeeSchema).optional(), - conference: conferenceSchema.optional(), - createdAt: z.instanceof(Temporal.Instant).optional(), - updatedAt: z.instanceof(Temporal.Instant).optional(), -}); +export const createEventInputSchema = z + .object({ + id: z.string(), + title: z.string().optional(), + start: dateInputSchema, + end: dateInputSchema, + allDay: z.boolean().optional(), + recurrence: recurrenceSchema.optional(), + recurringEventId: z.string().optional(), + description: z.string().optional(), + location: z.string().optional(), + availability: z.enum(["busy", "free"]).optional(), + color: z.string().optional(), + visibility: z + .enum(["default", "public", "private", "confidential"]) + .optional(), + accountId: z.string(), + calendarId: z.string(), + providerId: z.enum(["google", "microsoft"]), + readOnly: z.boolean(), + metadata: z.union([microsoftMetadataSchema, googleMetadataSchema]).optional(), + attendees: z.array(attendeeSchema).optional(), + conference: conferenceSchema.optional(), + createdAt: z.instanceof(Temporal.Instant).optional(), + updatedAt: z.instanceof(Temporal.Instant).optional(), + }) + .refine((data) => isStartBeforeEnd(data.start, data.end), { + message: "Event start time must be earlier than end time", + path: ["start"], + }); -export const updateEventInputSchema = createEventInputSchema.extend({ +export const updateEventInputSchema = createEventInputSchema.safeExtend({ id: z.string(), etag: z.string().optional(), metadata: z.union([microsoftMetadataSchema, googleMetadataSchema]).optional(), From 9f0c79f042e80061dfd7124cca9d653856750761 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:24:14 +0000 Subject: [PATCH 2/4] refactor(events): use isBefore from @repo/temporal for validation --- packages/schemas/src/events.ts | 36 ++-------------------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/packages/schemas/src/events.ts b/packages/schemas/src/events.ts index 03af4389..f8f68d64 100644 --- a/packages/schemas/src/events.ts +++ b/packages/schemas/src/events.ts @@ -1,3 +1,4 @@ +import { isBefore } from "@repo/temporal"; import { Temporal } from "temporal-polyfill"; import { zInstantInstance, @@ -132,39 +133,6 @@ const attendeeSchema = z.object({ additionalGuests: z.number().int().optional(), }); -/** - * Helper function to compare two temporal dates - * Handles PlainDate, Instant, and ZonedDateTime types - * Returns true if start is earlier than end - */ -function isStartBeforeEnd( - start: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, - end: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, -): boolean { - // Convert both to Instant for comparison - let startInstant: Temporal.Instant; - let endInstant: Temporal.Instant; - - if (start instanceof Temporal.Instant) { - startInstant = start; - } else if (start instanceof Temporal.ZonedDateTime) { - startInstant = start.toInstant(); - } else { - // PlainDate - convert to Instant at start of day (UTC) - startInstant = start.toZonedDateTime("UTC").toInstant(); - } - - if (end instanceof Temporal.Instant) { - endInstant = end; - } else if (end instanceof Temporal.ZonedDateTime) { - endInstant = end.toInstant(); - } else { - // PlainDate - convert to Instant at start of day (UTC) - endInstant = end.toZonedDateTime("UTC").toInstant(); - } - - return Temporal.Instant.compare(startInstant, endInstant) < 0; -} export const recurrenceSchema = z .object({ @@ -265,7 +233,7 @@ export const createEventInputSchema = z createdAt: z.instanceof(Temporal.Instant).optional(), updatedAt: z.instanceof(Temporal.Instant).optional(), }) - .refine((data) => isStartBeforeEnd(data.start, data.end), { + .refine((data) => isBefore(data.start, data.end), { message: "Event start time must be earlier than end time", path: ["start"], }); From ff5dc52b51ec0ba60db0234b91f8c5ec16589e5e Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:40:53 +0000 Subject: [PATCH 3/4] refactor(schemas): add type guard for temporal comparison in event validation --- packages/schemas/src/events.ts | 4 ++-- packages/temporal/src/compare.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/schemas/src/events.ts b/packages/schemas/src/events.ts index f8f68d64..5fb3c41d 100644 --- a/packages/schemas/src/events.ts +++ b/packages/schemas/src/events.ts @@ -1,4 +1,4 @@ -import { isBefore } from "@repo/temporal"; +import { isBefore, isSameType } from "@repo/temporal"; import { Temporal } from "temporal-polyfill"; import { zInstantInstance, @@ -233,7 +233,7 @@ export const createEventInputSchema = z createdAt: z.instanceof(Temporal.Instant).optional(), updatedAt: z.instanceof(Temporal.Instant).optional(), }) - .refine((data) => isBefore(data.start, data.end), { + .refine((data) => !isSameType(data.start, data.end) || isBefore(data.start, data.end), { message: "Event start time must be earlier than end time", path: ["start"], }); diff --git a/packages/temporal/src/compare.ts b/packages/temporal/src/compare.ts index c09126f9..f36b9fd7 100644 --- a/packages/temporal/src/compare.ts +++ b/packages/temporal/src/compare.ts @@ -286,6 +286,13 @@ export function isToday( return today.equals(toPlainDate(value, { timeZone })); } +export function isSameType( + a: TemporalConvertible, + b: TemporalConvertible, +): boolean { + return a.constructor === b.constructor; +} + export function isBefore(a: Temporal.PlainDate, b: Temporal.PlainDate): boolean; export function isBefore( a: Temporal.ZonedDateTime, From b4d71ddf14efb9f3d990d9eb077a4e10d3cec642 Mon Sep 17 00:00:00 2001 From: "tembo[bot]" <208362400+tembo-io[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:10:07 +0000 Subject: [PATCH 4/4] fix(schemas): add type guards and format validation refine --- packages/schemas/src/events.ts | 42 ++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/schemas/src/events.ts b/packages/schemas/src/events.ts index 5fb3c41d..90ea5e99 100644 --- a/packages/schemas/src/events.ts +++ b/packages/schemas/src/events.ts @@ -1,4 +1,3 @@ -import { isBefore, isSameType } from "@repo/temporal"; import { Temporal } from "temporal-polyfill"; import { zInstantInstance, @@ -7,6 +6,8 @@ import { } from "temporal-zod"; import * as z from "zod"; +import { isBefore, isSameType } from "@repo/temporal"; + const conferenceEntryPointSchema = z.object({ joinUrl: z.object({ label: z.string().optional(), @@ -133,7 +134,6 @@ const attendeeSchema = z.object({ additionalGuests: z.number().int().optional(), }); - export const recurrenceSchema = z .object({ freq: z @@ -227,16 +227,44 @@ export const createEventInputSchema = z calendarId: z.string(), providerId: z.enum(["google", "microsoft"]), readOnly: z.boolean(), - metadata: z.union([microsoftMetadataSchema, googleMetadataSchema]).optional(), + metadata: z + .union([microsoftMetadataSchema, googleMetadataSchema]) + .optional(), attendees: z.array(attendeeSchema).optional(), conference: conferenceSchema.optional(), createdAt: z.instanceof(Temporal.Instant).optional(), updatedAt: z.instanceof(Temporal.Instant).optional(), }) - .refine((data) => !isSameType(data.start, data.end) || isBefore(data.start, data.end), { - message: "Event start time must be earlier than end time", - path: ["start"], - }); + .refine( + (data) => { + if (!isSameType(data.start, data.end)) { + return false; + } + if ( + data.start instanceof Temporal.PlainDate && + data.end instanceof Temporal.PlainDate + ) { + return isBefore(data.start, data.end); + } + if ( + data.start instanceof Temporal.Instant && + data.end instanceof Temporal.Instant + ) { + return isBefore(data.start, data.end); + } + if ( + data.start instanceof Temporal.ZonedDateTime && + data.end instanceof Temporal.ZonedDateTime + ) { + return isBefore(data.start, data.end); + } + return false; + }, + { + message: "Event start time must be earlier than end time", + path: ["start"], + }, + ); export const updateEventInputSchema = createEventInputSchema.safeExtend({ id: z.string(),