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..748f3007e96 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,95 @@ 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({ + 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..210e2b5b700 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -129,6 +129,41 @@ 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. 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() || + event.location?.trim() || + event.organizer || + (event.attendees && event.attendees.length > 0) + ) +} + /** * Counts attendees excluding rooms/equipment, matching what the content renderer lists. */ @@ -182,6 +217,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 +310,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 +327,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 +338,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 +436,7 @@ export const googleCalendarConnector: ConnectorConfig = { const queryParams = new URLSearchParams({ singleEvents: 'true', orderBy: 'startTime', + eventTypes: INDEXED_EVENT_TYPE, maxResults: String(pageSize), timeMin, timeMax, @@ -594,8 +638,6 @@ export const googleCalendarConnector: ConnectorConfig = { const event = (await response.json()) as CalendarEvent - if (event.status === 'cancelled') return null - return eventToDocument( event, calendarId,