From 5ae1b6c49a5916c207c72132ecee989a01958e92 Mon Sep 17 00:00:00 2001 From: iboughtbed Date: Tue, 12 Aug 2025 13:52:29 +0500 Subject: [PATCH 1/3] feat: blocked time layer metadata for events --- packages/api/src/interfaces/events.ts | 9 ++- .../calendars/google-calendar/events.ts | 60 +++++++++++++++++++ .../calendars/microsoft-calendar/events.ts | 55 +++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/packages/api/src/interfaces/events.ts b/packages/api/src/interfaces/events.ts index 52a258bc..51579ff9 100644 --- a/packages/api/src/interfaces/events.ts +++ b/packages/api/src/interfaces/events.ts @@ -20,13 +20,20 @@ export interface CalendarEvent { status: AttendeeStatus; comment?: string; }; - metadata?: Record; + metadata?: Metadata; conference?: Conference; recurrence?: Recurrence; providerAccountId?: string; recurringEventId?: string; } +export interface Metadata extends Record { + blockedTime?: { + before?: number; + after?: number; + }; +} + export interface ConferenceEntryPoint { joinUrl: { label?: string; diff --git a/packages/api/src/providers/calendars/google-calendar/events.ts b/packages/api/src/providers/calendars/google-calendar/events.ts index 76231320..12541cdf 100644 --- a/packages/api/src/providers/calendars/google-calendar/events.ts +++ b/packages/api/src/providers/calendars/google-calendar/events.ts @@ -116,6 +116,37 @@ function parseRecurrence( ); } +function parseBlockedTime(event: GoogleCalendarEvent) { + const extendedProperties = event.extendedProperties; + if (!extendedProperties?.private && !extendedProperties?.shared) { + return undefined; + } + + const blockedTimeData = + extendedProperties.private?.blockedTime || + extendedProperties.shared?.blockedTime; + + if (!blockedTimeData) { + return undefined; + } + + try { + const parsed = JSON.parse(blockedTimeData); + const result: { before?: number; after?: number } = {}; + + if (typeof parsed.before === "number" && parsed.before > 0) { + result.before = parsed.before; + } + if (typeof parsed.after === "number" && parsed.after > 0) { + result.after = parsed.after; + } + + return Object.keys(result).length > 0 ? result : undefined; + } catch { + return undefined; + } +} + interface ParsedGoogleCalendarEventOptions { calendar: Calendar; accountId: string; @@ -135,6 +166,7 @@ export function parseGoogleCalendarEvent({ event, event.start?.timeZone ?? defaultTimeZone, ); + const blockedTime = parseBlockedTime(event); return { // ID should always be present if not defined Google Calendar will generate one @@ -173,6 +205,11 @@ export function parseGoogleCalendarEvent({ ...(event.recurringEventId && { recurringEventId: event.recurringEventId, }), + ...(event.extendedProperties && { + private: event.extendedProperties.private, + shared: event.extendedProperties.shared, + }), + ...(blockedTime && { blockedTime }), }, }; } @@ -205,9 +242,29 @@ function toGoogleCalendarAttendees( return attendees.map(toGoogleCalendarAttendee); } +function toGoogleCalendarBlockedTime(blockedTime: { + before?: number; + after?: number; +}) { + return { + private: { + blockedTime: JSON.stringify(blockedTime), + }, + }; +} + export function toGoogleCalendarEvent( event: CreateEventInput | UpdateEventInput, ): GoogleCalendarEventCreateParams { + const blockedTimeExtendedProperties = + event.metadata && + "blockedTime" in event.metadata && + event.metadata.blockedTime + ? toGoogleCalendarBlockedTime( + event.metadata.blockedTime as { before?: number; after?: number }, + ) + : undefined; + return { id: event.id, summary: event.title, @@ -228,6 +285,9 @@ export function toGoogleCalendarEvent( recurrence: toRecurrenceProperties(event.recurrence), }), recurringEventId: event.recurringEventId, + ...(blockedTimeExtendedProperties && { + extendedProperties: blockedTimeExtendedProperties, + }), }; } diff --git a/packages/api/src/providers/calendars/microsoft-calendar/events.ts b/packages/api/src/providers/calendars/microsoft-calendar/events.ts index 60e3270e..d8d4187f 100644 --- a/packages/api/src/providers/calendars/microsoft-calendar/events.ts +++ b/packages/api/src/providers/calendars/microsoft-calendar/events.ts @@ -100,6 +100,36 @@ function parseResponseStatus( : undefined; } +function parseBlockedTime(event: MicrosoftEvent) { + if (!event.singleValueExtendedProperties) { + return undefined; + } + + const blockedTimeProperty = event.singleValueExtendedProperties.find( + (prop) => prop && prop.id && prop.id.includes("blockedTime"), + ); + + if (!blockedTimeProperty?.value) { + return undefined; + } + + try { + const parsed = JSON.parse(blockedTimeProperty.value); + const result: { before?: number; after?: number } = {}; + + if (typeof parsed.before === "number" && parsed.before > 0) { + result.before = parsed.before; + } + if (typeof parsed.after === "number" && parsed.after > 0) { + result.after = parsed.after; + } + + return Object.keys(result).length > 0 ? result : undefined; + } catch { + return undefined; + } +} + export function parseMicrosoftEvent({ accountId, calendar, @@ -112,6 +142,7 @@ export function parseMicrosoftEvent({ } const responseStatus = parseResponseStatus(event); + const blockedTime = parseBlockedTime(event); return { id: event.id!, @@ -157,6 +188,7 @@ export function parseMicrosoftEvent({ } : {}), onlineMeeting: event.onlineMeeting, + ...(blockedTime && { blockedTime }), }, }; } @@ -199,10 +231,30 @@ function toMicrosoftConferenceData(conference: Conference) { }; } +function toMicrosoftBlockedTime(blockedTime: { + before?: number; + after?: number; +}) { + return [ + { + id: `String {${crypto.randomUUID()}} Name blockedTime`, + value: JSON.stringify(blockedTime), + }, + ]; +} + export function toMicrosoftEvent( event: CreateEventInput | UpdateEventInput, ): MicrosoftEvent { const metadata = event.metadata as MicrosoftEventMetadata | undefined; + const blockedTimeProperties = + event.metadata && + "blockedTime" in event.metadata && + event.metadata.blockedTime + ? toMicrosoftBlockedTime( + event.metadata.blockedTime as { before?: number; after?: number }, + ) + : undefined; return { subject: event.title, @@ -220,6 +272,9 @@ export function toMicrosoftEvent( isAllDay: event.allDay ?? false, location: event.location ? { displayName: event.location } : undefined, // ...(event.conference && toMicrosoftConferenceData(event.conference)), + ...(blockedTimeProperties && { + singleValueExtendedProperties: blockedTimeProperties, + }), }; } From 137bc0ded46c0312132239515614c51cf00c4fff Mon Sep 17 00:00:00 2001 From: iboughtbed Date: Tue, 12 Aug 2025 20:33:38 +0500 Subject: [PATCH 2/3] fix: wip --- .../src/providers/calendars/microsoft-calendar.ts | 3 +++ .../calendars/microsoft-calendar/events.ts | 2 +- packages/api/src/schemas/events.ts | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/api/src/providers/calendars/microsoft-calendar.ts b/packages/api/src/providers/calendars/microsoft-calendar.ts index 856ab41e..388a4db8 100644 --- a/packages/api/src/providers/calendars/microsoft-calendar.ts +++ b/packages/api/src/providers/calendars/microsoft-calendar.ts @@ -119,6 +119,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { .filter( `start/dateTime ge '${startTime}' and end/dateTime le '${endTime}'`, ) + .expand("singleValueExtendedProperties") .orderby("start/dateTime") .top(CALENDAR_DEFAULTS.MAX_EVENTS_PER_CALENDAR) .get(); @@ -163,6 +164,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { return this.withErrorHandler("createEvent", async () => { const createdEvent: MicrosoftEvent = await this.graphClient .api(`${calendarPath(calendar.id)}/events`) + .expand("singleValueExtendedProperties") .post(toMicrosoftEvent(event)); return parseMicrosoftEvent({ @@ -190,6 +192,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider { // First, perform the regular event update const updatedEvent: MicrosoftEvent = await this.graphClient .api(`${calendarPath(calendar.id)}/events/${eventId}`) + .expand("singleValueExtendedProperties") .patch(toMicrosoftEvent(event)); // Then, handle response status update if present (Microsoft-specific approach) diff --git a/packages/api/src/providers/calendars/microsoft-calendar/events.ts b/packages/api/src/providers/calendars/microsoft-calendar/events.ts index d8d4187f..89a83901 100644 --- a/packages/api/src/providers/calendars/microsoft-calendar/events.ts +++ b/packages/api/src/providers/calendars/microsoft-calendar/events.ts @@ -106,7 +106,7 @@ function parseBlockedTime(event: MicrosoftEvent) { } const blockedTimeProperty = event.singleValueExtendedProperties.find( - (prop) => prop && prop.id && prop.id.includes("blockedTime"), + (prop) => prop && prop.id && prop.id.includes("Name blockedTime"), ); if (!blockedTimeProperty?.value) { diff --git a/packages/api/src/schemas/events.ts b/packages/api/src/schemas/events.ts index a98c0da6..24b352a8 100644 --- a/packages/api/src/schemas/events.ts +++ b/packages/api/src/schemas/events.ts @@ -67,6 +67,12 @@ const microsoftMetadataSchema = z.object({ tollNumber: z.string().optional(), }) .optional(), + blockedTime: z + .object({ + before: z.number().int().positive().optional(), + after: z.number().int().positive().optional(), + }) + .optional(), }); const googleMetadataSchema = z.object({ @@ -101,6 +107,15 @@ const googleMetadataSchema = z.object({ originalRecurrence: z.array(z.string()).optional(), // Store the recurring event ID for instances of recurring events recurringEventId: z.string().optional(), + // Extended properties for custom data + private: z.record(z.string(), z.string()).optional(), + shared: z.record(z.string(), z.string()).optional(), + blockedTime: z + .object({ + before: z.number().int().positive().optional(), + after: z.number().int().positive().optional(), + }) + .optional(), }); export const dateInputSchema = z.union([ From 1fbb8193c67f6b9710ad1021af5433ab2e94429d Mon Sep 17 00:00:00 2001 From: iboughtbed Date: Tue, 26 Aug 2025 16:29:46 +0500 Subject: [PATCH 3/3] fix(api): use superjson and zod to parse blocked time properties --- .../calendars/google-calendar/events.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/api/src/providers/calendars/google-calendar/events.ts b/packages/api/src/providers/calendars/google-calendar/events.ts index 12541cdf..13a167c0 100644 --- a/packages/api/src/providers/calendars/google-calendar/events.ts +++ b/packages/api/src/providers/calendars/google-calendar/events.ts @@ -1,5 +1,6 @@ import { detectMeetingLink } from "@analog/meeting-links"; import { Temporal } from "temporal-polyfill"; +import { z } from "zod/v3"; import { Attendee, @@ -10,6 +11,7 @@ import { Recurrence, } from "../../../interfaces"; import { CreateEventInput, UpdateEventInput } from "../../../schemas/events"; +import { superjson } from "../../../utils/superjson"; import { toRecurrenceProperties } from "../../../utils/recurrences/export"; import { fromRecurrenceProperties } from "../../../utils/recurrences/parse"; import { @@ -22,6 +24,11 @@ import { GoogleCalendarEventCreateParams, } from "./interfaces"; +const blockedTimeSchema = z.object({ + before: z.number().positive().optional(), + after: z.number().positive().optional(), +}); + export function toGoogleCalendarDate( value: Temporal.PlainDate | Temporal.Instant | Temporal.ZonedDateTime, ): GoogleCalendarDate | GoogleCalendarDateTime { @@ -131,17 +138,14 @@ function parseBlockedTime(event: GoogleCalendarEvent) { } try { - const parsed = JSON.parse(blockedTimeData); - const result: { before?: number; after?: number } = {}; + const parsed = superjson.parse(blockedTimeData); + const validated = blockedTimeSchema.parse(parsed); - if (typeof parsed.before === "number" && parsed.before > 0) { - result.before = parsed.before; - } - if (typeof parsed.after === "number" && parsed.after > 0) { - result.after = parsed.after; + if (!validated.before && !validated.after) { + return undefined; } - return Object.keys(result).length > 0 ? result : undefined; + return validated; } catch { return undefined; }