From 2c1e747fa1e487f31770ea3c1c24b942ecea085c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 22:43:57 -0700 Subject: [PATCH 1/2] fix(search): index only meetings from Google Calendar and mark declined invites The listing never filtered by event type, so working-location, out-of-office, focus-time and birthday entries were indexed as documents. With recurring events expanded to instances, a daily working-location entry alone produced dozens of near-identical documents per member. The listing now asks Google for default events only and the connector drops any other type it still receives, including on a direct fetch. A shared calendar the member can read only as free/busy returns time blocks with no title or description, which were indexed as "Untitled Event". An event with neither is no longer a document. Invitations the connected account declined stay indexed, since the agenda and links are still something the person was sent, but the content now carries a "Response: declined" line and the metadata records the response. The metadata-only hash gains a declined suffix so already-indexed invitations pick the line up on the next sync without waiting for an edit. Co-Authored-By: Claude Fable 5.1 --- .../content/docs/search/google-calendar.mdx | 4 +- .../google-calendar/google-calendar.test.ts | 66 ++++++++++++++++++- .../google-calendar/google-calendar.ts | 42 ++++++++++-- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx index 020039b20ab..1ce80d7ad57 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -68,9 +68,9 @@ In the add-source form, **More options** contains optional **Metadata tags**. Se ## What gets indexed -Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. Results link back to Google Calendar. +Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. An invitation you declined stays searchable and is marked `Response: declined`. Results link back to Google Calendar. -Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). +Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays are not indexed. A shared calendar where you can see only free or busy times contributes nothing, since those blocks have no title or description. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). Search schedules syncs hourly. Event edits, cancellations, access changes, and events moving outside the date window are reconciled during background sync. The first sync may take longer, and results appear as indexing progresses. diff --git a/apps/sim/connectors/google-calendar/google-calendar.test.ts b/apps/sim/connectors/google-calendar/google-calendar.test.ts index 84f9d170e74..cf591275eaa 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.test.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.test.ts @@ -178,7 +178,7 @@ describe('Google Calendar Search isolation', () => { fetchMock.mockResolvedValueOnce( jsonResponse({ accessRole: 'reader', - items: [{ id: EVENT.id, updated: EVENT.updated, start: EVENT.start, end: EVENT.end }], + items: [{ ...EVENT, description: undefined, organizer: undefined, attendees: undefined }], }) ) const restricted = await googleCalendarConnector.listDocuments('token', {}, undefined, alice) @@ -189,6 +189,70 @@ describe('Google Calendar Search isolation', () => { expect(restricted.documents[0].metadata?.organizer).toBe('') }) + it('withdraws a free/busy time block that carries no title or description', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + accessRole: 'freeBusyReader', + items: [{ id: EVENT.id, updated: EVENT.updated, start: EVENT.start, end: EVENT.end }], + }) + ) + const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice) + expect(result).toEqual({ documents: [], hasMore: false }) + }) + + it('asks Google for meetings only and drops status entries it still returns', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + items: [ + { ...EVENT, id: 'wfh', summary: 'Home', eventType: 'workingLocation' }, + { ...EVENT, id: 'ooo', summary: 'Out of office', eventType: 'outOfOffice' }, + { ...EVENT, id: 'focus', summary: 'Focus time', eventType: 'focusTime' }, + { ...EVENT, id: 'bday', summary: 'Birthday', eventType: 'birthday' }, + { ...EVENT, id: 'meeting', eventType: 'default' }, + EVENT, + ], + }) + ) + const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice) + expect(result.documents.map((doc) => doc.externalId)).toEqual([ + expect.stringContaining('meeting'), + expect.stringContaining(EVENT.id), + ]) + const listUrl = new URL(String(fetchMock.mock.calls[0][0])) + expect(listUrl.searchParams.getAll('eventTypes')).toEqual(['default']) + }) + + it('returns null for a status entry fetched directly', async () => { + const listing = await googleCalendarConnector.listDocuments('token', {}, undefined, alice) + fetchMock.mockResolvedValueOnce(jsonResponse({ ...EVENT, eventType: 'outOfOffice' })) + expect( + await googleCalendarConnector.getDocument('token', {}, listing.documents[0].externalId, alice) + ).toBeNull() + }) + + it('keeps a declined invitation and marks the response on it', async () => { + const declined = { + ...EVENT, + attendees: [ + ...EVENT.attendees, + { email: 'alice@example.com', self: true, responseStatus: 'declined' }, + ], + } + fetchMock.mockResolvedValueOnce(jsonResponse({ items: [declined] })) + const [doc] = (await googleCalendarConnector.listDocuments('token', {}, undefined, alice)) + .documents + expect(doc.content).toContain('Response: declined') + expect(doc.metadata?.responseStatus).toBe('declined') + + const accepted = await listOne({}) + expect(accepted.content).not.toContain('Response:') + expect(accepted.metadata?.responseStatus).toBeUndefined() + + fetchMock.mockResolvedValueOnce(jsonResponse({ items: [declined] })) + const workspaceDeclined = await listOne({}) + expect(workspaceDeclined.contentHash).toBe(`${accepted.contentHash}:declined`) + }) + it('withdraws cancelled events, including instances of recurring events', async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ items: [{ ...EVENT, status: 'cancelled', recurringEventId: 'series' }] }) diff --git a/apps/sim/connectors/google-calendar/google-calendar.ts b/apps/sim/connectors/google-calendar/google-calendar.ts index 84ccc631ffc..15557bf4970 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -129,6 +129,33 @@ function readIncludeAttendees(sourceConfig: Record): boolean { */ const NO_ATTENDEES_HASH_SUFFIX = ':noattendees' +/** + * Appended to the metadata-only content hash of an invitation the connected + * account declined, so an event indexed before the response was recorded picks + * up the response line without waiting for the organizer to edit it. + */ +const DECLINED_HASH_SUFFIX = ':declined' + +/** Only `default` events describe meetings; the listing asks Google for these alone. */ +const INDEXED_EVENT_TYPE = 'default' + +/** The connected account's own response to an invitation, when Google reports it. */ +function memberResponseStatus(event: CalendarEvent): string | undefined { + return event.attendees?.find((attendee) => attendee.self)?.responseStatus +} + +/** + * Whether the event carries something to search. Status entries (working + * location, out of office, focus time, birthdays) describe availability rather + * than a meeting, and a reader with free/busy access alone sees a time block + * with no title or description. + */ +function isSearchableEvent(event: CalendarEvent): boolean { + if (event.status === 'cancelled') return false + if (event.eventType && event.eventType !== INDEXED_EVENT_TYPE) return false + return Boolean(event.summary?.trim() || event.description?.trim()) +} + /** * Counts attendees excluding rooms/equipment, matching what the content renderer lists. */ @@ -182,6 +209,10 @@ function eventToContent(event: CalendarEvent, includeAttendees: boolean): string parts.push(`Location: ${event.location}`) } + if (memberResponseStatus(event) === 'declined') { + parts.push('Response: declined') + } + if (includeAttendees) { const organizer = formatOrganizer(event.organizer) if (organizer) { @@ -271,13 +302,14 @@ async function eventToDocument( includeAttendees: boolean, syncContext?: Record ): Promise { - if (event.status === 'cancelled') return null + if (!isSearchableEvent(event)) return null const content = eventToContent(event, includeAttendees) if (!content.trim()) return null const startTime = event.start?.dateTime || event.start?.date || '' const attendeeCount = countAttendees(event.attendees) + const responseStatus = memberResponseStatus(event) const memberScoped = isPerMemberListing(syncContext) const externalId = memberDocumentId( @@ -287,7 +319,9 @@ async function eventToDocument( const baseHash = isMultiCalendar ? `gcal:${calendarId}:${event.id}:${event.updated ?? ''}` : `gcal:${event.id}:${event.updated ?? ''}` - const contentHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}` + const attendeeHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}` + const contentHash = + responseStatus === 'declined' ? `${attendeeHash}${DECLINED_HASH_SUFFIX}` : attendeeHash const metadata = { calendarId, @@ -296,6 +330,7 @@ async function eventToDocument( location: event.location || '', organizer: includeAttendees ? formatOrganizer(event.organizer) : '', attendeeCount, + ...(responseStatus ? { responseStatus } : {}), isAllDay: isAllDayEvent(event), eventDate: startTime, updatedTime: event.updated, @@ -393,6 +428,7 @@ export const googleCalendarConnector: ConnectorConfig = { const queryParams = new URLSearchParams({ singleEvents: 'true', orderBy: 'startTime', + eventTypes: INDEXED_EVENT_TYPE, maxResults: String(pageSize), timeMin, timeMax, @@ -594,8 +630,6 @@ export const googleCalendarConnector: ConnectorConfig = { const event = (await response.json()) as CalendarEvent - if (event.status === 'cancelled') return null - return eventToDocument( event, calendarId, From 906889ec14283552f4164f8edb8b1378b77f4926 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 9 Sep 2026 22:55:01 -0700 Subject: [PATCH 2/2] fix(search): keep untitled Calendar meetings that name a room or attendees Only an event with no title, description, location, organizer and no attendees is the bare time block a free/busy reader receives. Co-Authored-By: Claude Fable 5.1 --- .../google-calendar/google-calendar.test.ts | 25 +++++++++++++++++++ .../google-calendar/google-calendar.ts | 14 ++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/sim/connectors/google-calendar/google-calendar.test.ts b/apps/sim/connectors/google-calendar/google-calendar.test.ts index cf591275eaa..748f3007e96 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.test.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.test.ts @@ -189,6 +189,31 @@ describe('Google Calendar Search isolation', () => { expect(restricted.documents[0].metadata?.organizer).toBe('') }) + it('keeps an untitled meeting that still names a room or its participants', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + items: [ + { ...EVENT, id: 'room', summary: undefined, description: undefined }, + { + ...EVENT, + id: 'bare-location', + summary: undefined, + description: undefined, + organizer: undefined, + attendees: undefined, + }, + ], + }) + ) + const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice) + expect(result.documents.map((doc) => doc.externalId)).toEqual([ + expect.stringContaining('room'), + expect.stringContaining('bare-location'), + ]) + expect(result.documents[0].content).toContain(ATTENDEE_NAME) + expect(result.documents[1].content).toContain(EVENT.location) + }) + it('withdraws a free/busy time block that carries no title or description', async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ diff --git a/apps/sim/connectors/google-calendar/google-calendar.ts b/apps/sim/connectors/google-calendar/google-calendar.ts index 15557bf4970..210e2b5b700 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -147,13 +147,21 @@ function memberResponseStatus(event: CalendarEvent): string | undefined { /** * Whether the event carries something to search. Status entries (working * location, out of office, focus time, birthdays) describe availability rather - * than a meeting, and a reader with free/busy access alone sees a time block - * with no title or description. + * than a meeting. A reader with free/busy access alone receives a bare time + * block: Google strips the title, description, location, organizer and + * attendees, so an event with none of those is that placeholder. An untitled + * meeting that still names a room or its participants stays indexed. */ function isSearchableEvent(event: CalendarEvent): boolean { if (event.status === 'cancelled') return false if (event.eventType && event.eventType !== INDEXED_EVENT_TYPE) return false - return Boolean(event.summary?.trim() || event.description?.trim()) + return Boolean( + event.summary?.trim() || + event.description?.trim() || + event.location?.trim() || + event.organizer || + (event.attendees && event.attendees.length > 0) + ) } /**