Skip to content

Commit b6d1da4

Browse files
waleedlatif1claude
andauthored
fix(search): index only meetings from Google Calendar and mark declined invites (#7718)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1cc4fb1 commit b6d1da4

3 files changed

Lines changed: 138 additions & 7 deletions

File tree

apps/docs/content/docs/search/google-calendar.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ In the add-source form, **More options** contains optional **Metadata tags**. Se
6868

6969
## What gets indexed
7070

71-
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.
71+
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.
7272

73-
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).
73+
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).
7474

7575
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.
7676

apps/sim/connectors/google-calendar/google-calendar.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ describe('Google Calendar Search isolation', () => {
178178
fetchMock.mockResolvedValueOnce(
179179
jsonResponse({
180180
accessRole: 'reader',
181-
items: [{ id: EVENT.id, updated: EVENT.updated, start: EVENT.start, end: EVENT.end }],
181+
items: [{ ...EVENT, description: undefined, organizer: undefined, attendees: undefined }],
182182
})
183183
)
184184
const restricted = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
@@ -189,6 +189,95 @@ describe('Google Calendar Search isolation', () => {
189189
expect(restricted.documents[0].metadata?.organizer).toBe('')
190190
})
191191

192+
it('keeps an untitled meeting that still names a room or its participants', async () => {
193+
fetchMock.mockResolvedValueOnce(
194+
jsonResponse({
195+
items: [
196+
{ ...EVENT, id: 'room', summary: undefined, description: undefined },
197+
{
198+
...EVENT,
199+
id: 'bare-location',
200+
summary: undefined,
201+
description: undefined,
202+
organizer: undefined,
203+
attendees: undefined,
204+
},
205+
],
206+
})
207+
)
208+
const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
209+
expect(result.documents.map((doc) => doc.externalId)).toEqual([
210+
expect.stringContaining('room'),
211+
expect.stringContaining('bare-location'),
212+
])
213+
expect(result.documents[0].content).toContain(ATTENDEE_NAME)
214+
expect(result.documents[1].content).toContain(EVENT.location)
215+
})
216+
217+
it('withdraws a free/busy time block that carries no title or description', async () => {
218+
fetchMock.mockResolvedValueOnce(
219+
jsonResponse({
220+
accessRole: 'freeBusyReader',
221+
items: [{ id: EVENT.id, updated: EVENT.updated, start: EVENT.start, end: EVENT.end }],
222+
})
223+
)
224+
const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
225+
expect(result).toEqual({ documents: [], hasMore: false })
226+
})
227+
228+
it('asks Google for meetings only and drops status entries it still returns', async () => {
229+
fetchMock.mockResolvedValueOnce(
230+
jsonResponse({
231+
items: [
232+
{ ...EVENT, id: 'wfh', summary: 'Home', eventType: 'workingLocation' },
233+
{ ...EVENT, id: 'ooo', summary: 'Out of office', eventType: 'outOfOffice' },
234+
{ ...EVENT, id: 'focus', summary: 'Focus time', eventType: 'focusTime' },
235+
{ ...EVENT, id: 'bday', summary: 'Birthday', eventType: 'birthday' },
236+
{ ...EVENT, id: 'meeting', eventType: 'default' },
237+
EVENT,
238+
],
239+
})
240+
)
241+
const result = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
242+
expect(result.documents.map((doc) => doc.externalId)).toEqual([
243+
expect.stringContaining('meeting'),
244+
expect.stringContaining(EVENT.id),
245+
])
246+
const listUrl = new URL(String(fetchMock.mock.calls[0][0]))
247+
expect(listUrl.searchParams.getAll('eventTypes')).toEqual(['default'])
248+
})
249+
250+
it('returns null for a status entry fetched directly', async () => {
251+
const listing = await googleCalendarConnector.listDocuments('token', {}, undefined, alice)
252+
fetchMock.mockResolvedValueOnce(jsonResponse({ ...EVENT, eventType: 'outOfOffice' }))
253+
expect(
254+
await googleCalendarConnector.getDocument('token', {}, listing.documents[0].externalId, alice)
255+
).toBeNull()
256+
})
257+
258+
it('keeps a declined invitation and marks the response on it', async () => {
259+
const declined = {
260+
...EVENT,
261+
attendees: [
262+
...EVENT.attendees,
263+
{ email: 'alice@example.com', self: true, responseStatus: 'declined' },
264+
],
265+
}
266+
fetchMock.mockResolvedValueOnce(jsonResponse({ items: [declined] }))
267+
const [doc] = (await googleCalendarConnector.listDocuments('token', {}, undefined, alice))
268+
.documents
269+
expect(doc.content).toContain('Response: declined')
270+
expect(doc.metadata?.responseStatus).toBe('declined')
271+
272+
const accepted = await listOne({})
273+
expect(accepted.content).not.toContain('Response:')
274+
expect(accepted.metadata?.responseStatus).toBeUndefined()
275+
276+
fetchMock.mockResolvedValueOnce(jsonResponse({ items: [declined] }))
277+
const workspaceDeclined = await listOne({})
278+
expect(workspaceDeclined.contentHash).toBe(`${accepted.contentHash}:declined`)
279+
})
280+
192281
it('withdraws cancelled events, including instances of recurring events', async () => {
193282
fetchMock.mockResolvedValueOnce(
194283
jsonResponse({ items: [{ ...EVENT, status: 'cancelled', recurringEventId: 'series' }] })

apps/sim/connectors/google-calendar/google-calendar.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,41 @@ function readIncludeAttendees(sourceConfig: Record<string, unknown>): boolean {
129129
*/
130130
const NO_ATTENDEES_HASH_SUFFIX = ':noattendees'
131131

132+
/**
133+
* Appended to the metadata-only content hash of an invitation the connected
134+
* account declined, so an event indexed before the response was recorded picks
135+
* up the response line without waiting for the organizer to edit it.
136+
*/
137+
const DECLINED_HASH_SUFFIX = ':declined'
138+
139+
/** Only `default` events describe meetings; the listing asks Google for these alone. */
140+
const INDEXED_EVENT_TYPE = 'default'
141+
142+
/** The connected account's own response to an invitation, when Google reports it. */
143+
function memberResponseStatus(event: CalendarEvent): string | undefined {
144+
return event.attendees?.find((attendee) => attendee.self)?.responseStatus
145+
}
146+
147+
/**
148+
* Whether the event carries something to search. Status entries (working
149+
* location, out of office, focus time, birthdays) describe availability rather
150+
* than a meeting. A reader with free/busy access alone receives a bare time
151+
* block: Google strips the title, description, location, organizer and
152+
* attendees, so an event with none of those is that placeholder. An untitled
153+
* meeting that still names a room or its participants stays indexed.
154+
*/
155+
function isSearchableEvent(event: CalendarEvent): boolean {
156+
if (event.status === 'cancelled') return false
157+
if (event.eventType && event.eventType !== INDEXED_EVENT_TYPE) return false
158+
return Boolean(
159+
event.summary?.trim() ||
160+
event.description?.trim() ||
161+
event.location?.trim() ||
162+
event.organizer ||
163+
(event.attendees && event.attendees.length > 0)
164+
)
165+
}
166+
132167
/**
133168
* Counts attendees excluding rooms/equipment, matching what the content renderer lists.
134169
*/
@@ -182,6 +217,10 @@ function eventToContent(event: CalendarEvent, includeAttendees: boolean): string
182217
parts.push(`Location: ${event.location}`)
183218
}
184219

220+
if (memberResponseStatus(event) === 'declined') {
221+
parts.push('Response: declined')
222+
}
223+
185224
if (includeAttendees) {
186225
const organizer = formatOrganizer(event.organizer)
187226
if (organizer) {
@@ -271,13 +310,14 @@ async function eventToDocument(
271310
includeAttendees: boolean,
272311
syncContext?: Record<string, unknown>
273312
): Promise<ExternalDocument | null> {
274-
if (event.status === 'cancelled') return null
313+
if (!isSearchableEvent(event)) return null
275314

276315
const content = eventToContent(event, includeAttendees)
277316
if (!content.trim()) return null
278317

279318
const startTime = event.start?.dateTime || event.start?.date || ''
280319
const attendeeCount = countAttendees(event.attendees)
320+
const responseStatus = memberResponseStatus(event)
281321

282322
const memberScoped = isPerMemberListing(syncContext)
283323
const externalId = memberDocumentId(
@@ -287,7 +327,9 @@ async function eventToDocument(
287327
const baseHash = isMultiCalendar
288328
? `gcal:${calendarId}:${event.id}:${event.updated ?? ''}`
289329
: `gcal:${event.id}:${event.updated ?? ''}`
290-
const contentHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}`
330+
const attendeeHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}`
331+
const contentHash =
332+
responseStatus === 'declined' ? `${attendeeHash}${DECLINED_HASH_SUFFIX}` : attendeeHash
291333

292334
const metadata = {
293335
calendarId,
@@ -296,6 +338,7 @@ async function eventToDocument(
296338
location: event.location || '',
297339
organizer: includeAttendees ? formatOrganizer(event.organizer) : '',
298340
attendeeCount,
341+
...(responseStatus ? { responseStatus } : {}),
299342
isAllDay: isAllDayEvent(event),
300343
eventDate: startTime,
301344
updatedTime: event.updated,
@@ -393,6 +436,7 @@ export const googleCalendarConnector: ConnectorConfig = {
393436
const queryParams = new URLSearchParams({
394437
singleEvents: 'true',
395438
orderBy: 'startTime',
439+
eventTypes: INDEXED_EVENT_TYPE,
396440
maxResults: String(pageSize),
397441
timeMin,
398442
timeMax,
@@ -594,8 +638,6 @@ export const googleCalendarConnector: ConnectorConfig = {
594638

595639
const event = (await response.json()) as CalendarEvent
596640

597-
if (event.status === 'cancelled') return null
598-
599641
return eventToDocument(
600642
event,
601643
calendarId,

0 commit comments

Comments
 (0)