Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/api/src/interfaces/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,20 @@ export interface CalendarEvent {
status: AttendeeStatus;
comment?: string;
};
metadata?: Record<string, unknown>;
metadata?: Metadata;
conference?: Conference;
recurrence?: Recurrence;
providerAccountId?: string;
recurringEventId?: string;
}

export interface Metadata extends Record<string, unknown> {
blockedTime?: {
before?: number;
after?: number;
};
}

export interface ConferenceEntryPoint {
joinUrl: {
label?: string;
Expand Down
64 changes: 64 additions & 0 deletions packages/api/src/providers/calendars/google-calendar/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { detectMeetingLink } from "@analog/meeting-links";
import { Temporal } from "temporal-polyfill";
import { z } from "zod/v3";

import {
Attendee,
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -116,6 +123,34 @@ 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 = superjson.parse(blockedTimeData);
const validated = blockedTimeSchema.parse(parsed);

if (!validated.before && !validated.after) {
return undefined;
}

return validated;
} catch {
return undefined;
}
}

interface ParsedGoogleCalendarEventOptions {
calendar: Calendar;
accountId: string;
Expand All @@ -135,6 +170,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
Expand Down Expand Up @@ -173,6 +209,11 @@ export function parseGoogleCalendarEvent({
...(event.recurringEventId && {
recurringEventId: event.recurringEventId,
}),
...(event.extendedProperties && {
private: event.extendedProperties.private,
shared: event.extendedProperties.shared,
}),
...(blockedTime && { blockedTime }),
},
};
}
Expand Down Expand Up @@ -205,9 +246,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;
Comment on lines +263 to +270
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Compute extendedProperties once and merge with any user-provided maps

Right now you only include extendedProperties when blockedTime is present, and you don’t merge other keys from metadata.private/metadata.shared. On updates, this risks overwriting/losing existing keys.

Apply this diff to merge both sources:

-  const blockedTimeExtendedProperties =
-    event.metadata &&
-    "blockedTime" in event.metadata &&
-    event.metadata.blockedTime
-      ? toGoogleCalendarBlockedTime(
-          event.metadata.blockedTime as { before?: number; after?: number },
-        )
-      : undefined;
+  const md = event.metadata as
+    | { blockedTime?: { before?: number; after?: number }; private?: Record<string, string>; shared?: Record<string, string> }
+    | undefined;
+  const blockedTimeProps = md?.blockedTime
+    ? toGoogleCalendarBlockedTime(md.blockedTime, md?.shared?.blockedTime ? "shared" : "private")
+    : undefined;
+  const mergedExtendedProps =
+    md?.private || md?.shared || blockedTimeProps
+      ? {
+          private: { ...(md?.private ?? {}), ...(blockedTimeProps?.private ?? {}) },
+          shared: { ...(md?.shared ?? {}), ...(blockedTimeProps?.shared ?? {}) },
+        }
+      : undefined;

Then below, emit extendedProperties: mergedExtendedProps when defined.

🤖 Prompt for AI Agents
In packages/api/src/providers/calendars/google-calendar/events.ts around lines
222-229, compute a single merged extendedProperties object instead of only
emitting when blockedTime exists and avoid clobbering user maps: create new
objects for mergedPrivate and mergedShared by shallow-copying metadata.private
and metadata.shared (if present), convert blockedTime via
toGoogleCalendarBlockedTime when present and merge its keys into the
mergedPrivate (or mergedShared if your convention dictates) map without mutating
originals, then set mergedExtendedProps = { private: mergedPrivate, shared:
mergedShared } and emit extendedProperties: mergedExtendedProps only when at
least one of those maps is non-empty.


return {
id: event.id,
summary: event.title,
Expand All @@ -228,6 +289,9 @@ export function toGoogleCalendarEvent(
recurrence: toRecurrenceProperties(event.recurrence),
}),
recurringEventId: event.recurringEventId,
...(blockedTimeExtendedProperties && {
extendedProperties: blockedTimeExtendedProperties,
}),
Comment on lines +292 to +294
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Emit merged extendedProperties to avoid data loss on updates

Use the merged object from above rather than only blockedTime-derived properties.

Apply this diff:

-    ...(blockedTimeExtendedProperties && {
-      extendedProperties: blockedTimeExtendedProperties,
-    }),
+    ...(mergedExtendedProps && { extendedProperties: mergedExtendedProps }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
...(blockedTimeExtendedProperties && {
extendedProperties: blockedTimeExtendedProperties,
}),
// … other props …
- ...(blockedTimeExtendedProperties && {
- extendedProperties: blockedTimeExtendedProperties,
...(mergedExtendedProps && { extendedProperties: mergedExtendedProps }),
// … following props …
🤖 Prompt for AI Agents
In packages/api/src/providers/calendars/google-calendar/events.ts around lines
251-253, the code currently sets extendedProperties to only
blockedTimeExtendedProperties which overwrites/loses previously merged
properties; change it to emit the merged extendedProperties object created
earlier (e.g., mergedExtendedProperties or the variable name used above) so the
final payload contains the full merged set of properties instead of only the
blocked-time subset, and keep the conditional presence check (only include
extendedProperties when the merged object exists).

};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/providers/calendars/microsoft-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class MicrosoftCalendarProvider implements CalendarProvider {
.filter(
`start/dateTime ge '${startTime}' and end/dateTime le '${endTime}'`,
)
.expand("singleValueExtendedProperties")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

$expand for singleValueExtendedProperties should include an $filter and use a stable property ID

Microsoft Graph generally requires filtering extended properties by id when expanding; expanding without a filter is unsupported/inefficient and often rejected. Also, your current writer uses a random GUID for the property set ID, making it impossible to filter/round‑trip reliably.

Apply this diff (assumes you export a stable constant MICROSOFT_BLOCKED_TIME_PROP_ID from ./microsoft-calendar/events):

-        .expand("singleValueExtendedProperties")
+        .expand(
+          `singleValueExtendedProperties($filter=id eq '${MICROSOFT_BLOCKED_TIME_PROP_ID}')`
+        )

This ensures the events list returns only the blockedTime property and keeps OData semantics correct.


🌐 Web query:

Does Microsoft Graph require $filter on $expand=singleValueExtendedProperties for events? Provide an official doc snippet or example.

💡 Result:

Yes, Microsoft Graph requires a $filter when using $expand=singleValueExtendedProperties for events if you want to retrieve specific extended properties. This is explicitly documented in the official Microsoft Graph docs and confirmed by multiple support threads.

Official documentation states:

Using the query parameter $expand allows you to get the specified resource instance expanded with a specific extended property. Use a $filter and eq operator on the id property to specify the extended property. This is currently the only way to get the singleValueLegacyExtendedProperty object that represents an extended property[5].

Example from the official docs:

GET https://graph.microsoft.com/v1.0/me/events?$expand=singleValueExtendedProperties($filter=id eq 'String {GUID} Name CustomProperty')

This request will expand the event with only the extended property matching the given id[5][8].

Community threads and support answers clarify:

  • You cannot simply include all singleValueExtendedProperties by using $expand=singleValueExtendedProperties without a filter; you must specify the property id using a filter[3][8].
  • Without a filter, the response will not include extended properties[3].
  • This is by design; the API requires you to explicitly filter for the property you want to expand for events (and other resource types like messages)[5][8].

Representative code snippet from a support answer:

.Expand("singleValueExtendedProperties($filter=id eq 'String {GUID} Name MyExtendedProperty')")

Retrieves only the extended property with the specified id for each event[8].

In summary:
Yes, a $filter is required on $expand=singleValueExtendedProperties when querying events in Microsoft Graph—there is no supported way to expand all extended properties without specifying a filter by property ID[3][5][8].

Reference - official documentation snippet:

"Use a $filter and eq operator on the id property to specify the extended property. This is currently the only way to get the singleValueLegacyExtendedProperty object that represents an extended property."
— [Microsoft Graph Docs: Get singleValueLegacyExtendedProperty][5]

If you need to retrieve all singleValueExtendedProperties for an event, the Graph API currently does not support a wildcard or omitting the filter; you must query those properties individually by their ID[3][5][8].

Citations:


Include $filter on singleValueExtendedProperties expand and use a stable property ID

Microsoft Graph requires specifying a $filter on the id when expanding singleValueExtendedProperties; expanding without a filter returns no extended properties. Additionally, avoid using a random GUID—define and reuse a stable constant MICROSOFT_BLOCKED_TIME_PROP_ID to reliably round-trip your custom property. (learn.microsoft.com)

Key location:

  • packages/api/src/providers/calendars/microsoft-calendar.ts, around line 121: update the .expand call.

Suggested diff (assuming you export MICROSOFT_BLOCKED_TIME_PROP_ID from ./microsoft-calendar/events):

-        .expand("singleValueExtendedProperties")
+        .expand(
+          `singleValueExtendedProperties($filter=id eq '${MICROSOFT_BLOCKED_TIME_PROP_ID}')`
+        )

This ensures the events list returns only the blockedTime property and conforms to OData requirements.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.expand("singleValueExtendedProperties")
.expand(
`singleValueExtendedProperties($filter=id eq '${MICROSOFT_BLOCKED_TIME_PROP_ID}')`
)
🤖 Prompt for AI Agents
In packages/api/src/providers/calendars/microsoft-calendar.ts around line 121,
the current .expand("singleValueExtendedProperties") call must specify a $filter
on the extended property id and use a stable property id constant; import
MICROSOFT_BLOCKED_TIME_PROP_ID from ./microsoft-calendar/events and replace the
expand call with one that filters singleValueExtendedProperties by id equal to
that constant so the API returns the custom blockedTime property reliably.

.orderby("start/dateTime")
.top(CALENDAR_DEFAULTS.MAX_EVENTS_PER_CALENDAR)
.get();
Expand Down Expand Up @@ -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));
Comment on lines +167 to 168
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Remove $expand from POST create; prefer return=representation if you need the created entity

OData options like $expand are not guaranteed on POST. To receive the created event, rely on Prefer: return=representation. Since you already serialize singleValueExtendedProperties in the request body, Graph will persist them; if you need them immediately, follow with a GET using $expand + $filter.

Apply this diff:

-        .api(`${calendarPath(calendar.id)}/events`)
-        .expand("singleValueExtendedProperties")
-        .post(toMicrosoftEvent(event));
+        .api(`${calendarPath(calendar.id)}/events`)
+        .header("Prefer", 'return=representation')
+        .post(toMicrosoftEvent(event));

Optional: If you must guarantee the extended property in the response, perform a subsequent GET with the same $expand($filter=...).


🌐 Web query:

Does Microsoft Graph support using $expand on POST requests? What is the recommended way to get a representation of the created entity, including extended properties?

💡 Result:

Microsoft Graph does not support using $expand on POST requests; $expand is limited to GET queries and cannot be used to get expanded data in the response of a create (POST) call[7][1]. To obtain a representation of the newly created entity—including extended properties—the recommended approach is to issue a GET request for the entity and use $expand in that request.

Details

  • $expand in Microsoft Graph:
    The $expand query parameter is documented as only working for GET requests where you fetch an entity and want to include its related (navigation) properties[1][7]. The documentation and SDK examples show usage only with GET endpoints, e.g. GET /me/messages/{message-id}?$expand=attachments[1][7]. There is no mention or example of $expand with POST[1][7].

  • POST request behavior:
    When you create an entity via a POST request (e.g., creating a message, event, etc. with extended properties), the API response generally contains only the representation of the entity itself—not its expanded navigation properties or extended properties unless those are part of the main entity or specified in the request body[2][5].

  • Extended properties (single- and multi-value):
    To add extended properties when creating an entity, you include them in the initial POST request body[5][2]. However, to read all extended properties on the entity (especially those that are part of navigation properties), you need to do a follow-up GET with $expand[6][9]. For example:

    • After creating a message with extended properties, make a GET /messages/{id}?$expand=singleValueExtendedProperties,multiValueExtendedProperties request to retrieve the full representation including those properties[6][9].
    • This is described as "the only way to get the multiValueLegacyExtendedProperty object that represents an extended property"[6].

Workflow Recommendation

  1. POST to create the entity (including any extended properties in the request body, if supported)[5][2].
  2. GET the entity with $expand to retrieve the full representation, including extended properties:
    GET /messages/{id}?$expand=singleValueExtendedProperties,multiValueExtendedProperties
    
    This pattern is necessary because $expand is not permitted or supported in POST requests[7][1][6].

Additional Considerations

  • This limitation applies to all query options in Graph: only certain ones (e.g. $select, $expand, $filter) are permitted in GET requests[7][1].
  • For some resources, extended properties must be specifically requested via $expand or otherwise are not included in the default GET response[6][9].

Summary Table:

HTTP Verb $expand Supported? How to get extended properties in response?
POST No POST with extended properties; follow with GET+expand
GET Yes Use $expand in query string

This approach is explicitly confirmed in Microsoft Graph documentation regarding extended properties and $expand usage[1][5][6][7][9].

Citations:


Remove unsupported $expand on POST and use Prefer: return=representation with subsequent GET for extended properties

The $expand query option is only supported on GET operations and has no effect on POST requests, so it should be removed from the create call. (learn.microsoft.com)

To receive the created event entity in the response, rely on the default representation returned by POST (or explicitly request it) using the Prefer: return=representation header. If you don’t need the response body, you can request return=minimal, but for creation you typically want the representation. (docs.oasis-open.org, learn.microsoft.com)

Extended properties included in the request body are persisted but not returned in the POST response. To retrieve them immediately, issue a follow-up GET on the event with $expand=singleValueExtendedProperties (and any necessary $filter). (dzone.com)

Apply this diff in microsoft-calendar.ts:

-        .api(`${calendarPath(calendar.id)}/events`)
-        .expand("singleValueExtendedProperties")
-        .post(toMicrosoftEvent(event));
+        .api(`${calendarPath(calendar.id)}/events`)
+        .header("Prefer", "return=representation")
+        .post(toMicrosoftEvent(event));

Optional: to guarantee extended properties in the response, perform:

const created = await client
  .api(`${calendarPath(calendar.id)}/events/${createdEvent.id}`)
  .expand("singleValueExtendedProperties($filter=id eq '<your-prop-id>')")
  .get();
🤖 Prompt for AI Agents
In packages/api/src/providers/calendars/microsoft-calendar.ts around lines
139-140, remove the unsupported .expand("singleValueExtendedProperties") from
the POST call, add the HTTP header Prefer: return=representation to ensure the
created event is returned (or Prefer: return=minimal if you want no body), and
after the POST issue a follow-up GET to retrieve extended properties (e.g. GET
the created event with .expand("singleValueExtendedProperties($filter=id eq
'<prop-id>')") or appropriate $filter) so extended properties persisted on
create are returned.


return parseMicrosoftEvent({
Expand Down Expand Up @@ -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));
Comment on lines +195 to 196
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Remove $expand from PATCH update; ensure you get a representation

Similar to create, $expand on PATCH is not reliable. If you need the updated entity, use Prefer: return=representation, or re-fetch via GET with $expand($filter=...).

Apply this diff:

-        .api(`${calendarPath(calendar.id)}/events/${eventId}`)
-        .expand("singleValueExtendedProperties")
-        .patch(toMicrosoftEvent(event));
+        .api(`${calendarPath(calendar.id)}/events/${eventId}`)
+        .header("Prefer", 'return=representation')
+        .patch(toMicrosoftEvent(event));

Follow-up: If the API still returns 204 No Content depending on resource, perform a GET to fetch the updated record with $expand($filter=...).


🌐 Web query:

When updating an event via Microsoft Graph, does PATCH return a representation by default, or is Prefer: return=representation required? Cite official docs.

💡 Result:

When updating an event via Microsoft Graph using PATCH, the response does not return a resource representation by default; instead, it returns a 204 No Content by default unless you include the Prefer: return=representation header[2]. The official documentation for PATCHing events does not specify that a representation is returned unless the Prefer header is used[10].

Supporting details:

  • According to Microsoft’s OData-conformant endpoint behavior, PATCH (for resources like users, events, planner plans, and others) typically responds with 204 No Content unless Prefer: return=representation is specified[2][8]. This is also observed for event update operations.
  • The GitHub version of the official event-update doc and API request samples show PATCH requests without the Prefer header, and in practice, these result in a 204 response.
  • An official Microsoft tech community discussion confirms: "When patching a user (and probably many other things), the Graph API will always return 204 No Content, even when adding the header Prefer: return=representation. This however is not following the OData specification"[2]. This suggests even specifying Prefer might not always work as expected for certain resources, but the documented intent is that Prefer is required for representation.

In summary: PATCHing an event in Microsoft Graph returns 204 No Content by default; use Prefer: return=representation if you require the updated resource returned in the response[2][10].

Citations:


Ensure PATCH returns the updated event by replacing .expand() with Prefer: return=representation

The Graph API’s PATCH method does not return a resource by default (it responds with HTTP 204 No Content unless you include a Prefer: return=representation header). Relying on .expand("singleValueExtendedProperties") during a PATCH is therefore ineffective.

• Location: packages/api/src/providers/calendars/microsoft-calendar.ts, around lines 167–168
• Issue: .expand(...) on a PATCH request is ignored and will not yield the updated entity.

Apply this fix:

-        .api(`${calendarPath(calendar.id)}/events/${eventId}`)
-        .expand("singleValueExtendedProperties")
-        .patch(toMicrosoftEvent(event));
+        .api(`${calendarPath(calendar.id)}/events/${eventId}`)
+        .header("Prefer", "return=representation")
+        .patch(toMicrosoftEvent(event));

Follow-up: If you still receive 204 No Content (which can occur for some resources even with the header), immediately issue a GET on the same endpoint using:

.api(`${calendarPath(calendar.id)}/events/${eventId}`)
  .expand("singleValueExtendedProperties")
  .get();

to retrieve the fully populated event.

🤖 Prompt for AI Agents
In packages/api/src/providers/calendars/microsoft-calendar.ts around lines
167–168, the PATCH call currently uses .expand("singleValueExtendedProperties")
which is ignored by Graph PATCH (it returns 204 No Content by default); replace
the .expand usage on the PATCH with adding the request header "Prefer:
return=representation" so the PATCH returns the updated event (including
singleValueExtendedProperties) and, as a fallback if you still receive 204,
immediately issue a GET to the same event endpoint with
.expand("singleValueExtendedProperties") to retrieve the fully populated event.


// Then, handle response status update if present (Microsoft-specific approach)
Expand Down
55 changes: 55 additions & 0 deletions packages/api/src/providers/calendars/microsoft-calendar/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("Name 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;
}
}
Comment on lines +103 to +131
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Parsing extended properties should target a stable property ID, not a name substring

Filtering by prop.id.includes("Name blockedTime") is brittle and couples correctness to server-side behavior you don’t control. Use an exact, stable extended property ID and ensure your GET $expand filters by it.

Apply this diff (and export the constant for reuse by the provider’s GET):

+// Use a stable property set GUID that is constant for your app,
+// not a per-event random UUID. Generate once and commit it.
+export const MICROSOFT_BLOCKED_TIME_PROP_ID =
+  "String {f8a0fe66-219a-4e34-93da-e5edbe9801bc} Name blockedTime";
+
 function parseBlockedTime(event: MicrosoftEvent) {
   if (!event.singleValueExtendedProperties) {
     return undefined;
   }
 
-  const blockedTimeProperty = event.singleValueExtendedProperties.find(
-    (prop) => prop && prop.id && prop.id.includes("Name blockedTime"),
-  );
+  const blockedTimeProperty = event.singleValueExtendedProperties.find(
+    (prop) => prop?.id === MICROSOFT_BLOCKED_TIME_PROP_ID,
+  );

This aligns parsing with the $expand($filter=id eq '${MICROSOFT_BLOCKED_TIME_PROP_ID}') change.


export function parseMicrosoftEvent({
accountId,
calendar,
Expand All @@ -112,6 +142,7 @@ export function parseMicrosoftEvent({
}

const responseStatus = parseResponseStatus(event);
const blockedTime = parseBlockedTime(event);

return {
id: event.id!,
Expand Down Expand Up @@ -157,6 +188,7 @@ export function parseMicrosoftEvent({
}
: {}),
onlineMeeting: event.onlineMeeting,
...(blockedTime && { blockedTime }),
},
};
}
Expand Down Expand Up @@ -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),
},
];
}
Comment on lines +234 to +244
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Don’t generate a new GUID per write; use a constant property set ID and import crypto correctly if needed

  • Writing with a random GUID will make subsequent reads impossible to filter by ID and will accumulate orphaned properties.
  • Also, crypto.randomUUID() isn’t imported; relying on a global may fail type-checking in Node.

Apply this diff to use the same stable ID and drop the random UUID:

-function toMicrosoftBlockedTime(blockedTime: {
+function toMicrosoftBlockedTime(blockedTime: {
   before?: number;
   after?: number;
 }) {
   return [
     {
-      id: `String {${crypto.randomUUID()}} Name blockedTime`,
+      id: MICROSOFT_BLOCKED_TIME_PROP_ID,
       value: JSON.stringify(blockedTime),
     },
   ];
 }

If you still need randomUUID elsewhere, prefer import { randomUUID } from "node:crypto".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function toMicrosoftBlockedTime(blockedTime: {
before?: number;
after?: number;
}) {
return [
{
id: `String {${crypto.randomUUID()}} Name blockedTime`,
value: JSON.stringify(blockedTime),
},
];
}
function toMicrosoftBlockedTime(blockedTime: {
before?: number;
after?: number;
}) {
return [
{
id: MICROSOFT_BLOCKED_TIME_PROP_ID,
value: JSON.stringify(blockedTime),
},
];
}
🤖 Prompt for AI Agents
In packages/api/src/providers/calendars/microsoft-calendar/events.ts around
lines 234 to 244, the function currently generates a new GUID per write using
crypto.randomUUID(), which prevents reliable reads/filters and causes orphaned
properties; change it to use a stable constant property set ID (e.g., a fixed
string constant defined near the top of the module) instead of a random UUID,
remove the call to randomUUID(), and update the id value to use that constant;
if randomUUID is needed elsewhere, import it properly with import { randomUUID }
from "node:crypto" (but do not use it here).


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,
Expand All @@ -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,
}),
};
}

Expand Down
15 changes: 15 additions & 0 deletions packages/api/src/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Comment on lines +70 to 76
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Deduplicate and harden blockedTime schema

You define the same blockedTime shape twice. Extract a shared schema and also require at least one of before or after to avoid {} passing validation (which would serialize to {} in providers).

Apply this diff (add the shared schema once, then reuse):

 import { z } from "zod/v3";
 
+const blockedTimeSchema = z
+  .object({
+    before: z.number().int().positive().optional(),
+    after: z.number().int().positive().optional(),
+  })
+  .refine((v) => typeof v.before === "number" || typeof v.after === "number", {
+    message: "blockedTime must include at least one of 'before' or 'after'",
+    path: [],
+  });
+
 const microsoftMetadataSchema = z.object({
@@
-  blockedTime: z
-    .object({
-      before: z.number().int().positive().optional(),
-      after: z.number().int().positive().optional(),
-    })
-    .optional(),
+  blockedTime: blockedTimeSchema.optional(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
blockedTime: z
.object({
before: z.number().int().positive().optional(),
after: z.number().int().positive().optional(),
})
.optional(),
});
import { z } from "zod/v3";
const blockedTimeSchema = z
.object({
before: z.number().int().positive().optional(),
after: z.number().int().positive().optional(),
})
.refine(
(v) => typeof v.before === "number" || typeof v.after === "number",
{
message: "blockedTime must include at least one of 'before' or 'after'",
path: [],
}
);
const microsoftMetadataSchema = z.object({
// ... other fields ...
blockedTime: blockedTimeSchema.optional(),
});


const googleMetadataSchema = z.object({
Expand Down Expand Up @@ -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(),
});
Comment on lines +110 to 119
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Reuse the shared blockedTime schema for Google metadata as well

Keep the constraints identical across providers.

Apply this diff:

-  blockedTime: z
-    .object({
-      before: z.number().int().positive().optional(),
-      after: z.number().int().positive().optional(),
-    })
-    .optional(),
+  blockedTime: blockedTimeSchema.optional(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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(),
});
// Extended properties for custom data
private: z.record(z.string(), z.string()).optional(),
shared: z.record(z.string(), z.string()).optional(),
blockedTime: blockedTimeSchema.optional(),
});


export const dateInputSchema = z.union([
Expand Down
Loading