diff --git a/apps/bubble-studio/src/components/BubbleNode.tsx b/apps/bubble-studio/src/components/BubbleNode.tsx index db2570d6..07ecd15b 100644 --- a/apps/bubble-studio/src/components/BubbleNode.tsx +++ b/apps/bubble-studio/src/components/BubbleNode.tsx @@ -2,11 +2,16 @@ import { memo, useMemo, useState } from 'react'; import { Handle, Position } from '@xyflow/react'; import { CogIcon } from '@heroicons/react/24/outline'; import { BookOpen, Code, Info, Sparkles } from 'lucide-react'; -import { CredentialType } from '@bubblelab/shared-schemas'; +import { + CredentialType, + SYSTEM_CREDENTIALS, + getAlternativeCredentialsGroup, + getCredentialTypeLabel, + validateCredentialSelection, +} from '@bubblelab/shared-schemas'; import { CreateCredentialModal } from '../pages/CredentialsPage'; import { useCreateCredential } from '../hooks/useCredentials'; import { findLogoForBubble, findDocsUrlForBubble } from '../lib/integrations'; -import { SYSTEM_CREDENTIALS } from '@bubblelab/shared-schemas'; import type { ParsedBubbleWithInfo } from '@bubblelab/shared-schemas'; import BubbleExecutionBadge from './BubbleExecutionBadge'; import { BUBBLE_COLORS, BADGE_COLORS } from './BubbleColors'; @@ -169,12 +174,12 @@ function BubbleNode({ data }: BubbleNodeProps) { return Object.keys(credValue); }, [propRequiredCredentialTypes, bubble.parameters]); - // Check if credentials are missing - const hasMissingRequirements = requiredCredentialTypes.some((credType) => { - if (SYSTEM_CREDENTIALS.has(credType as CredentialType)) return false; - const selectedId = selectedBubbleCredentials[credType]; - return selectedId === undefined || selectedId === null; - }); + // Check if credentials are missing using centralized validation + const hasMissingRequirements = !validateCredentialSelection( + requiredCredentialTypes.map((t) => t as CredentialType), + selectedBubbleCredentials, + SYSTEM_CREDENTIALS + ).isValid; const handleCredentialChange = (credType: string, credId: number | null) => { setCredential(credentialsKey, credType, credId); @@ -574,72 +579,159 @@ function BubbleNode({ data }: BubbleNodeProps) { return null; } + // Check if all credential types form a group (alternatives like OAuth vs API Key) + const alternativeGroup = getAlternativeCredentialsGroup( + filteredCredentialTypes.map((t) => t as CredentialType) + ); + return (
- {filteredCredentialTypes.map((credType) => { - const availableForType = getCredentialsForType(credType); - const systemCred = isSystemCredential( - credType as CredentialType - ); - const isMissingSelection = - !systemCred && - (selectedBubbleCredentials[credType] === undefined || - selectedBubbleCredentials[credType] === null); - - return ( -
- - { + // Find which credential type has a selection + for (const credType of filteredCredentialTypes) { + const val = selectedBubbleCredentials[credType]; + if (val !== undefined && val !== null) { + return `${credType}:${val}`; + } } - onChange={(e) => { - const val = e.target.value; - if (val === '__ADD_NEW__') { - setCreateModalForType(credType); - return; + return ''; + })()} + onChange={(e) => { + const val = e.target.value; + if (val === '__ADD_NEW__') { + // Open modal for the first credential type in the group + setCreateModalForType(filteredCredentialTypes[0]); + return; + } + if (val) { + const [credType, credIdStr] = val.split(':'); + const credId = parseInt(credIdStr, 10); + // Clear other credential types in the group + for (const ct of filteredCredentialTypes) { + if (ct !== credType) { + handleCredentialChange(ct, null); + } } - const credId = val ? parseInt(val, 10) : null; handleCredentialChange(credType, credId); - }} - className={`w-full px-2 py-1 text-xs bg-neutral-700 border ${isMissingSelection ? 'border-amber-500' : 'border-neutral-500'} rounded text-neutral-100`} - > - - {availableForType.map((cred) => ( - + {filteredCredentialTypes.map((credType) => { + const availableForType = + getCredentialsForType(credType); + if (availableForType.length === 0) return null; + return ( + + {availableForType.map((cred) => ( + + ))} + + ); + })} + + + +
+ ) : ( + // Render individual credential dropdowns (original behavior) + filteredCredentialTypes.map((credType) => { + const availableForType = getCredentialsForType(credType); + const systemCred = isSystemCredential( + credType as CredentialType + ); + const isMissingSelection = + !systemCred && + (selectedBubbleCredentials[credType] === undefined || + selectedBubbleCredentials[credType] === null); + + return ( +
+ + -
- ); - })} + {availableForType.map((cred) => ( + + ))} + + + +
+ ); + }) + )}
); @@ -718,12 +810,31 @@ function BubbleNode({ data }: BubbleNodeProps) { onClose={() => setCreateModalForType(null)} onSubmit={(data) => createCredentialMutation.mutateAsync(data)} isLoading={createCredentialMutation.isPending} - lockedCredentialType={createModalForType as CredentialType} - lockType + lockedCredentialType={ + // Only lock the type if it's NOT part of a group + getAlternativeCredentialsGroup( + requiredCredentialTypes.map((t) => t as CredentialType) + ) + ? undefined + : (createModalForType as CredentialType) + } + lockType={ + // Only lock the type selector if it's NOT part of a group + !getAlternativeCredentialsGroup( + requiredCredentialTypes.map((t) => t as CredentialType) + ) + } + allowedCredentialTypes={ + // For grouped credentials, allow all types in the group + getAlternativeCredentialsGroup( + requiredCredentialTypes.map((t) => t as CredentialType) + ) + ? (requiredCredentialTypes as CredentialType[]) + : undefined + } onSuccess={(created) => { - if (createModalForType) { - handleCredentialChange(createModalForType, created.id); - } + // Set the credential for the type that was actually created + handleCredentialChange(created.credentialType, created.id); setCreateModalForType(null); }} /> diff --git a/apps/bubble-studio/src/hooks/useRunExecution.ts b/apps/bubble-studio/src/hooks/useRunExecution.ts index 7b12a608..1ed5cdd6 100644 --- a/apps/bubble-studio/src/hooks/useRunExecution.ts +++ b/apps/bubble-studio/src/hooks/useRunExecution.ts @@ -7,7 +7,10 @@ import { useUpdateBubbleFlow } from '@/hooks/useUpdateBubbleFlow'; import { useBubbleFlow } from '@/hooks/useBubbleFlow'; import { useEditor } from '@/hooks/useEditor'; import { cleanupFlattenedKeys } from '@/utils/codeParser'; -import { SYSTEM_CREDENTIALS } from '@bubblelab/shared-schemas'; +import { + SYSTEM_CREDENTIALS, + validateCredentialSelection, +} from '@bubblelab/shared-schemas'; import type { CredentialType, StreamingLogEvent, @@ -502,15 +505,18 @@ export function useRunExecution( >; for (const [bubbleKey, credTypes] of requiredEntries) { - for (const credType of credTypes) { - if (SYSTEM_CREDENTIALS.has(credType as CredentialType)) continue; - - const selectedForBubble = - getExecutionStore(flowId).pendingCredentials[bubbleKey] || {}; - const selectedId = selectedForBubble[credType]; - - if (selectedId === undefined || selectedId === null) { - reasons.push(`Missing credential for ${bubbleKey}: ${credType}`); + const selectedForBubble = + getExecutionStore(flowId).pendingCredentials[bubbleKey] || {}; + + const validation = validateCredentialSelection( + credTypes.map((t) => t as CredentialType), + selectedForBubble, + SYSTEM_CREDENTIALS + ); + + if (!validation.isValid) { + for (const missing of validation.missing) { + reasons.push(`Missing credential for ${bubbleKey}: ${missing}`); } } } diff --git a/apps/bubble-studio/src/pages/CredentialsPage.tsx b/apps/bubble-studio/src/pages/CredentialsPage.tsx index 2484b7ac..6ce60ea0 100644 --- a/apps/bubble-studio/src/pages/CredentialsPage.tsx +++ b/apps/bubble-studio/src/pages/CredentialsPage.tsx @@ -18,6 +18,9 @@ import { isOAuthCredential, getScopeDescriptions, ScopeDescription, + CREDENTIAL_GROUPS, + getCredentialGroupName, + getCredentialTypeLabel, } from '@bubblelab/shared-schemas'; import { useCredentials, @@ -189,6 +192,13 @@ const CREDENTIAL_TYPE_CONFIG: Record = { namePlaceholder: 'My Follow Up Boss Connection', credentialConfigurations: {}, }, + [CredentialType.FUB_API_KEY_CRED]: { + label: 'Follow Up Boss (API Key)', + description: 'API key for Follow Up Boss CRM (alternative to OAuth)', + placeholder: 'Enter your FUB API key', + namePlaceholder: 'My Follow Up Boss API Key', + credentialConfigurations: {}, + }, [CredentialType.GITHUB_TOKEN]: { label: 'GitHub', description: @@ -239,6 +249,7 @@ const getServiceNameForCredentialType = ( [CredentialType.GOOGLE_SHEETS_CRED]: 'Google Sheets', [CredentialType.GOOGLE_CALENDAR_CRED]: 'Google Calendar', [CredentialType.FUB_CRED]: 'Follow Up Boss', + [CredentialType.FUB_API_KEY_CRED]: 'Follow Up Boss', [CredentialType.GITHUB_TOKEN]: 'GitHub', }; @@ -259,6 +270,7 @@ interface CreateCredentialModalProps { lockedCredentialType?: CredentialType; lockType?: boolean; onSuccess?: (credential: CredentialResponse) => void; + allowedCredentialTypes?: CredentialType[]; // Filter to only show these types in dropdown } export function CreateCredentialModal({ @@ -269,6 +281,7 @@ export function CreateCredentialModal({ lockedCredentialType, lockType, onSuccess, + allowedCredentialTypes, }: CreateCredentialModalProps) { const queryClient = useQueryClient(); const [formData, setFormData] = useState({ @@ -318,15 +331,32 @@ export function CreateCredentialModal({ } }, [isOpen]); - // If a locked type is provided, set it when opening the modal + // If a locked type or allowed types are provided, set the credential type when opening the modal useEffect(() => { - if (isOpen && lockedCredentialType) { - setFormData((prev) => ({ - ...prev, - credentialType: lockedCredentialType, - })); + if (isOpen) { + if (lockedCredentialType) { + setFormData((prev) => ({ + ...prev, + credentialType: lockedCredentialType, + })); + } else if (allowedCredentialTypes && allowedCredentialTypes.length > 0) { + // Set to first allowed type if current type is not in allowed list + setFormData((prev) => { + if ( + !allowedCredentialTypes.includes( + prev.credentialType as CredentialType + ) + ) { + return { + ...prev, + credentialType: allowedCredentialTypes[0], + }; + } + return prev; + }); + } } - }, [isOpen, lockedCredentialType]); + }, [isOpen, lockedCredentialType, allowedCredentialTypes]); const handleOAuthConnect = async () => { setIsOAuthConnecting(true); @@ -516,30 +546,45 @@ export function CreateCredentialModal({ value={formData.credentialType} onChange={(e) => { const newCredentialType = e.target.value as CredentialType; - setFormData((prev) => ({ - ...prev, - credentialType: newCredentialType, - name: - prev.name || - CREDENTIAL_TYPE_CONFIG[newCredentialType] - .namePlaceholder, - credentialConfigurations: + setFormData((prev) => { + const oldPlaceholder = + CREDENTIAL_TYPE_CONFIG[ + prev.credentialType as CredentialType + ]?.namePlaceholder; + const newPlaceholder = CREDENTIAL_TYPE_CONFIG[newCredentialType] - .credentialConfigurations, - })); + .namePlaceholder; + + // Update name if it's empty or matches the old placeholder + const shouldUpdateName = + !prev.name || prev.name === oldPlaceholder; + + return { + ...prev, + credentialType: newCredentialType, + name: shouldUpdateName ? newPlaceholder : prev.name, + credentialConfigurations: + CREDENTIAL_TYPE_CONFIG[newCredentialType] + .credentialConfigurations, + }; + }); setError(null); }} className="w-full bg-[#1a1a1a] text-gray-100 pl-3 py-2 pr-16 rounded-lg border border-[#30363d] focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition-all duration-200 appearance-none" disabled={!!lockType || !!lockedCredentialType} required > - {Object.entries(CREDENTIAL_TYPE_CONFIG).map( - ([type, config]) => ( + {Object.entries(CREDENTIAL_TYPE_CONFIG) + .filter( + ([type]) => + !allowedCredentialTypes || + allowedCredentialTypes.includes(type as CredentialType) + ) + .map(([type, config]) => ( - ) - )} + ))}
@@ -1126,20 +1171,158 @@ export function CredentialsPage({ apiBaseUrl }: CredentialsPageProps) {
) : (
- {credentials.map((credential) => ( - { + // Group credentials by their credential group + const groupedCredentials: Record = + {}; + const ungroupedCredentials: CredentialResponse[] = []; + + for (const credential of credentials) { + const groupName = getCredentialGroupName( + credential.credentialType as CredentialType + ); + if (groupName) { + if (!groupedCredentials[groupName]) { + groupedCredentials[groupName] = []; + } + groupedCredentials[groupName].push(credential); + } else { + ungroupedCredentials.push(credential); } - /> - ))} + } + + return ( + <> + {/* Render grouped credentials */} + {Object.entries(groupedCredentials).map( + ([groupName, groupCreds]) => { + const groupConfig = CREDENTIAL_GROUPS[groupName]; + if (!groupConfig) return null; + + return ( +
+
+
+ {(() => { + const serviceName = + getServiceNameForCredentialType( + groupCreds[0] + .credentialType as CredentialType + ); + const logo = resolveLogoByName(serviceName); + return logo ? ( + {`${logo.name} + ) : ( +
+ +
+ ); + })()} +
+
+

+ {groupConfig.label} +

+

+ {groupCreds.length} credential + {groupCreds.length !== 1 ? 's' : ''} +

+
+
+
+ {groupCreds.map((credential) => ( +
+
+ + {getCredentialTypeLabel( + credential.credentialType as CredentialType + )} + + + {credential.name} + +
+
+ {isOAuthCredential( + credential.credentialType as CredentialType + ) && ( + + )} + + +
+
+ ))} +
+
+ ); + } + )} + {/* Render ungrouped credentials */} + {ungroupedCredentials.map((credential) => ( + + ))} + + ); + })()}
)} diff --git a/apps/bubblelab-api/.env.example b/apps/bubblelab-api/.env.example index f86d10e1..4457f75f 100644 --- a/apps/bubblelab-api/.env.example +++ b/apps/bubblelab-api/.env.example @@ -15,4 +15,8 @@ CLOUDFLARE_R2_ACCOUNT_ID= FIRE_CRAWL_API_KEY= BUBBLE_CONNECTING_STRING_URL= GOOGLE_OAUTH_CLIENT_ID= -GOOGLE_OAUTH_CLIENT_SECRET= \ No newline at end of file +GOOGLE_OAUTH_CLIENT_SECRET= +FUB_OAUTH_CLIENT_ID= +FUB_OAUTH_CLIENT_SECRET= +FUB_SYSTEM_NAME= +FUB_SYSTEM_KEY= \ No newline at end of file diff --git a/packages/bubble-core/CREATE_BUBBLE_README.md b/packages/bubble-core/CREATE_BUBBLE_README.md index 02639ec6..65531dfe 100644 --- a/packages/bubble-core/CREATE_BUBBLE_README.md +++ b/packages/bubble-core/CREATE_BUBBLE_README.md @@ -601,6 +601,44 @@ export const BUBBLE_CREDENTIAL_OPTIONS: Record = { }; ``` +### 5a. **Alternative Auth Methods (Credential Groups)** (Optional) +📍 **File:** `packages/shared-schemas/src/credential-schema.ts` + +If your service supports multiple authentication methods (e.g., OAuth AND API Key), you need to configure them as a **credential group**. This tells the UI to display them as alternatives where the user only needs to provide ONE of them, not all. + +```typescript +// Add to CREDENTIAL_GROUPS if your service has alternative auth methods +export const CREDENTIAL_GROUPS: Record = { + // ... existing groups + 'your-service': { + label: 'Your Service', // Display name for the group + types: [CredentialType.YOUR_SERVICE_OAUTH, CredentialType.YOUR_SERVICE_API_KEY], + typeLabels: { + [CredentialType.YOUR_SERVICE_OAUTH]: 'OAuth', + [CredentialType.YOUR_SERVICE_API_KEY]: 'API Key', + }, + }, +}; +``` + +**Why this matters:** +- Without this, the UI will show both credential types as **required** (user must provide both) +- With this configuration, they are shown as **alternatives** (user picks one) +- The validation will pass when ANY one of the group is selected +- The credential dropdown will show them grouped with labels like "OAuth" and "API Key" + +**Example use case:** Follow Up Boss supports both OAuth and API Key authentication: +```typescript +followupboss: { + label: 'Follow Up Boss', + types: [CredentialType.FUB_CRED, CredentialType.FUB_API_KEY_CRED], + typeLabels: { + [CredentialType.FUB_CRED]: 'OAuth', + [CredentialType.FUB_API_KEY_CRED]: 'API Key', + }, +}, +``` + ### 6. **Bubble Name Type Definition** 📍 **File:** `packages/shared-schemas/src/types.ts` diff --git a/packages/bubble-core/src/bubbles/service-bubble/followupboss.test.ts b/packages/bubble-core/src/bubbles/service-bubble/followupboss.test.ts index 4b7ee8a2..adb03b5f 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/followupboss.test.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/followupboss.test.ts @@ -12,6 +12,7 @@ describe('FollowUpBoss Bubble Details', () => { expect(result.data?.name).toBe('followupboss'); expect(result.data?.alias).toBe('fub'); expect(result.data?.usageExample).toBeDefined(); + console.log(result.data?.usageExample); // Should include enum values expect(result.data?.usageExample).toContain('peopleCreated'); expect(result.data?.usageExample).toContain('peopleUpdated'); diff --git a/packages/bubble-core/src/bubbles/service-bubble/followupboss.ts b/packages/bubble-core/src/bubbles/service-bubble/followupboss.ts index 176be362..64481405 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/followupboss.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/followupboss.ts @@ -535,6 +535,40 @@ const FUBParamsSchema = z.discriminatedUnion('operation', [ }), // Event operations (preferred for new leads) + z.object({ + operation: z.literal('list_events').describe('List/search events'), + limit: z + .number() + .min(1) + .max(100) + .optional() + .default(25) + .describe('Number of results to return'), + offset: z + .number() + .optional() + .default(0) + .describe('Number of results to skip'), + personId: z.number().optional().describe('Filter by person ID'), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + + z.object({ + operation: z.literal('get_event').describe('Get a specific event by ID'), + event_id: z.number().describe('Event ID to retrieve'), + credentials: z + .record(z.nativeEnum(CredentialType), z.string()) + .optional() + .describe( + 'Object mapping credential types to values (injected at runtime)' + ), + }), + z.object({ operation: z .literal('create_event') @@ -873,6 +907,27 @@ const FUBResultSchema = z.discriminatedUnion('operation', [ }), // Event results + z.object({ + operation: z.literal('list_events'), + success: z.boolean(), + events: z.array(FUBEventSchema).optional(), + _metadata: z + .object({ + total: z.number().optional(), + limit: z.number().optional(), + offset: z.number().optional(), + }) + .optional(), + error: z.string(), + }), + + z.object({ + operation: z.literal('get_event'), + success: z.boolean(), + event: FUBEventSchema.optional(), + error: z.string(), + }), + z.object({ operation: z.literal('create_event'), success: z.boolean(), @@ -1007,11 +1062,10 @@ export class FollowUpBossBubble< } public async testCredential(): Promise { - const credential = this.chooseCredential(); try { const response = await fetch('https://api.followupboss.com/v1/me', { headers: { - Authorization: `Bearer ${credential}`, + Authorization: this.getAuthHeader(), 'Content-Type': 'application/json', 'X-System': process.env.FUB_SYSTEM_NAME || 'Bubble-Lab', 'X-System-Key': process.env.FUB_SYSTEM_KEY || '', @@ -1031,7 +1085,7 @@ export class FollowUpBossBubble< const url = `https://api.followupboss.com/v1${endpoint}`; const requestHeaders: Record = { - Authorization: `Bearer ${this.chooseCredential()}`, + Authorization: this.getAuthHeader(), 'Content-Type': 'application/json', 'X-System': process.env.FUB_SYSTEM_NAME || 'Bubble-Lab', 'X-System-Key': process.env.FUB_SYSTEM_KEY || '', @@ -1155,6 +1209,14 @@ export class FollowUpBossBubble< ); // Event operations + case 'list_events': + return await this.listEvents( + this.params as Extract + ); + case 'get_event': + return await this.getEvent( + this.params as Extract + ); case 'create_event': return await this.createEvent( this.params as Extract @@ -1563,6 +1625,47 @@ export class FollowUpBossBubble< } // Event operations + private async listEvents( + params: Extract + ): Promise> { + const queryParams = new URLSearchParams(); + if (params.limit) queryParams.set('limit', params.limit.toString()); + if (params.offset) queryParams.set('offset', params.offset.toString()); + if (params.personId) + queryParams.set('personId', params.personId.toString()); + + const response = (await this.makeFUBApiRequest( + `/events?${queryParams.toString()}` + )) as { events?: unknown[]; _metadata?: unknown }; + + return { + operation: 'list_events', + success: true, + events: response.events as z.infer[], + _metadata: response._metadata as { + total?: number; + limit?: number; + offset?: number; + }, + error: '', + }; + } + + private async getEvent( + params: Extract + ): Promise> { + const response = (await this.makeFUBApiRequest( + `/events/${params.event_id}` + )) as z.infer; + + return { + operation: 'get_event', + success: true, + event: response, + error: '', + }; + } + private async createEvent( params: Extract ): Promise> { @@ -1764,10 +1867,43 @@ export class FollowUpBossBubble< credentials?: Record; }; + if (!credentials || typeof credentials !== 'object') { + return undefined; + } + + if (credentials[CredentialType.FUB_API_KEY_CRED]) { + return credentials[CredentialType.FUB_API_KEY_CRED]; + } + + if (credentials[CredentialType.FUB_CRED]) { + return credentials[CredentialType.FUB_CRED]; + } + + return undefined; + } + + private getAuthHeader(): string { + const { credentials } = this.params as { + credentials?: Record; + }; + if (!credentials || typeof credentials !== 'object') { throw new Error('No Follow Up Boss credentials provided'); } - return credentials[CredentialType.FUB_CRED]; + // OAuth takes priority - uses Bearer token + if (credentials[CredentialType.FUB_CRED]) { + return `Bearer ${credentials[CredentialType.FUB_CRED]}`; + } + + // Fall back to API key - uses Basic auth (key as username, empty password) + if (credentials[CredentialType.FUB_API_KEY_CRED]) { + const apiKey = credentials[CredentialType.FUB_API_KEY_CRED]; + return `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}`; + } + + throw new Error( + 'No Follow Up Boss credentials provided (OAuth or API key)' + ); } } diff --git a/packages/bubble-core/src/bubbles/service-bubble/gemini-2.5-flash-reliability.integration.test.ts b/packages/bubble-core/src/bubbles/service-bubble/gemini-2.5-flash-reliability.integration.test.ts index 7b431fab..e13f65c8 100644 --- a/packages/bubble-core/src/bubbles/service-bubble/gemini-2.5-flash-reliability.integration.test.ts +++ b/packages/bubble-core/src/bubbles/service-bubble/gemini-2.5-flash-reliability.integration.test.ts @@ -8,7 +8,25 @@ import { CredentialType } from '@bubblelab/shared-schemas'; * Runs 20 times to ensure consistent success */ describe('Gemini 2.5 Flash Reliability Test', () => { - const testMessage = `Here is a list of my upcoming calendar events for the next 1 day(s): [{"id":"395ms8fs5jlkqujnk46vnoeils","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=Mzk1bXM4ZnM1amxrcXVqbms0NnZub2VpbHMgemFjaHpob25nQGJ1YmJsZWxhYi5haQ","created":"2025-10-24T02:31:52.000Z","updated":"2025-10-24T02:31:53.007Z","summary":"LA Clippers @ Magic","location":"Kia Center","start":{"dateTime":"2025-11-20T16:00:00-08:00","timeZone":"America/Los_Angeles"},"end":{"dateTime":"2025-11-20T19:00:00-08:00","timeZone":"America/Los_Angeles"},"attendees":[{"email":"zzyzsy0516321@gmail.com","responseStatus":"needsAction"}],"organizer":{"email":"zachzhong@bubblelab.ai"},"kind":"calendar#event","etag":"\"3522546226014142\"","creator":{"email":"zachzhong@bubblelab.ai","self":true},"iCalUID":"395ms8fs5jlkqujnk46vnoeils@google.com","sequence":0,"reminders":{"useDefault":true},"eventType":"default"}] Please create a summary email for me in HTML format. - Use a clean, professional design. - Group events by day if the range spans multiple days. - List events chronologically. - Highlight the time, summary, and location for each event. - If there are any overlapping events, flag them as potential conflicts. - Add a brief "At a Glance" summary at the top (e.g., "You have 5 events today, starting at 9:00 AM"). - Do not include the raw JSON in the output, only the HTML content.`; + const testMessage = `Here is a list of my upcoming calendar events for the next 7 day(s): [{"id":"_60q30c1g60o30e1i60o4ac1g60rj8gpl88rj2c1h84s34h9g60s30c1g60o30c1g6114acq26h1jce1l6ksk8gpg64o30c1g60o30c1g60o30c1g60o32c1g60o30c1g6p0j2dpn8l14cci46ko42e9k8d144gq18h348e1i84r4cdho692g","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=XzYwcTMwYzFnNjBvMzBlMWk2MG80YWMxZzYwcmo4Z3BsODhyajJjMWg4NHMzNGg5ZzYwczMwYzFnNjBvMzBjMWc2MTE0YWNxMjZoMWpjZTFsNmtzazhncGc2NG8zMGMxZzYwbzMwYzFnNjBvMzBjMWc2MG8zMmMxZzYwbzMwYzFnNnAwajJkcG44bDE0Y2NpNDZrbzQyZTlrOGQxNDRncTE4aDM0OGUxaTg0cjRjZGhvNjkyZyBzZWxpbmFsaUBidWJibGVsYWIuYWk","created":"2025-11-19T18:53:26.000Z","updated":"2025-11-20T17:45:00.539Z","summary":"Selina<>Ani | Catchup//Intro","description":"Hi Selina - pls use this zoom link:\n\nhttps://northwestern.zoom.us/j/95437428899\n\n\n\nTalk soon!\n\n\n\nAni\n","start":{"dateTime":"2025-11-20T14:30:00-08:00","timeZone":"America/Chicago"},"end":{"dateTime":"2025-11-20T14:40:00-08:00","timeZone":"America/Chicago"},"attendees":[{"email":"selinali@bubblelab.ai","responseStatus":"needsAction"},{"email":"aniruddh.singh@kellogg.northwestern.edu","responseStatus":"accepted","displayName":"Aniruddh Singh"}],"organizer":{"email":"aniruddh.singh@kellogg.northwestern.edu","displayName":"Aniruddh Singh"},"kind":"calendar#event","etag":"\"3527321401078622\"","creator":{"email":"selinali@bubblelab.ai","self":true},"iCalUID":"040000008200E00074C5B7101A82E008000000000BE3B4C68559DC010000000000000000100000006A177EBF2D50A94CBBCADFD82A6F682E","sequence":1,"guestsCanInviteOthers":false,"privateCopy":true,"reminders":{"useDefault":true},"eventType":"default"},{"id":"ag336flo1nnrfba6sep5le5uvc","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=YWczMzZmbG8xbm5yZmJhNnNlcDVsZTV1dmMgc2VsaW5hbGlAYnViYmxlbGFiLmFp","created":"2025-11-12T04:15:20.000Z","updated":"2025-11-14T02:33:55.143Z","summary":"Ava x Selina","description":"Follow up on Bubble Lab and Rho :)","start":{"dateTime":"2025-11-21T11:00:00-08:00","timeZone":"America/Los_Angeles"},"end":{"dateTime":"2025-11-21T11:30:00-08:00","timeZone":"America/Los_Angeles"},"attendees":[{"email":"selinali@bubblelab.ai","responseStatus":"needsAction"},{"email":"ava.gueits@rho.co","responseStatus":"accepted"}],"organizer":{"email":"ava.gueits@rho.co"},"conferenceData":{"entryPoints":[{"entryPointType":"video","uri":"https://rho-co.zoom.us/j/83815239874?pwd=QJGa04AKjAB3wiwSugualEn3t4j6pI.1&jst=2","label":"rho-co.zoom.us/j/83815239874?pwd=QJGa04AKjAB3wiwSugualEn3t4j6pI.1&jst=2","meetingCode":"83815239874","passcode":"296305"},{"regionCode":"US","entryPointType":"phone","uri":"tel:+13017158592,,83815239874#","label":"+1 301-715-8592","passcode":"296305"},{"entryPointType":"more","uri":"https://www.google.com/url?q=https://applications.zoom.us/addon/invitation/detail?meetingUuid%3D3jUImutiTVGW%252Bep5flT%252B%252Bw%253D%253D%26signature%3D20c51af573b419f9f3d80064717488b54a2c817d01f30e8fcc6a2ef136375217%26v%3D1&sa=D&source=calendar&usg=AOvVaw3GIewkNVExALqq7OpyMe1r"}],"conferenceSolution":{"key":{"type":"addOn"},"name":"Zoom Meeting","iconUri":"https://lh3.googleusercontent.com/pw/AM-JKLUkiyTEgH-6DiQP85RGtd_BORvAuFnS9katNMgwYQBJUTiDh12qtQxMJFWYH2Dj30hNsNUrr-kzKMl7jX-Qd0FR7JmVSx-Fhruf8xTPPI-wdsMYez6WJE7tz7KmqsORKBEnBTiILtMJXuMvphqKdB9X=s128-no"},"conferenceId":"83815239874","notes":"Meeting host: ava.gueits@rho.co\n\n\n\nJoin Zoom Meeting: \n\nhttps://rho-co.zoom.us/j/83815239874?pwd=QJGa04AKjAB3wiwSugualEn3t4j6pI.1&jst=2","parameters":{"addOnParameters":{"parameters":{"scriptId":"1O_9DeEljSH2vrECr8XeFYYRxFFiowFKOivqSDz316BlBcDXrF00BXrkO","realMeetingId":"83815239874","creatorUserId":"Tp36nVAITdCLdoih9VdTmA","meetingUuid":"3jUImutiTVGW+ep5flT++w==","meetingType":"2","originalEventId":"ag336flo1nnrfba6sep5le5uvc"}}}},"kind":"calendar#event","etag":"\"3526175270287294\"","creator":{"email":"ava.gueits@rho.co"},"iCalUID":"ag336flo1nnrfba6sep5le5uvc@google.com","sequence":2,"extendedProperties":{"shared":{"meetingId":"83815239874","zmMeetingNum":"83815239874","meetingParams":"{\"topic\":\"Ava x Selina\",\"type\":2,\"start_time\":\"2025-11-21T11:00:00-08:00\",\"duration\":30,\"timezone\":\"America/Los_Angeles\",\"invitees_hash\":\"le59dCFwGDjydgujs1Qc0A==\",\"all_day\":false}"}},"reminders":{"useDefault":true},"eventType":"default"},{"id":"8bqs4cocc1aar9nd60mqni9cf0","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=OGJxczRjb2NjMWFhcjluZDYwbXFuaTljZjAgc2VsaW5hbGlAYnViYmxlbGFiLmFp","created":"2025-10-29T05:34:50.000Z","updated":"2025-11-14T16:20:39.106Z","summary":"Selina Li and Robin Lim Fang Min","description":"Event Name\n\n30 min chat with Robin\n\n\n\nLocation: This is a Google Meet web conference.\n\nYou can join this meeting from your computer, tablet, or smartphone.\n\nhttps://calendly.com/events/c2cba2a2-6425-4343-af66-c700c403bf00/google_meet\n\n\n\nNeed to make changes to this event?\n\nCancel: https://calendly.com/cancellations/116c52e8-9da4-4c84-82d7-fbbd7319773c\n\nReschedule: https://calendly.com/reschedulings/116c52e8-9da4-4c84-82d7-fbbd7319773c\n\n\n\nPowered by Calendly.com\n","location":"Google Meet (instructions in description)","start":{"dateTime":"2025-11-21T11:30:00-08:00","timeZone":"Europe/Lisbon"},"end":{"dateTime":"2025-11-21T12:00:00-08:00","timeZone":"Europe/Lisbon"},"attendees":[{"email":"selinali@bubblelab.ai","responseStatus":"needsAction"},{"email":"robinlim.fm@gmail.com","responseStatus":"accepted"},{"email":"georgi@axy.digital","responseStatus":"needsAction"},{"email":"robin@axy.digital","responseStatus":"accepted"}],"organizer":{"email":"robinlim.fm@gmail.com"},"hangoutLink":"https://meet.google.com/oib-bpxf-zec","conferenceData":{"createRequest":{"requestId":"aea8f0d5-6f9d-4a22-9462-6ddbe2c200df","conferenceSolutionKey":{"type":"hangoutsMeet"},"status":{"statusCode":"success"}},"entryPoints":[{"entryPointType":"video","uri":"https://meet.google.com/oib-bpxf-zec","label":"meet.google.com/oib-bpxf-zec"}],"conferenceSolution":{"key":{"type":"hangoutsMeet"},"name":"Google Meet","iconUri":"https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png"},"conferenceId":"oib-bpxf-zec"},"kind":"calendar#event","etag":"\"3526274478213022\"","creator":{"email":"robinlim.fm@gmail.com"},"iCalUID":"8bqs4cocc1aar9nd60mqni9cf0@google.com","sequence":1,"reminders":{"useDefault":true},"eventType":"default"},{"id":"1nq9m5hbsjsh5lseqs4vvk976d","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=MW5xOW01aGJzanNoNWxzZXFzNHZ2azk3NmQgc2VsaW5hbGlAYnViYmxlbGFiLmFp","created":"2025-11-10T19:03:04.000Z","updated":"2025-11-10T19:03:04.665Z","summary":"Edward Lunch","start":{"date":"2025-11-22"},"end":{"date":"2025-11-23"},"organizer":{"email":"selinali@bubblelab.ai"},"kind":"calendar#event","etag":"\"3525602769330206\"","creator":{"email":"selinali@bubblelab.ai","self":true},"transparency":"transparent","iCalUID":"1nq9m5hbsjsh5lseqs4vvk976d@google.com","sequence":0,"reminders":{"useDefault":false},"eventType":"default"},{"id":"quvlgdrsl1j0bdtsfu731mjflc","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=cXV2bGdkcnNsMWowYmR0c2Z1NzMxbWpmbGMgc2VsaW5hbGlAYnViYmxlbGFiLmFp","created":"2025-11-19T00:36:36.000Z","updated":"2025-11-19T00:36:36.740Z","start":{"date":"2025-11-22"},"end":{"date":"2025-11-23"},"organizer":{"email":"selinali@bubblelab.ai"},"kind":"calendar#event","etag":"\"3527025193480574\"","creator":{"email":"selinali@bubblelab.ai","self":true},"transparency":"transparent","iCalUID":"quvlgdrsl1j0bdtsfu731mjflc@google.com","sequence":0,"reminders":{"useDefault":false},"eventType":"default"},{"id":"_clr78baj8pp4cobhb8pk8hb19926cq20clr6arjkecn6ot9edlgg","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=X2Nscjc4YmFqOHBwNGNvYmhiOHBrOGhiMTk5MjZjcTIwY2xyNmFyamtlY242b3Q5ZWRsZ2cgc2VsaW5hbGlAYnViYmxlbGFiLmFp","created":"2025-11-17T22:48:20.000Z","updated":"2025-11-17T22:48:21.427Z","summary":"AGI, Inc. x OpenAI x Lovable REAL Agent Challenge - Open Registration","description":"Get up-to-date information at: https://luma.com/event/evt-SFrFaqZ3DEaJDfh?pk=g-ncvy3fZEBtebVVj\n\n\n\nAddress:\n\nFrontier Tower @ Spaceship 995 Market Street, San Francisco\n\n\n\n∞ REAL Agent Challenge \n\nHosted by AGI, Inc. in collaboration with OpenAI and Lovable\n\n\n\n★ Event Highlights:\n\n💰 $15,000+ in prizes\n\n💬 Fast-track interviews with AGI, Inc.\n\n🌍 Custom billboard in SF for the top winner\n\n🔥 Fireside chats with researchers from frontier AI labs\n\n🤝 VC scout referrals to a16z speedrun and Afore Capital\n\n\n\nThe REAL Agent Challenge is the world's first 3 month sprint dedicated to building frontier autonomous web and computer-use agents. We have a live global leaderboard and huge prizes.\n\nWe will be opening the challenge with a two-day hackathon in SF and we're selecting top…\n\n\n\nHosted by AGI Inc. & 4 others","location":"Frontier Tower @ Spaceship 995 Market Street, San Francisco","start":{"dateTime":"2025-11-22T10:00:00-08:00","timeZone":"UTC"},"end":{"dateTime":"2025-11-23T20:00:00-08:00","timeZone":"UTC"},"attendees":[{"email":"selinali@bubblelab.ai","responseStatus":"accepted"}],"organizer":{"email":"calendar-invite@lu.ma","displayName":"Frontier Tower SF"},"kind":"calendar#event","etag":"\"3526839402854014\"","creator":{"email":"selinali@bubblelab.ai","self":true},"transparency":"transparent","iCalUID":"evt-SFrFaqZ3DEaJDfh@events.lu.ma","sequence":185582897,"guestsCanInviteOthers":false,"privateCopy":true,"reminders":{"useDefault":true},"attachments":[{"fileUrl":"https://mail.google.com/?view=att&th=19a940134366d7e4&attid=0.1&disp=attd&zw","title":"AGI, Inc. x OpenAI x Lovable REAL Agent Challenge - Open Registration.pkpass","iconLink":""}],"eventType":"default"},{"id":"5shclj0qej7ao7jv37apodnvgt","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=NXNoY2xqMHFlajdhbzdqdjM3YXBvZG52Z3Qgc2VsaW5hbGlAYnViYmxlbGFiLmFp","created":"2025-11-18T18:06:12.000Z","updated":"2025-11-18T18:06:12.351Z","summary":"Thomas (NEA) lunch","start":{"date":"2025-11-24"},"end":{"date":"2025-11-25"},"organizer":{"email":"selinali@bubblelab.ai"},"kind":"calendar#event","etag":"\"3526978344702142\"","creator":{"email":"selinali@bubblelab.ai","self":true},"transparency":"transparent","iCalUID":"5shclj0qej7ao7jv37apodnvgt@google.com","sequence":0,"reminders":{"useDefault":false},"eventType":"default"},{"id":"mk13hvulp7l9uqoa66acd8dn98","status":"confirmed","htmlLink":"https://www.google.com/calendar/event?eid=bWsxM2h2dWxwN2w5dXFvYTY2YWNkOGRuOTggc2VsaW5hbGlAYnViYmxlbGFiLmFp","created":"2025-11-17T04:29:57.000Z","updated":"2025-11-17T04:30:01.425Z","summary":"Selina Li <> Alex Rankin - 25 minutes","description":"\n\n Meeting type: 25 minutes\n\n N/A\n\n \n\n Location:\n\n N/A\n\n \n\n\n\n Provided infos:\n\n Website: bubblelab.ai\n\n \n\n Need to make changes to this meeting ?\n\n Cancel\n\n Reschedule\n\n ","start":{"dateTime":"2025-11-24T17:00:00-08:00","timeZone":"Asia/Singapore"},"end":{"dateTime":"2025-11-24T17:25:00-08:00","timeZone":"Asia/Singapore"},"attendees":[{"email":"selinali@bubblelab.ai","responseStatus":"accepted"},{"email":"alex@january.capital","responseStatus":"accepted"}],"organizer":{"email":"alex@january.capital"},"kind":"calendar#event","etag":"\"3526707602851550\"","creator":{"email":"alex@january.capital"},"iCalUID":"mk13hvulp7l9uqoa66acd8dn98@google.com","sequence":0,"reminders":{"useDefault":true},"eventType":"default"}] + + Please create a summary email for me in HTML format. + + - Use a clean, professional design. + + - Group events by day if the range spans multiple days. + + - List events chronologically. + + - Highlight the time, summary, and location for each event. + + - If there are any overlapping events, flag them as potential conflicts. + + - Add a brief "At a Glance" summary at the top (e.g., "You have 5 events today, starting at 9:00 AM"). + + - Do not include the raw JSON in the output, only the HTML content. + + `; const testConfig = { message: testMessage, @@ -16,8 +34,8 @@ describe('Gemini 2.5 Flash Reliability Test', () => { systemPrompt: 'You are a helpful personal executive assistant. Your goal is to create clear, concise, and formatted daily briefings.', model: { - model: 'google/gemini-2.5-flash' as const, - temperature: 0.3, + model: 'google/gemini-2.5-flash', + temperature: 1, maxTokens: 12800, maxRetries: 3, jsonMode: false, @@ -44,6 +62,7 @@ describe('Gemini 2.5 Flash Reliability Test', () => { const credentials = { [CredentialType.GOOGLE_GEMINI_CRED]: process.env.GOOGLE_API_KEY!, + [CredentialType.OPENAI_CRED]: process.env.OPENAI_API_KEY!, }; const results: Array<{ @@ -62,8 +81,9 @@ describe('Gemini 2.5 Flash Reliability Test', () => { const overallStartTime = Date.now(); + const numIterations = 20; // Create 20 parallel promises - const promises = Array.from({ length: 20 }, (_, i) => { + const promises = Array.from({ length: numIterations }, (_, i) => { const attemptNumber = i + 1; const startTime = Date.now(); @@ -148,7 +168,7 @@ describe('Gemini 2.5 Flash Reliability Test', () => { const overallExecutionTime = Date.now() - overallStartTime; console.log( - `\n⏱️ All 20 parallel requests completed in ${overallExecutionTime}ms\n` + `\n⏱️ All parallel requests completed in ${overallExecutionTime}ms\n` ); // Sort results by attempt number for consistent reporting @@ -173,7 +193,7 @@ describe('Gemini 2.5 Flash Reliability Test', () => { console.log(''); // Final assertions - expect(successful).toBe(20); + expect(successful).toBe(numIterations); expect(failed).toBe(0); // Verify no candidateContent.parts.reduce errors occurred @@ -193,39 +213,4 @@ describe('Gemini 2.5 Flash Reliability Test', () => { timeout: 300000, // 5 minutes timeout for 20 parallel iterations (should be much faster) } ); - - it('should handle the exact calendar summary scenario once', async () => { - if (shouldSkip) { - return; - } - - const credentials = { - [CredentialType.GOOGLE_GEMINI_CRED]: process.env.GOOGLE_API_KEY!, - }; - - const agent = new AIAgentBubble({ - ...testConfig, - credentials, - }); - - const result = await agent.action(); - - // Should not have the candidateContent.parts.reduce error - expect(result.error).not.toContain('candidateContent.parts.reduce'); - expect(result.success).toBe(true); - expect(result.data?.response).toBeDefined(); - expect(result.data?.response?.length).toBeGreaterThan(0); - - // Response should contain HTML-like content - const response = result.data?.response || ''; - expect( - response.includes('<') || - response.includes('html') || - response.length > 100 - ).toBe(true); - - console.log( - `✅ Single test passed: ${response.length} chars, ${result.data?.iterations || 0} iterations` - ); - }); }); diff --git a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts index cfa8dc85..85428c5e 100644 --- a/packages/bubble-shared-schemas/src/bubble-definition-schema.ts +++ b/packages/bubble-shared-schemas/src/bubble-definition-schema.ts @@ -32,6 +32,7 @@ export const CREDENTIAL_CONFIGURATION_MAP: Record< [CredentialType.OPENROUTER_CRED]: {}, [CredentialType.CLOUDFLARE_R2_ACCESS_KEY]: {}, [CredentialType.CLOUDFLARE_R2_SECRET_KEY]: {}, + [CredentialType.FUB_API_KEY_CRED]: {}, [CredentialType.CLOUDFLARE_R2_ACCOUNT_ID]: {}, [CredentialType.APIFY_CRED]: {}, [CredentialType.GOOGLE_DRIVE_CRED]: {}, diff --git a/packages/bubble-shared-schemas/src/credential-schema.ts b/packages/bubble-shared-schemas/src/credential-schema.ts index 8a17454d..f5d8832e 100644 --- a/packages/bubble-shared-schemas/src/credential-schema.ts +++ b/packages/bubble-shared-schemas/src/credential-schema.ts @@ -23,6 +23,7 @@ export const CREDENTIAL_ENV_MAP: Record = { [CredentialType.GOOGLE_SHEETS_CRED]: '', [CredentialType.GOOGLE_CALENDAR_CRED]: '', [CredentialType.FUB_CRED]: '', + [CredentialType.FUB_API_KEY_CRED]: 'FUB_API_KEY', [CredentialType.GITHUB_TOKEN]: 'GITHUB_TOKEN', }; @@ -245,6 +246,137 @@ export function getScopeDescriptions( */ export type CredentialOptions = Partial>; +/** + * Credential group configuration for services with multiple auth methods + */ +export interface CredentialGroupConfig { + label: string; // Display label for the group (e.g., "Follow Up Boss") + types: CredentialType[]; // Credential types that belong to this group + typeLabels: Partial>; // Labels for each type within the group +} + +/** + * Groups of credential types that are alternatives (user picks one) + * Used to display related auth methods together in UI + */ +export const CREDENTIAL_GROUPS: Record = { + followupboss: { + label: 'Follow Up Boss', + types: [CredentialType.FUB_CRED, CredentialType.FUB_API_KEY_CRED], + typeLabels: { + [CredentialType.FUB_CRED]: 'OAuth', + [CredentialType.FUB_API_KEY_CRED]: 'API Key', + }, + }, +}; + +/** + * Get the group name for a credential type, if it belongs to a group + */ +export function getCredentialGroupName( + credentialType: CredentialType +): string | null { + for (const [groupName, config] of Object.entries(CREDENTIAL_GROUPS)) { + if (config.types.includes(credentialType)) { + return groupName; + } + } + return null; +} + +/** + * Get the group configuration for a credential type + */ +export function getCredentialGroup( + credentialType: CredentialType +): CredentialGroupConfig | null { + const groupName = getCredentialGroupName(credentialType); + return groupName ? CREDENTIAL_GROUPS[groupName] : null; +} + +/** + * Check if credential types are alternatives (belong to same group) + * Returns the group config if they are alternatives, null otherwise + */ +export function getAlternativeCredentialsGroup( + types: CredentialType[] +): CredentialGroupConfig | null { + if (types.length < 2) return null; + + // Check if all types belong to the same group + const firstGroup = getCredentialGroupName(types[0]); + if (!firstGroup) return null; + + const allSameGroup = types.every( + (type) => getCredentialGroupName(type) === firstGroup + ); + + return allSameGroup ? CREDENTIAL_GROUPS[firstGroup] : null; +} + +/** + * Get the label for a credential type within its group + */ +export function getCredentialTypeLabel(credentialType: CredentialType): string { + const group = getCredentialGroup(credentialType); + if (group && group.typeLabels[credentialType]) { + return group.typeLabels[credentialType]; + } + return credentialType; +} + +/** + * Check if credentials are satisfied for a set of required credential types. + * For grouped credentials (alternatives), only one needs to be selected. + * For non-grouped credentials, each one must be selected individually. + * + * @param requiredTypes - Array of required credential types + * @param selectedCredentials - Record of credentialType -> credentialId (or null/undefined if not selected) + * @param systemCredentials - Set of credential types that are system-managed (optional) + * @returns Object with isValid boolean and array of missing credential labels + */ +export function validateCredentialSelection( + requiredTypes: CredentialType[], + selectedCredentials: Record, + systemCredentials?: Set +): { isValid: boolean; missing: string[] } { + const missing: string[] = []; + + // Filter out system credentials + const nonSystemTypes = systemCredentials + ? requiredTypes.filter((t) => !systemCredentials.has(t)) + : requiredTypes; + + if (nonSystemTypes.length === 0) { + return { isValid: true, missing: [] }; + } + + // Check if these credential types form a group (alternatives) + const alternativeGroup = getAlternativeCredentialsGroup(nonSystemTypes); + + if (alternativeGroup) { + // For grouped credentials, only ONE needs to be selected + const hasAnySelected = nonSystemTypes.some((credType) => { + const selectedId = selectedCredentials[credType]; + return selectedId !== undefined && selectedId !== null; + }); + + if (!hasAnySelected) { + missing.push(alternativeGroup.label); + } + } else { + // For non-grouped credentials, check each one individually + for (const credType of nonSystemTypes) { + const selectedId = selectedCredentials[credType]; + if (selectedId === undefined || selectedId === null) { + missing.push(credType); + } + } + } + + return { isValid: missing.length === 0, missing }; +} + /** * Collection of credential options for all bubbles */ @@ -336,7 +468,7 @@ export const BUBBLE_CREDENTIAL_OPTIONS: Record = { 'linkedin-tool': [CredentialType.APIFY_CRED], 'youtube-tool': [CredentialType.APIFY_CRED], github: [CredentialType.GITHUB_TOKEN], - followupboss: [CredentialType.FUB_CRED], + followupboss: [CredentialType.FUB_CRED, CredentialType.FUB_API_KEY_CRED], }; // POST /credentials - Create credential schema diff --git a/packages/bubble-shared-schemas/src/types.ts b/packages/bubble-shared-schemas/src/types.ts index 02fab225..fd9994d5 100644 --- a/packages/bubble-shared-schemas/src/types.ts +++ b/packages/bubble-shared-schemas/src/types.ts @@ -27,6 +27,7 @@ export enum CredentialType { GOOGLE_SHEETS_CRED = 'GOOGLE_SHEETS_CRED', GOOGLE_CALENDAR_CRED = 'GOOGLE_CALENDAR_CRED', FUB_CRED = 'FUB_CRED', + FUB_API_KEY_CRED = 'FUB_API_KEY_CRED', // Development Platform Credentials GITHUB_TOKEN = 'GITHUB_TOKEN',