Skip to content

Commit 19ffecb

Browse files
committed
feat(connectors): add scoping filters for Gong (host users), Fathom (meeting type/domain), Granola (folder/created-after)
1 parent ad7cbd4 commit 19ffecb

3 files changed

Lines changed: 144 additions & 0 deletions

File tree

apps/sim/connectors/fathom/fathom.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,30 @@ export const fathomConnector: ConnectorConfig = {
316316
required: false,
317317
description: 'Only sync meetings belonging to this team',
318318
},
319+
{
320+
id: 'meetingType',
321+
title: 'Filter by Meeting Type',
322+
type: 'dropdown',
323+
mode: 'advanced',
324+
required: false,
325+
description:
326+
'Only sync internal meetings (everyone shares the recorder’s domain) or external meetings (at least one outside attendee). Leave as All to sync both.',
327+
options: [
328+
{ id: 'all', label: 'All meetings' },
329+
{ id: 'one_or_more_external', label: 'External (customer-facing) only' },
330+
{ id: 'only_internal', label: 'Internal only' },
331+
],
332+
},
333+
{
334+
id: 'inviteeDomains',
335+
title: 'Filter by Attendee Domain',
336+
type: 'short-input',
337+
mode: 'advanced',
338+
placeholder: 'e.g. acme.com',
339+
required: false,
340+
description:
341+
'Only sync meetings that include a calendar invitee from this company email domain (exact match).',
342+
},
319343
{
320344
id: 'maxMeetings',
321345
title: 'Max Meetings',
@@ -334,18 +358,26 @@ export const fathomConnector: ConnectorConfig = {
334358
): Promise<ExternalDocumentList> => {
335359
const recordedBy = (sourceConfig.recordedBy as string | undefined)?.trim()
336360
const teams = (sourceConfig.teams as string | undefined)?.trim()
361+
const meetingType = (sourceConfig.meetingType as string | undefined)?.trim()
362+
const inviteeDomain = (sourceConfig.inviteeDomains as string | undefined)?.trim()
337363
const maxMeetings = sourceConfig.maxMeetings ? Number(sourceConfig.maxMeetings) : 0
338364

339365
const url = new URL(`${FATHOM_API_BASE}/meetings`)
340366
if (recordedBy) url.searchParams.append('recorded_by[]', recordedBy)
341367
if (teams) url.searchParams.append('teams[]', teams)
368+
if (meetingType && meetingType !== 'all') {
369+
url.searchParams.append('calendar_invitees_domains_type', meetingType)
370+
}
371+
if (inviteeDomain) url.searchParams.append('calendar_invitees_domains[]', inviteeDomain)
342372
if (cursor) url.searchParams.append('cursor', cursor)
343373
if (lastSyncAt) url.searchParams.append('created_after', lastSyncAt.toISOString())
344374

345375
logger.info('Listing Fathom meetings', {
346376
hasCursor: Boolean(cursor),
347377
recordedBy,
348378
teams,
379+
meetingType,
380+
inviteeDomain,
349381
incremental: Boolean(lastSyncAt),
350382
})
351383

apps/sim/connectors/gong/gong.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,24 @@ function buildHeaders(accessToken: string): Record<string, string> {
125125
}
126126
}
127127

128+
/**
129+
* Parses a comma- or newline-separated list of Gong IDs into a trimmed,
130+
* de-duplicated, non-empty array. Returns `undefined` when nothing usable
131+
* remains so the caller can omit the filter key entirely.
132+
*/
133+
function parseIdList(raw: unknown): string[] | undefined {
134+
if (typeof raw !== 'string') return undefined
135+
const ids = Array.from(
136+
new Set(
137+
raw
138+
.split(/[\n,]/)
139+
.map((value) => value.trim())
140+
.filter((value) => value.length > 0)
141+
)
142+
)
143+
return ids.length > 0 ? ids : undefined
144+
}
145+
128146
/**
129147
* Metadata-based content hash shared by `listDocuments` stubs and `getDocument`
130148
* results. Derived purely from call identity and its start time so the value is
@@ -327,6 +345,16 @@ export const gongConnector: ConnectorConfig = {
327345
required: false,
328346
placeholder: 'Optional — limit to a single Gong workspace',
329347
},
348+
{
349+
id: 'primaryUserIds',
350+
title: 'Host User IDs',
351+
type: 'short-input',
352+
required: false,
353+
mode: 'advanced',
354+
placeholder: 'Optional — comma-separated Gong user IDs (call hosts)',
355+
description:
356+
'Only sync calls hosted by these users. Find IDs in Gong under Company Settings → Users, or via the API.',
357+
},
330358
],
331359

332360
listDocuments: async (
@@ -339,13 +367,15 @@ export const gongConnector: ConnectorConfig = {
339367
const lookbackDays = computeLookbackDays(sourceConfig, lastSyncAt)
340368
const maxCalls = sourceConfig.maxCalls ? Number(sourceConfig.maxCalls) : 0
341369
const workspaceId = (sourceConfig.workspaceId as string | undefined)?.trim()
370+
const primaryUserIds = parseIdList(sourceConfig.primaryUserIds)
342371

343372
const now = new Date()
344373
const fromDateTime = new Date(now.getTime() - lookbackDays * MS_PER_DAY).toISOString()
345374
const toDateTime = now.toISOString()
346375

347376
const filter: Record<string, unknown> = { fromDateTime, toDateTime }
348377
if (workspaceId) filter.workspaceId = workspaceId
378+
if (primaryUserIds) filter.primaryUserIds = primaryUserIds
349379

350380
logger.info('Listing Gong calls', {
351381
fromDateTime,

apps/sim/connectors/granola/granola.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ const GRANOLA_API_BASE = 'https://public-api.granola.ai/v1'
1111
/** Granola caps page_size at 30; request the maximum to minimize round trips. */
1212
const PAGE_SIZE = 30
1313

14+
/** Granola folder identifiers match `fol_` followed by 14 alphanumeric chars. */
15+
const FOLDER_ID_PATTERN = /^fol_[a-zA-Z0-9]{14}$/
16+
1417
/**
1518
* A note owner or attendee as returned by the Granola API.
1619
*/
@@ -108,6 +111,33 @@ function parseMaxNotes(sourceConfig: Record<string, unknown>): number {
108111
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
109112
}
110113

114+
/**
115+
* Parses the optional `folderId` scope from source config. Returns a trimmed
116+
* folder id only when it matches Granola's `fol_…` identifier shape; otherwise
117+
* returns undefined so the request is not scoped to an invalid folder.
118+
*/
119+
function parseFolderId(sourceConfig: Record<string, unknown>): string | undefined {
120+
const raw = sourceConfig.folderId
121+
if (typeof raw !== 'string') return undefined
122+
const trimmed = raw.trim()
123+
if (!trimmed) return undefined
124+
return FOLDER_ID_PATTERN.test(trimmed) ? trimmed : undefined
125+
}
126+
127+
/**
128+
* Parses the optional `createdAfter` date filter from source config. Returns a
129+
* normalized ISO 8601 string when the value is a valid date; otherwise returns
130+
* undefined so the request is not scoped to an invalid date.
131+
*/
132+
function parseCreatedAfter(sourceConfig: Record<string, unknown>): string | undefined {
133+
const raw = sourceConfig.createdAfter
134+
if (typeof raw !== 'string') return undefined
135+
const trimmed = raw.trim()
136+
if (!trimmed) return undefined
137+
const parsed = new Date(trimmed)
138+
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString()
139+
}
140+
111141
/**
112142
* Detects whether a string contains HTML markup. Granola returns markdown for
113143
* `summary_markdown`, but this guard lets us defensively strip tags if the API
@@ -212,6 +242,26 @@ export const granolaConnector: ConnectorConfig = {
212242
placeholder: 'e.g. 200 (default: unlimited)',
213243
description: 'Cap the number of notes synced. Leave blank to sync all notes.',
214244
},
245+
{
246+
id: 'folderId',
247+
title: 'Folder ID',
248+
type: 'short-input',
249+
required: false,
250+
mode: 'advanced',
251+
placeholder: 'e.g. fol_4y6LduVdwSKC27',
252+
description:
253+
'Scope the sync to a single folder and its child folders. Leave blank to sync notes from all folders.',
254+
},
255+
{
256+
id: 'createdAfter',
257+
title: 'Created After',
258+
type: 'short-input',
259+
required: false,
260+
mode: 'advanced',
261+
placeholder: 'e.g. 2025-01-01 or 2025-01-01T00:00:00Z',
262+
description:
263+
'Only sync notes created on or after this date (ISO 8601). Leave blank to sync notes regardless of creation date.',
264+
},
215265
],
216266

217267
listDocuments: async (
@@ -222,15 +272,21 @@ export const granolaConnector: ConnectorConfig = {
222272
lastSyncAt?: Date
223273
): Promise<ExternalDocumentList> => {
224274
const maxNotes = parseMaxNotes(sourceConfig)
275+
const folderId = parseFolderId(sourceConfig)
276+
const createdAfter = parseCreatedAfter(sourceConfig)
225277

226278
const url = new URL(`${GRANOLA_API_BASE}/notes`)
227279
url.searchParams.set('page_size', String(PAGE_SIZE))
228280
if (cursor) url.searchParams.set('cursor', cursor)
229281
if (lastSyncAt) url.searchParams.set('updated_after', lastSyncAt.toISOString())
282+
if (folderId) url.searchParams.set('folder_id', folderId)
283+
if (createdAfter) url.searchParams.set('created_after', createdAfter)
230284

231285
logger.info('Listing Granola notes', {
232286
hasCursor: Boolean(cursor),
233287
incremental: Boolean(lastSyncAt),
288+
scopedToFolder: Boolean(folderId),
289+
scopedByCreatedAfter: Boolean(createdAfter),
234290
})
235291

236292
const response = await fetchWithRetry(url.toString(), {
@@ -350,6 +406,32 @@ export const granolaConnector: ConnectorConfig = {
350406
return { valid: false, error: 'Max notes must be a non-negative number' }
351407
}
352408

409+
const folderId = sourceConfig.folderId
410+
if (
411+
typeof folderId === 'string' &&
412+
folderId.trim() &&
413+
!FOLDER_ID_PATTERN.test(folderId.trim())
414+
) {
415+
return {
416+
valid: false,
417+
error:
418+
'Folder ID must look like fol_ followed by 14 alphanumeric characters (e.g. fol_4y6LduVdwSKC27)',
419+
}
420+
}
421+
422+
const createdAfter = sourceConfig.createdAfter
423+
if (
424+
typeof createdAfter === 'string' &&
425+
createdAfter.trim() &&
426+
Number.isNaN(new Date(createdAfter.trim()).getTime())
427+
) {
428+
return {
429+
valid: false,
430+
error:
431+
'Created After must be a valid date (ISO 8601, e.g. 2025-01-01 or 2025-01-01T00:00:00Z)',
432+
}
433+
}
434+
353435
try {
354436
const url = new URL(`${GRANOLA_API_BASE}/notes`)
355437
url.searchParams.set('page_size', '1')

0 commit comments

Comments
 (0)