Skip to content

Commit 2c1e747

Browse files
waleedlatif1claude
andcommitted
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>
1 parent 1cc4fb1 commit 2c1e747

3 files changed

Lines changed: 105 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: 65 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,70 @@ describe('Google Calendar Search isolation', () => {
189189
expect(restricted.documents[0].metadata?.organizer).toBe('')
190190
})
191191

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

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,33 @@ 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, and a reader with free/busy access alone sees a time block
151+
* with no title or description.
152+
*/
153+
function isSearchableEvent(event: CalendarEvent): boolean {
154+
if (event.status === 'cancelled') return false
155+
if (event.eventType && event.eventType !== INDEXED_EVENT_TYPE) return false
156+
return Boolean(event.summary?.trim() || event.description?.trim())
157+
}
158+
132159
/**
133160
* Counts attendees excluding rooms/equipment, matching what the content renderer lists.
134161
*/
@@ -182,6 +209,10 @@ function eventToContent(event: CalendarEvent, includeAttendees: boolean): string
182209
parts.push(`Location: ${event.location}`)
183210
}
184211

212+
if (memberResponseStatus(event) === 'declined') {
213+
parts.push('Response: declined')
214+
}
215+
185216
if (includeAttendees) {
186217
const organizer = formatOrganizer(event.organizer)
187218
if (organizer) {
@@ -271,13 +302,14 @@ async function eventToDocument(
271302
includeAttendees: boolean,
272303
syncContext?: Record<string, unknown>
273304
): Promise<ExternalDocument | null> {
274-
if (event.status === 'cancelled') return null
305+
if (!isSearchableEvent(event)) return null
275306

276307
const content = eventToContent(event, includeAttendees)
277308
if (!content.trim()) return null
278309

279310
const startTime = event.start?.dateTime || event.start?.date || ''
280311
const attendeeCount = countAttendees(event.attendees)
312+
const responseStatus = memberResponseStatus(event)
281313

282314
const memberScoped = isPerMemberListing(syncContext)
283315
const externalId = memberDocumentId(
@@ -287,7 +319,9 @@ async function eventToDocument(
287319
const baseHash = isMultiCalendar
288320
? `gcal:${calendarId}:${event.id}:${event.updated ?? ''}`
289321
: `gcal:${event.id}:${event.updated ?? ''}`
290-
const contentHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}`
322+
const attendeeHash = includeAttendees ? baseHash : `${baseHash}${NO_ATTENDEES_HASH_SUFFIX}`
323+
const contentHash =
324+
responseStatus === 'declined' ? `${attendeeHash}${DECLINED_HASH_SUFFIX}` : attendeeHash
291325

292326
const metadata = {
293327
calendarId,
@@ -296,6 +330,7 @@ async function eventToDocument(
296330
location: event.location || '',
297331
organizer: includeAttendees ? formatOrganizer(event.organizer) : '',
298332
attendeeCount,
333+
...(responseStatus ? { responseStatus } : {}),
299334
isAllDay: isAllDayEvent(event),
300335
eventDate: startTime,
301336
updatedTime: event.updated,
@@ -393,6 +428,7 @@ export const googleCalendarConnector: ConnectorConfig = {
393428
const queryParams = new URLSearchParams({
394429
singleEvents: 'true',
395430
orderBy: 'startTime',
431+
eventTypes: INDEXED_EVENT_TYPE,
396432
maxResults: String(pageSize),
397433
timeMin,
398434
timeMax,
@@ -594,8 +630,6 @@ export const googleCalendarConnector: ConnectorConfig = {
594630

595631
const event = (await response.json()) as CalendarEvent
596632

597-
if (event.status === 'cancelled') return null
598-
599633
return eventToDocument(
600634
event,
601635
calendarId,

0 commit comments

Comments
 (0)