From 020037728d7c5b67d7daec03e40df9ae7befddc6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 7 Jan 2026 17:49:40 -0800 Subject: [PATCH 01/12] feat(polling-groups): can invite multiple people to have their gmail/outlook inboxes connected to a workflow (#2695) * progress on cred sets * fix credential set system * return data to render credential set in block preview * progress * invite flow * simplify code * fix ui * fix tests * fix types * fix * fix icon for outlook * fix cred set name not showing up for owner * fix rendering of credential set name * fix outlook well known folder id resolution * fix perms for creating cred set * add to docs and simplify ui * consolidate webhook code better * fix tests * fix credential collab logic issue * fix ui * fix lint --- apps/docs/content/docs/en/triggers/index.mdx | 25 + apps/sim/app/(auth)/signup/signup-form.tsx | 8 +- .../api/auth/oauth/disconnect/route.test.ts | 22 + .../app/api/auth/oauth/disconnect/route.ts | 46 +- .../app/api/auth/oauth/token/route.test.ts | 5 +- apps/sim/app/api/auth/oauth/token/route.ts | 54 +- apps/sim/app/api/auth/oauth/utils.ts | 111 +- .../[id]/invite/[invitationId]/route.ts | 146 + .../api/credential-sets/[id]/invite/route.ts | 215 + .../api/credential-sets/[id]/members/route.ts | 166 + .../sim/app/api/credential-sets/[id]/route.ts | 155 + .../api/credential-sets/invitations/route.ts | 53 + .../credential-sets/invite/[token]/route.ts | 196 + .../api/credential-sets/memberships/route.ts | 115 + apps/sim/app/api/credential-sets/route.ts | 157 + .../[id]/invitations/[invitationId]/route.ts | 99 + apps/sim/app/api/webhooks/[id]/route.ts | 65 +- apps/sim/app/api/webhooks/route.ts | 159 + .../api/webhooks/trigger/[path]/route.test.ts | 110 + .../app/api/webhooks/trigger/[path]/route.ts | 223 +- .../sim/app/api/workflows/[id]/state/route.ts | 61 +- .../app/credential-account/[token]/page.tsx | 269 + .../credential-selector.tsx | 150 +- .../components/tool-credential-selector.tsx | 5 +- .../credential-sets/credential-sets.tsx | 1170 +++ .../settings-modal/components/index.ts | 1 + .../member-invitation-card.tsx | 15 +- .../components/team-members/team-members.tsx | 148 +- .../settings-modal/settings-modal.tsx | 12 +- .../components/invite-modal/invite-modal.tsx | 279 +- apps/sim/background/webhook-execution.ts | 7 +- apps/sim/blocks/types.ts | 2 + .../components/emails/invitations/index.ts | 1 + .../polling-group-invitation-email.tsx | 52 + apps/sim/components/emails/render.ts | 19 + apps/sim/components/emails/subjects.ts | 3 + apps/sim/executor/constants.ts | 16 + apps/sim/executor/execution/types.ts | 1 + apps/sim/executor/types.ts | 1 + apps/sim/hooks/queries/credential-sets.ts | 370 + apps/sim/hooks/queries/oauth-credentials.ts | 29 +- apps/sim/hooks/queries/organization.ts | 26 + apps/sim/hooks/use-webhook-management.ts | 38 +- apps/sim/lib/auth/auth.ts | 84 + apps/sim/lib/credential-sets/providers.ts | 27 + .../sim/lib/webhooks/gmail-polling-service.ts | 9 +- .../lib/webhooks/outlook-polling-service.ts | 105 +- apps/sim/lib/webhooks/processor.ts | 158 +- apps/sim/lib/webhooks/utils.server.ts | 353 +- apps/sim/lib/workflows/persistence/utils.ts | 1 + apps/sim/tools/index.ts | 3 +- apps/sim/tools/types.ts | 4 +- apps/sim/triggers/gmail/poller.ts | 23 + apps/sim/triggers/outlook/poller.ts | 17 + bun.lock | 1 - .../db/migrations/0135_stormy_puff_adder.sql | 61 + .../db/migrations/meta/0135_snapshot.json | 9333 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 100 + 59 files changed, 14773 insertions(+), 318 deletions(-) create mode 100644 apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts create mode 100644 apps/sim/app/api/credential-sets/[id]/invite/route.ts create mode 100644 apps/sim/app/api/credential-sets/[id]/members/route.ts create mode 100644 apps/sim/app/api/credential-sets/[id]/route.ts create mode 100644 apps/sim/app/api/credential-sets/invitations/route.ts create mode 100644 apps/sim/app/api/credential-sets/invite/[token]/route.ts create mode 100644 apps/sim/app/api/credential-sets/memberships/route.ts create mode 100644 apps/sim/app/api/credential-sets/route.ts create mode 100644 apps/sim/app/credential-account/[token]/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx create mode 100644 apps/sim/components/emails/invitations/polling-group-invitation-email.tsx create mode 100644 apps/sim/hooks/queries/credential-sets.ts create mode 100644 apps/sim/lib/credential-sets/providers.ts create mode 100644 packages/db/migrations/0135_stormy_puff_adder.sql create mode 100644 packages/db/migrations/meta/0135_snapshot.json diff --git a/apps/docs/content/docs/en/triggers/index.mdx b/apps/docs/content/docs/en/triggers/index.mdx index 8ac76ad4b6..2a78dd1b33 100644 --- a/apps/docs/content/docs/en/triggers/index.mdx +++ b/apps/docs/content/docs/en/triggers/index.mdx @@ -33,6 +33,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o Monitor RSS and Atom feeds for new content + + Monitor team Gmail and Outlook inboxes + ## Quick Comparison @@ -43,6 +46,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o | **Schedule** | Timer managed in schedule block | | **Webhook** | On inbound HTTP request | | **RSS Feed** | New item published to feed | +| **Email Polling Groups** | New email received in team Gmail or Outlook inboxes | > The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data. @@ -66,3 +70,24 @@ If your workflow has multiple triggers, the highest priority trigger will be exe **External triggers with mock payloads**: When external triggers (webhooks and integrations) are executed manually, Sim automatically generates mock payloads based on the trigger's expected data structure. This ensures downstream blocks can resolve variables correctly during testing. +## Email Polling Groups + +Polling Groups let you monitor multiple team members' Gmail or Outlook inboxes with a single trigger. Requires a Team or Enterprise plan. + +**Creating a Polling Group** (Admin/Owner) + +1. Go to **Settings → Email Polling** +2. Click **Create** and choose Gmail or Outlook +3. Enter a name for the group + +**Inviting Members** + +1. Click **Add Members** on your polling group +2. Enter email addresses (comma or newline separated, or drag & drop a CSV) +3. Click **Send Invites** + +Invitees receive an email with a link to connect their account. Once connected, their inbox is automatically included in the polling group. Invitees don't need to be members of your Sim organization. + +**Using in a Workflow** + +When configuring an email trigger, select your polling group from the credentials dropdown instead of an individual account. The system creates webhooks for each member and routes all emails through your workflow. diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 0c08283b37..108e964909 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -109,11 +109,15 @@ function SignupFormContent({ setEmail(emailParam) } - const redirectParam = searchParams.get('redirect') + // Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl) + const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl') if (redirectParam) { setRedirectUrl(redirectParam) - if (redirectParam.startsWith('/invite/')) { + if ( + redirectParam.startsWith('/invite/') || + redirectParam.startsWith('/credential-account/') + ) { setIsInviteFlow(true) } } diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 9cd956fee6..7f625d2539 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -8,11 +8,18 @@ import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/ut describe('OAuth Disconnect API Route', () => { const mockGetSession = vi.fn() + const mockSelectChain = { + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + } const mockDb = { delete: vi.fn().mockReturnThis(), where: vi.fn(), + select: vi.fn().mockReturnValue(mockSelectChain), } const mockLogger = createMockLogger() + const mockSyncAllWebhooksForCredentialSet = vi.fn().mockResolvedValue({}) const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef' @@ -33,6 +40,13 @@ describe('OAuth Disconnect API Route', () => { vi.doMock('@sim/db/schema', () => ({ account: { userId: 'userId', providerId: 'providerId' }, + credentialSetMember: { + id: 'id', + credentialSetId: 'credentialSetId', + userId: 'userId', + status: 'status', + }, + credentialSet: { id: 'id', providerId: 'providerId' }, })) vi.doMock('drizzle-orm', () => ({ @@ -45,6 +59,14 @@ describe('OAuth Disconnect API Route', () => { vi.doMock('@sim/logger', () => ({ createLogger: vi.fn().mockReturnValue(mockLogger), })) + + vi.doMock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), + })) + + vi.doMock('@/lib/webhooks/utils.server', () => ({ + syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet, + })) }) afterEach(() => { diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 5050e86172..be645aa732 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,11 +1,12 @@ import { db } from '@sim/db' -import { account } from '@sim/db/schema' +import { account, credentialSet, credentialSetMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, like, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' export const dynamic = 'force-dynamic' @@ -74,6 +75,49 @@ export async function POST(request: NextRequest) { ) } + // Sync webhooks for all credential sets the user is a member of + // This removes webhooks that were using the disconnected credential + const userMemberships = await db + .select({ + id: credentialSetMember.id, + credentialSetId: credentialSetMember.credentialSetId, + providerId: credentialSet.providerId, + }) + .from(credentialSetMember) + .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) + .where( + and( + eq(credentialSetMember.userId, session.user.id), + eq(credentialSetMember.status, 'active') + ) + ) + + for (const membership of userMemberships) { + // Only sync if the credential set matches this provider + // Credential sets store OAuth provider IDs like 'google-email' or 'outlook' + const matchesProvider = + membership.providerId === provider || + membership.providerId === providerId || + membership.providerId?.startsWith(`${provider}-`) + + if (matchesProvider) { + try { + await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId) + logger.info(`[${requestId}] Synced webhooks after credential disconnect`, { + credentialSetId: membership.credentialSetId, + provider, + }) + } catch (error) { + // Log but don't fail the disconnect - credential is already removed + logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, { + credentialSetId: membership.credentialSetId, + provider, + error, + }) + } + } + } + return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) diff --git a/apps/sim/app/api/auth/oauth/token/route.test.ts b/apps/sim/app/api/auth/oauth/token/route.test.ts index 4d22039777..7359361a40 100644 --- a/apps/sim/app/api/auth/oauth/token/route.test.ts +++ b/apps/sim/app/api/auth/oauth/token/route.test.ts @@ -138,7 +138,10 @@ describe('OAuth Token API Routes', () => { const data = await response.json() expect(response.status).toBe(400) - expect(data).toHaveProperty('error', 'Credential ID is required') + expect(data).toHaveProperty( + 'error', + 'Either credentialId or (credentialAccountUserId + providerId) is required' + ) expect(mockLogger.warn).toHaveBeenCalled() }) diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index 603f5c6b0b..f5c8d7b617 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { checkHybridAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -12,12 +12,17 @@ const logger = createLogger('OAuthTokenAPI') const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/ -const tokenRequestSchema = z.object({ - credentialId: z - .string({ required_error: 'Credential ID is required' }) - .min(1, 'Credential ID is required'), - workflowId: z.string().min(1, 'Workflow ID is required').nullish(), -}) +const tokenRequestSchema = z + .object({ + credentialId: z.string().min(1).optional(), + credentialAccountUserId: z.string().min(1).optional(), + providerId: z.string().min(1).optional(), + workflowId: z.string().min(1).nullish(), + }) + .refine( + (data) => data.credentialId || (data.credentialAccountUserId && data.providerId), + 'Either credentialId or (credentialAccountUserId + providerId) is required' + ) const tokenQuerySchema = z.object({ credentialId: z @@ -58,9 +63,37 @@ export async function POST(request: NextRequest) { ) } - const { credentialId, workflowId } = parseResult.data + const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data + + if (credentialAccountUserId && providerId) { + logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, { + credentialAccountUserId, + providerId, + }) + + try { + const accessToken = await getOAuthToken(credentialAccountUserId, providerId) + if (!accessToken) { + return NextResponse.json( + { + error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`, + }, + { status: 404 } + ) + } + + return NextResponse.json({ accessToken }, { status: 200 }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to get OAuth token' + logger.warn(`[${requestId}] OAuth token error: ${message}`) + return NextResponse.json({ error: message }, { status: 403 }) + } + } + + if (!credentialId) { + return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) + } - // We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it const authz = await authorizeCredentialUse(request, { credentialId, workflowId: workflowId ?? undefined, @@ -70,7 +103,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) } - // Fetch the credential as the owner to enforce ownership scoping const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId) if (!credential) { @@ -78,7 +110,6 @@ export async function POST(request: NextRequest) { } try { - // Refresh the token if needed const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId) let instanceUrl: string | undefined @@ -145,7 +176,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) } - // Get the credential from the database const credential = await getCredential(requestId, credentialId, auth.userId) if (!credential) { diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index cb9176e989..08dd16fdff 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { account, workflow } from '@sim/db/schema' +import { account, credentialSetMember, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, inArray } from 'drizzle-orm' import { getSession } from '@/lib/auth' import { refreshOAuthToken } from '@/lib/oauth' @@ -105,10 +105,10 @@ export async function getOAuthToken(userId: string, providerId: string): Promise refreshToken: account.refreshToken, accessTokenExpiresAt: account.accessTokenExpiresAt, idToken: account.idToken, + scope: account.scope, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) - // Always use the most recently updated credential for this provider .orderBy(desc(account.updatedAt)) .limit(1) @@ -335,3 +335,108 @@ export async function refreshTokenIfNeeded( throw error } } + +export interface CredentialSetCredential { + userId: string + credentialId: string + accessToken: string + providerId: string +} + +export async function getCredentialsForCredentialSet( + credentialSetId: string, + providerId: string +): Promise { + logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`) + + const members = await db + .select({ userId: credentialSetMember.userId }) + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, credentialSetId), + eq(credentialSetMember.status, 'active') + ) + ) + + logger.info(`Found ${members.length} active members in credential set ${credentialSetId}`) + + if (members.length === 0) { + logger.warn(`No active members found for credential set ${credentialSetId}`) + return [] + } + + const userIds = members.map((m) => m.userId) + logger.debug(`Member user IDs: ${userIds.join(', ')}`) + + const credentials = await db + .select({ + id: account.id, + userId: account.userId, + providerId: account.providerId, + accessToken: account.accessToken, + refreshToken: account.refreshToken, + accessTokenExpiresAt: account.accessTokenExpiresAt, + }) + .from(account) + .where(and(inArray(account.userId, userIds), eq(account.providerId, providerId))) + + logger.info( + `Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members` + ) + + const results: CredentialSetCredential[] = [] + + for (const cred of credentials) { + const now = new Date() + const tokenExpiry = cred.accessTokenExpiresAt + const shouldRefresh = + !!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now)) + + let accessToken = cred.accessToken + + if (shouldRefresh && cred.refreshToken) { + try { + const refreshResult = await refreshOAuthToken(providerId, cred.refreshToken) + + if (refreshResult) { + accessToken = refreshResult.accessToken + + const updateData: Record = { + accessToken: refreshResult.accessToken, + accessTokenExpiresAt: new Date(Date.now() + refreshResult.expiresIn * 1000), + updatedAt: new Date(), + } + + if (refreshResult.refreshToken && refreshResult.refreshToken !== cred.refreshToken) { + updateData.refreshToken = refreshResult.refreshToken + } + + await db.update(account).set(updateData).where(eq(account.id, cred.id)) + + logger.info(`Refreshed token for user ${cred.userId}, provider ${providerId}`) + } + } catch (error) { + logger.error(`Failed to refresh token for user ${cred.userId}, provider ${providerId}`, { + error: error instanceof Error ? error.message : String(error), + }) + continue + } + } + + if (accessToken) { + results.push({ + userId: cred.userId, + credentialId: cred.id, + accessToken, + providerId, + }) + } + } + + logger.info( + `Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}` + ) + + return results +} diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts new file mode 100644 index 0000000000..839538ff7c --- /dev/null +++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts @@ -0,0 +1,146 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' +import { getSession } from '@/lib/auth' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { sendEmail } from '@/lib/messaging/email/mailer' + +const logger = createLogger('CredentialSetInviteResend') + +async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { + const [set] = await db + .select({ + id: credentialSet.id, + organizationId: credentialSet.organizationId, + name: credentialSet.name, + providerId: credentialSet.providerId, + }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) + .limit(1) + + if (!membership) return null + + return { set, role: membership.role } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } +) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id, invitationId } = await params + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + const [invitation] = await db + .select() + .from(credentialSetInvitation) + .where( + and( + eq(credentialSetInvitation.id, invitationId), + eq(credentialSetInvitation.credentialSetId, id) + ) + ) + .limit(1) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Only pending invitations can be resent' }, { status: 400 }) + } + + // Update expiration + const newExpiresAt = new Date() + newExpiresAt.setDate(newExpiresAt.getDate() + 7) + + await db + .update(credentialSetInvitation) + .set({ expiresAt: newExpiresAt }) + .where(eq(credentialSetInvitation.id, invitationId)) + + const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}` + + // Send email if email address exists + if (invitation.email) { + try { + const [inviter] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, result.set.organizationId)) + .limit(1) + + const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' + const emailHtml = await renderPollingGroupInvitationEmail({ + inviterName: inviter?.name || 'A team member', + organizationName: org?.name || 'your organization', + pollingGroupName: result.set.name, + provider, + inviteLink: inviteUrl, + }) + + const emailResult = await sendEmail({ + to: invitation.email, + subject: getEmailSubject('polling-group-invitation'), + html: emailHtml, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.warn('Failed to resend invitation email', { + email: invitation.email, + error: emailResult.message, + }) + return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) + } + } catch (emailError) { + logger.error('Error sending invitation email', emailError) + return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) + } + } + + logger.info('Resent credential set invitation', { + credentialSetId: id, + invitationId, + userId: session.user.id, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error resending invitation', error) + return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts new file mode 100644 index 0000000000..59b34a8268 --- /dev/null +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -0,0 +1,215 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' +import { getSession } from '@/lib/auth' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { sendEmail } from '@/lib/messaging/email/mailer' + +const logger = createLogger('CredentialSetInvite') + +const createInviteSchema = z.object({ + email: z.string().email().optional(), +}) + +async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { + const [set] = await db + .select({ + id: credentialSet.id, + organizationId: credentialSet.organizationId, + name: credentialSet.name, + providerId: credentialSet.providerId, + }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) + .limit(1) + + if (!membership) return null + + return { set, role: membership.role } +} + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + const invitations = await db + .select() + .from(credentialSetInvitation) + .where(eq(credentialSetInvitation.credentialSetId, id)) + + return NextResponse.json({ invitations }) +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + const body = await req.json() + const { email } = createInviteSchema.parse(body) + + const token = crypto.randomUUID() + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + const invitation = { + id: crypto.randomUUID(), + credentialSetId: id, + email: email || null, + token, + invitedBy: session.user.id, + status: 'pending' as const, + expiresAt, + createdAt: new Date(), + } + + await db.insert(credentialSetInvitation).values(invitation) + + const inviteUrl = `${getBaseUrl()}/credential-account/${token}` + + // Send email if email address was provided + if (email) { + try { + // Get inviter name + const [inviter] = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + // Get organization name + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, result.set.organizationId)) + .limit(1) + + const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' + const emailHtml = await renderPollingGroupInvitationEmail({ + inviterName: inviter?.name || 'A team member', + organizationName: org?.name || 'your organization', + pollingGroupName: result.set.name, + provider, + inviteLink: inviteUrl, + }) + + const emailResult = await sendEmail({ + to: email, + subject: getEmailSubject('polling-group-invitation'), + html: emailHtml, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.warn('Failed to send invitation email', { + email, + error: emailResult.message, + }) + } + } catch (emailError) { + logger.error('Error sending invitation email', emailError) + // Don't fail the invitation creation if email fails + } + } + + logger.info('Created credential set invitation', { + credentialSetId: id, + invitationId: invitation.id, + userId: session.user.id, + emailSent: !!email, + }) + + return NextResponse.json({ + invitation: { + ...invitation, + inviteUrl, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error creating invitation', error) + return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const { searchParams } = new URL(req.url) + const invitationId = searchParams.get('invitationId') + + if (!invitationId) { + return NextResponse.json({ error: 'invitationId is required' }, { status: 400 }) + } + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + await db + .update(credentialSetInvitation) + .set({ status: 'cancelled' }) + .where( + and( + eq(credentialSetInvitation.id, invitationId), + eq(credentialSetInvitation.credentialSetId, id) + ) + ) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error cancelling invitation', error) + return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts new file mode 100644 index 0000000000..f8fb0b2f08 --- /dev/null +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -0,0 +1,166 @@ +import { db } from '@sim/db' +import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' + +const logger = createLogger('CredentialSetMembers') + +async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { + const [set] = await db + .select({ + id: credentialSet.id, + organizationId: credentialSet.organizationId, + providerId: credentialSet.providerId, + }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) + .limit(1) + + if (!membership) return null + + return { set, role: membership.role } +} + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + const members = await db + .select({ + id: credentialSetMember.id, + userId: credentialSetMember.userId, + status: credentialSetMember.status, + joinedAt: credentialSetMember.joinedAt, + createdAt: credentialSetMember.createdAt, + userName: user.name, + userEmail: user.email, + userImage: user.image, + }) + .from(credentialSetMember) + .leftJoin(user, eq(credentialSetMember.userId, user.id)) + .where(eq(credentialSetMember.credentialSetId, id)) + + // Get credentials for all active members filtered by the polling group's provider + const activeMembers = members.filter((m) => m.status === 'active') + const memberUserIds = activeMembers.map((m) => m.userId) + + let credentials: { userId: string; providerId: string; accountId: string }[] = [] + if (memberUserIds.length > 0 && result.set.providerId) { + credentials = await db + .select({ + userId: account.userId, + providerId: account.providerId, + accountId: account.accountId, + }) + .from(account) + .where( + and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId)) + ) + } + + // Group credentials by userId + const credentialsByUser = credentials.reduce( + (acc, cred) => { + if (!acc[cred.userId]) { + acc[cred.userId] = [] + } + acc[cred.userId].push({ + providerId: cred.providerId, + accountId: cred.accountId, + }) + return acc + }, + {} as Record + ) + + // Attach credentials to members + const membersWithCredentials = members.map((m) => ({ + ...m, + credentials: credentialsByUser[m.userId] || [], + })) + + return NextResponse.json({ members: membersWithCredentials }) +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const { searchParams } = new URL(req.url) + const memberId = searchParams.get('memberId') + + if (!memberId) { + return NextResponse.json({ error: 'memberId is required' }, { status: 400 }) + } + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + const [memberToRemove] = await db + .select() + .from(credentialSetMember) + .where(and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id))) + .limit(1) + + if (!memberToRemove) { + return NextResponse.json({ error: 'Member not found' }, { status: 404 }) + } + + const requestId = crypto.randomUUID().slice(0, 8) + + // Use transaction to ensure member deletion + webhook sync are atomic + await db.transaction(async (tx) => { + await tx.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) + + const syncResult = await syncAllWebhooksForCredentialSet(id, requestId, tx) + logger.info('Synced webhooks after member removed', { + credentialSetId: id, + ...syncResult, + }) + }) + + logger.info('Removed member from credential set', { + credentialSetId: id, + memberId, + userId: session.user.id, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error removing member from credential set', error) + return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts new file mode 100644 index 0000000000..26af42e705 --- /dev/null +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -0,0 +1,155 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetMember, member } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSet') + +const updateCredentialSetSchema = z.object({ + name: z.string().trim().min(1).max(100).optional(), + description: z.string().max(500).nullable().optional(), +}) + +async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { + const [set] = await db + .select({ + id: credentialSet.id, + organizationId: credentialSet.organizationId, + name: credentialSet.name, + description: credentialSet.description, + providerId: credentialSet.providerId, + createdBy: credentialSet.createdBy, + createdAt: credentialSet.createdAt, + updatedAt: credentialSet.updatedAt, + }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) return null + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) + .limit(1) + + if (!membership) return null + + return { set, role: membership.role } +} + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + return NextResponse.json({ credentialSet: result.set }) +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + const body = await req.json() + const updates = updateCredentialSetSchema.parse(body) + + if (updates.name) { + const existingSet = await db + .select({ id: credentialSet.id }) + .from(credentialSet) + .where( + and( + eq(credentialSet.organizationId, result.set.organizationId), + eq(credentialSet.name, updates.name) + ) + ) + .limit(1) + + if (existingSet.length > 0 && existingSet[0].id !== id) { + return NextResponse.json( + { error: 'A credential set with this name already exists' }, + { status: 409 } + ) + } + } + + await db + .update(credentialSet) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(credentialSet.id, id)) + + const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1) + + return NextResponse.json({ credentialSet: updated }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error updating credential set', error) + return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + try { + const result = await getCredentialSetWithAccess(id, session.user.id) + + if (!result) { + return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) + } + + if (result.role !== 'admin' && result.role !== 'owner') { + return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) + } + + await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id)) + await db.delete(credentialSet).where(eq(credentialSet.id, id)) + + logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting credential set', error) + return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts new file mode 100644 index 0000000000..0a4df72315 --- /dev/null +++ b/apps/sim/app/api/credential-sets/invitations/route.ts @@ -0,0 +1,53 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, gt, isNull, or } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSetInvitations') + +export async function GET() { + const session = await getSession() + + if (!session?.user?.id || !session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const invitations = await db + .select({ + invitationId: credentialSetInvitation.id, + token: credentialSetInvitation.token, + status: credentialSetInvitation.status, + expiresAt: credentialSetInvitation.expiresAt, + createdAt: credentialSetInvitation.createdAt, + credentialSetId: credentialSet.id, + credentialSetName: credentialSet.name, + providerId: credentialSet.providerId, + organizationId: organization.id, + organizationName: organization.name, + invitedByName: user.name, + invitedByEmail: user.email, + }) + .from(credentialSetInvitation) + .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) + .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) + .leftJoin(user, eq(credentialSetInvitation.invitedBy, user.id)) + .where( + and( + or( + eq(credentialSetInvitation.email, session.user.email), + isNull(credentialSetInvitation.email) + ), + eq(credentialSetInvitation.status, 'pending'), + gt(credentialSetInvitation.expiresAt, new Date()) + ) + ) + + return NextResponse.json({ invitations }) + } catch (error) { + logger.error('Error fetching credential set invitations', error) + return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts new file mode 100644 index 0000000000..c42fbecda5 --- /dev/null +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -0,0 +1,196 @@ +import { db } from '@sim/db' +import { + credentialSet, + credentialSetInvitation, + credentialSetMember, + organization, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' + +const logger = createLogger('CredentialSetInviteToken') + +export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) { + const { token } = await params + + const [invitation] = await db + .select({ + id: credentialSetInvitation.id, + credentialSetId: credentialSetInvitation.credentialSetId, + email: credentialSetInvitation.email, + status: credentialSetInvitation.status, + expiresAt: credentialSetInvitation.expiresAt, + credentialSetName: credentialSet.name, + providerId: credentialSet.providerId, + organizationId: credentialSet.organizationId, + organizationName: organization.name, + }) + .from(credentialSetInvitation) + .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) + .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) + .where(eq(credentialSetInvitation.token, token)) + .limit(1) + + if (!invitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) + } + + if (new Date() > invitation.expiresAt) { + await db + .update(credentialSetInvitation) + .set({ status: 'expired' }) + .where(eq(credentialSetInvitation.id, invitation.id)) + + return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) + } + + return NextResponse.json({ + invitation: { + credentialSetName: invitation.credentialSetName, + organizationName: invitation.organizationName, + providerId: invitation.providerId, + email: invitation.email, + }, + }) +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ token: string }> }) { + const { token } = await params + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + + try { + const [invitationData] = await db + .select({ + id: credentialSetInvitation.id, + credentialSetId: credentialSetInvitation.credentialSetId, + email: credentialSetInvitation.email, + status: credentialSetInvitation.status, + expiresAt: credentialSetInvitation.expiresAt, + invitedBy: credentialSetInvitation.invitedBy, + providerId: credentialSet.providerId, + }) + .from(credentialSetInvitation) + .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) + .where(eq(credentialSetInvitation.token, token)) + .limit(1) + + if (!invitationData) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + const invitation = invitationData + + if (invitation.status !== 'pending') { + return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) + } + + if (new Date() > invitation.expiresAt) { + await db + .update(credentialSetInvitation) + .set({ status: 'expired' }) + .where(eq(credentialSetInvitation.id, invitation.id)) + + return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) + } + + const existingMember = await db + .select() + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, invitation.credentialSetId), + eq(credentialSetMember.userId, session.user.id) + ) + ) + .limit(1) + + if (existingMember.length > 0) { + return NextResponse.json( + { error: 'Already a member of this credential set' }, + { status: 409 } + ) + } + + const now = new Date() + const requestId = crypto.randomUUID().slice(0, 8) + + // Use transaction to ensure membership + invitation update + webhook sync are atomic + await db.transaction(async (tx) => { + await tx.insert(credentialSetMember).values({ + id: crypto.randomUUID(), + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + status: 'active', + joinedAt: now, + invitedBy: invitation.invitedBy, + createdAt: now, + updatedAt: now, + }) + + await tx + .update(credentialSetInvitation) + .set({ + status: 'accepted', + acceptedAt: now, + acceptedByUserId: session.user.id, + }) + .where(eq(credentialSetInvitation.id, invitation.id)) + + // Clean up all other pending invitations for the same credential set and email + // This prevents duplicate invites from showing up after accepting one + if (invitation.email) { + await tx + .update(credentialSetInvitation) + .set({ + status: 'accepted', + acceptedAt: now, + acceptedByUserId: session.user.id, + }) + .where( + and( + eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), + eq(credentialSetInvitation.email, invitation.email), + eq(credentialSetInvitation.status, 'pending') + ) + ) + } + + // Sync webhooks within the transaction + const syncResult = await syncAllWebhooksForCredentialSet( + invitation.credentialSetId, + requestId, + tx + ) + logger.info('Synced webhooks after member joined', { + credentialSetId: invitation.credentialSetId, + ...syncResult, + }) + }) + + logger.info('Accepted credential set invitation', { + invitationId: invitation.id, + credentialSetId: invitation.credentialSetId, + userId: session.user.id, + }) + + return NextResponse.json({ + success: true, + credentialSetId: invitation.credentialSetId, + providerId: invitation.providerId, + }) + } catch (error) { + logger.error('Error accepting invitation', error) + return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts new file mode 100644 index 0000000000..5ce0384d4a --- /dev/null +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -0,0 +1,115 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetMember, organization } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' + +const logger = createLogger('CredentialSetMemberships') + +export async function GET() { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const memberships = await db + .select({ + membershipId: credentialSetMember.id, + status: credentialSetMember.status, + joinedAt: credentialSetMember.joinedAt, + credentialSetId: credentialSet.id, + credentialSetName: credentialSet.name, + credentialSetDescription: credentialSet.description, + providerId: credentialSet.providerId, + organizationId: organization.id, + organizationName: organization.name, + }) + .from(credentialSetMember) + .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) + .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) + .where(eq(credentialSetMember.userId, session.user.id)) + + return NextResponse.json({ memberships }) + } catch (error) { + logger.error('Error fetching credential set memberships', error) + return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 }) + } +} + +/** + * Leave a credential set (self-revocation). + * Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up. + */ +export async function DELETE(req: NextRequest) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const credentialSetId = searchParams.get('credentialSetId') + + if (!credentialSetId) { + return NextResponse.json({ error: 'credentialSetId is required' }, { status: 400 }) + } + + try { + const requestId = crypto.randomUUID().slice(0, 8) + + // Use transaction to ensure revocation + webhook sync are atomic + await db.transaction(async (tx) => { + // Find and verify membership + const [membership] = await tx + .select() + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, credentialSetId), + eq(credentialSetMember.userId, session.user.id) + ) + ) + .limit(1) + + if (!membership) { + throw new Error('Not a member of this credential set') + } + + if (membership.status === 'revoked') { + throw new Error('Already left this credential set') + } + + // Set status to 'revoked' - this immediately blocks credential from being used + await tx + .update(credentialSetMember) + .set({ + status: 'revoked', + updatedAt: new Date(), + }) + .where(eq(credentialSetMember.id, membership.id)) + + // Sync webhooks to remove this user's credential webhooks + const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId, tx) + logger.info('Synced webhooks after member left', { + credentialSetId, + userId: session.user.id, + ...syncResult, + }) + }) + + logger.info('User left credential set', { + credentialSetId, + userId: session.user.id, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to leave credential set' + logger.error('Error leaving credential set', error) + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts new file mode 100644 index 0000000000..51d80293d8 --- /dev/null +++ b/apps/sim/app/api/credential-sets/route.ts @@ -0,0 +1,157 @@ +import { db } from '@sim/db' +import { credentialSet, credentialSetMember, member, organization, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, count, desc, eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' + +const logger = createLogger('CredentialSets') + +const createCredentialSetSchema = z.object({ + organizationId: z.string().min(1), + name: z.string().trim().min(1).max(100), + description: z.string().max(500).optional(), + providerId: z.enum(['google-email', 'outlook']), +}) + +export async function GET(req: Request) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const organizationId = searchParams.get('organizationId') + + if (!organizationId) { + return NextResponse.json({ error: 'organizationId is required' }, { status: 400 }) + } + + const membership = await db + .select({ id: member.id, role: member.role }) + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) + .limit(1) + + if (membership.length === 0) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const sets = await db + .select({ + id: credentialSet.id, + name: credentialSet.name, + description: credentialSet.description, + providerId: credentialSet.providerId, + createdBy: credentialSet.createdBy, + createdAt: credentialSet.createdAt, + updatedAt: credentialSet.updatedAt, + creatorName: user.name, + creatorEmail: user.email, + }) + .from(credentialSet) + .leftJoin(user, eq(credentialSet.createdBy, user.id)) + .where(eq(credentialSet.organizationId, organizationId)) + .orderBy(desc(credentialSet.createdAt)) + + const setsWithCounts = await Promise.all( + sets.map(async (set) => { + const [memberCount] = await db + .select({ count: count() }) + .from(credentialSetMember) + .where( + and( + eq(credentialSetMember.credentialSetId, set.id), + eq(credentialSetMember.status, 'active') + ) + ) + + return { + ...set, + memberCount: memberCount?.count ?? 0, + } + }) + ) + + return NextResponse.json({ credentialSets: setsWithCounts }) +} + +export async function POST(req: Request) { + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body) + + const membership = await db + .select({ id: member.id, role: member.role }) + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) + .limit(1) + + const role = membership[0]?.role + if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) { + return NextResponse.json( + { error: 'Admin or owner permissions required to create credential sets' }, + { status: 403 } + ) + } + + const orgExists = await db + .select({ id: organization.id }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1) + + if (orgExists.length === 0) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) + } + + const existingSet = await db + .select({ id: credentialSet.id }) + .from(credentialSet) + .where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name))) + .limit(1) + + if (existingSet.length > 0) { + return NextResponse.json( + { error: 'A credential set with this name already exists' }, + { status: 409 } + ) + } + + const now = new Date() + const newCredentialSet = { + id: crypto.randomUUID(), + organizationId, + name, + description: description || null, + providerId, + createdBy: session.user.id, + createdAt: now, + updatedAt: now, + } + + await db.insert(credentialSet).values(newCredentialSet) + + logger.info('Created credential set', { + credentialSetId: newCredentialSet.id, + organizationId, + userId: session.user.id, + }) + + return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: error.errors[0].message }, { status: 400 }) + } + logger.error('Error creating credential set', error) + return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index bf9332caa0..143a924cc3 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -15,8 +15,11 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { getEmailSubject, renderInvitationEmail } from '@/components/emails' import { getSession } from '@/lib/auth' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { sendEmail } from '@/lib/messaging/email/mailer' const logger = createLogger('OrganizationInvitation') @@ -69,6 +72,102 @@ export async function GET( } } +// Resend invitation +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string; invitationId: string }> } +) { + const { id: organizationId, invitationId } = await params + const session = await getSession() + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + // Verify user is admin/owner + const memberEntry = await db + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) + .limit(1) + + if (memberEntry.length === 0 || !['owner', 'admin'].includes(memberEntry[0].role)) { + return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) + } + + const orgInvitation = await db + .select() + .from(invitation) + .where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId))) + .then((rows) => rows[0]) + + if (!orgInvitation) { + return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) + } + + if (orgInvitation.status !== 'pending') { + return NextResponse.json({ error: 'Can only resend pending invitations' }, { status: 400 }) + } + + const org = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .then((rows) => rows[0]) + + const inviter = await db + .select({ name: user.name }) + .from(user) + .where(eq(user.id, session.user.id)) + .limit(1) + + // Update expiration date + const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days + await db + .update(invitation) + .set({ expiresAt: newExpiresAt }) + .where(eq(invitation.id, invitationId)) + + // Send email + const emailHtml = await renderInvitationEmail( + inviter[0]?.name || 'Someone', + org?.name || 'organization', + `${getBaseUrl()}/invite/${invitationId}` + ) + + const emailResult = await sendEmail({ + to: orgInvitation.email, + subject: getEmailSubject('invitation'), + html: emailHtml, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.error('Failed to resend invitation email', { + email: orgInvitation.email, + error: emailResult.message, + }) + return NextResponse.json({ error: 'Failed to send invitation email' }, { status: 500 }) + } + + logger.info('Organization invitation resent', { + organizationId, + invitationId, + resentBy: session.user.id, + email: orgInvitation.email, + }) + + return NextResponse.json({ + success: true, + message: 'Invitation resent successfully', + }) + } catch (error) { + logger.error('Error resending organization invitation:', error) + return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) + } +} + export async function PUT( req: NextRequest, { params }: { params: Promise<{ id: string; invitationId: string }> } diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 6f7ffc3a3d..f5674ff36a 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { validateInteger } from '@/lib/core/security/input-validation' @@ -184,16 +184,28 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< hasFailedCountUpdate: failedCount !== undefined, }) - // Update the webhook + // Merge providerConfig to preserve credential-related fields + let finalProviderConfig = webhooks[0].webhook.providerConfig + if (providerConfig !== undefined) { + const existingConfig = (webhooks[0].webhook.providerConfig as Record) || {} + finalProviderConfig = { + ...resolvedProviderConfig, + credentialId: existingConfig.credentialId, + credentialSetId: existingConfig.credentialSetId, + userId: existingConfig.userId, + historyId: existingConfig.historyId, + lastCheckedTimestamp: existingConfig.lastCheckedTimestamp, + setupCompleted: existingConfig.setupCompleted, + externalId: existingConfig.externalId, + } + } + const updatedWebhook = await db .update(webhook) .set({ path: path !== undefined ? path : webhooks[0].webhook.path, provider: provider !== undefined ? provider : webhooks[0].webhook.provider, - providerConfig: - providerConfig !== undefined - ? resolvedProviderConfig - : webhooks[0].webhook.providerConfig, + providerConfig: finalProviderConfig, isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive, failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount, updatedAt: new Date(), @@ -276,13 +288,46 @@ export async function DELETE( } const foundWebhook = webhookData.webhook - const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions') - await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) - await db.delete(webhook).where(eq(webhook.id, id)) + const providerConfig = foundWebhook.providerConfig as Record | null + const credentialSetId = providerConfig?.credentialSetId as string | undefined + const blockId = providerConfig?.blockId as string | undefined + + if (credentialSetId && blockId) { + const allCredentialSetWebhooks = await db + .select() + .from(webhook) + .where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId))) + + const webhooksToDelete = allCredentialSetWebhooks.filter((w) => { + const config = w.providerConfig as Record | null + return config?.credentialSetId === credentialSetId + }) + + for (const w of webhooksToDelete) { + await cleanupExternalWebhook(w, webhookData.workflow, requestId) + } + + const idsToDelete = webhooksToDelete.map((w) => w.id) + for (const wId of idsToDelete) { + await db.delete(webhook).where(eq(webhook.id, wId)) + } + + logger.info( + `[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`, + { + credentialSetId, + blockId, + deletedIds: idsToDelete, + } + ) + } else { + await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) + await db.delete(webhook).where(eq(webhook.id, id)) + logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + } - logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { logger.error(`[${requestId}] Error deleting webhook`, { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 62ad40db06..28f3180b3a 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -262,6 +262,157 @@ export async function POST(request: NextRequest) { workflowRecord.workspaceId || undefined ) + // --- Credential Set Handling --- + // For credential sets, we fan out to create one webhook per credential at save time. + // This applies to all OAuth-based triggers, not just polling ones. + // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields + const rawCredentialId = (resolvedProviderConfig?.credentialId || + resolvedProviderConfig?.triggerCredentials) as string | undefined + const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined + + if (directCredentialSetId || rawCredentialId) { + const { isCredentialSetValue, extractCredentialSetId } = await import('@/executor/constants') + + const credentialSetId = + directCredentialSetId || + (rawCredentialId && isCredentialSetValue(rawCredentialId) + ? extractCredentialSetId(rawCredentialId) + : null) + + if (credentialSetId) { + logger.info( + `[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}` + ) + + const { getProviderIdFromServiceId } = await import('@/lib/oauth') + const { syncWebhooksForCredentialSet, configureGmailPolling, configureOutlookPolling } = + await import('@/lib/webhooks/utils.server') + + // Map provider to OAuth provider ID + const oauthProviderId = getProviderIdFromServiceId(provider) + + const { + credentialId: _cId, + triggerCredentials: _tCred, + credentialSetId: _csId, + ...baseProviderConfig + } = resolvedProviderConfig + + try { + const syncResult = await syncWebhooksForCredentialSet({ + workflowId, + blockId, + provider, + basePath: finalPath, + credentialSetId, + oauthProviderId, + providerConfig: baseProviderConfig, + requestId, + }) + + if (syncResult.webhooks.length === 0) { + logger.error( + `[${requestId}] No webhooks created for credential set - no valid credentials found` + ) + return NextResponse.json( + { + error: `No valid credentials found in credential set for ${provider}`, + details: 'Please ensure team members have connected their accounts', + }, + { status: 400 } + ) + } + + // Configure each new webhook (for providers that need configuration) + const pollingProviders = ['gmail', 'outlook'] + const needsConfiguration = pollingProviders.includes(provider) + + if (needsConfiguration) { + const configureFunc = + provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + const configureErrors: string[] = [] + + for (const wh of syncResult.webhooks) { + if (wh.isNew) { + // Fetch the webhook data for configuration + const webhookRows = await db + .select() + .from(webhook) + .where(eq(webhook.id, wh.id)) + .limit(1) + + if (webhookRows.length > 0) { + const success = await configureFunc(webhookRows[0], requestId) + if (!success) { + configureErrors.push( + `Failed to configure webhook for credential ${wh.credentialId}` + ) + logger.warn( + `[${requestId}] Failed to configure ${provider} polling for webhook ${wh.id}` + ) + } + } + } + } + + if ( + configureErrors.length > 0 && + configureErrors.length === syncResult.webhooks.length + ) { + // All configurations failed - roll back + logger.error(`[${requestId}] All webhook configurations failed, rolling back`) + for (const wh of syncResult.webhooks) { + await db.delete(webhook).where(eq(webhook.id, wh.id)) + } + return NextResponse.json( + { + error: `Failed to configure ${provider} polling`, + details: 'Please check account permissions and try again', + }, + { status: 500 } + ) + } + } + + logger.info( + `[${requestId}] Successfully synced ${syncResult.webhooks.length} webhooks for credential set ${credentialSetId}` + ) + + // Return the first webhook as the "primary" for the UI + // The UI will query by credentialSetId to get all of them + const primaryWebhookRows = await db + .select() + .from(webhook) + .where(eq(webhook.id, syncResult.webhooks[0].id)) + .limit(1) + + return NextResponse.json( + { + webhook: primaryWebhookRows[0], + credentialSetInfo: { + credentialSetId, + totalWebhooks: syncResult.webhooks.length, + created: syncResult.created, + updated: syncResult.updated, + deleted: syncResult.deleted, + }, + }, + { status: syncResult.created > 0 ? 201 : 200 } + ) + } catch (err) { + logger.error(`[${requestId}] Error syncing webhooks for credential set`, err) + return NextResponse.json( + { + error: `Failed to configure ${provider} webhook`, + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } + } + } + // --- End Credential Set Handling --- + // Create external subscriptions before saving to DB to prevent orphaned records let externalSubscriptionId: string | undefined let externalSubscriptionCreated = false @@ -422,6 +573,10 @@ export async function POST(request: NextRequest) { blockId, provider, providerConfig: resolvedProviderConfig, + credentialSetId: + ((resolvedProviderConfig as Record)?.credentialSetId as + | string + | null) || null, isActive: true, updatedAt: new Date(), }) @@ -445,6 +600,10 @@ export async function POST(request: NextRequest) { path: finalPath, provider, providerConfig: resolvedProviderConfig, + credentialSetId: + ((resolvedProviderConfig as Record)?.credentialSetId as + | string + | null) || null, isActive: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index cdb17c5a81..fff521ca8f 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -157,6 +157,112 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ blockExistsInDeployment: vi.fn().mockResolvedValue(true), })) +vi.mock('@/lib/webhooks/processor', () => ({ + findAllWebhooksForPath: vi.fn().mockImplementation(async (options: { path: string }) => { + // Filter webhooks by path from globalMockData + const matchingWebhooks = globalMockData.webhooks.filter( + (wh) => wh.path === options.path && wh.isActive + ) + + if (matchingWebhooks.length === 0) { + return [] + } + + // Return array of {webhook, workflow} objects + return matchingWebhooks.map((wh) => { + const matchingWorkflow = globalMockData.workflows.find((w) => w.id === wh.workflowId) || { + id: wh.workflowId || 'test-workflow-id', + userId: 'test-user-id', + workspaceId: 'test-workspace-id', + } + return { + webhook: wh, + workflow: matchingWorkflow, + } + }) + }), + parseWebhookBody: vi.fn().mockImplementation(async (request: any) => { + try { + const cloned = request.clone() + const rawBody = await cloned.text() + const body = rawBody ? JSON.parse(rawBody) : {} + return { body, rawBody } + } catch { + return { body: {}, rawBody: '' } + } + }), + handleProviderChallenges: vi.fn().mockResolvedValue(null), + handleProviderReachabilityTest: vi.fn().mockReturnValue(null), + verifyProviderAuth: vi + .fn() + .mockImplementation( + async ( + foundWebhook: any, + _foundWorkflow: any, + request: any, + _rawBody: string, + _requestId: string + ) => { + // Implement generic webhook auth verification for tests + if (foundWebhook.provider === 'generic') { + const providerConfig = foundWebhook.providerConfig || {} + if (providerConfig.requireAuth) { + const configToken = providerConfig.token + const secretHeaderName = providerConfig.secretHeaderName + + if (configToken) { + let isTokenValid = false + + if (secretHeaderName) { + // Custom header auth + const headerValue = request.headers.get(secretHeaderName.toLowerCase()) + if (headerValue === configToken) { + isTokenValid = true + } + } else { + // Bearer token auth + const authHeader = request.headers.get('authorization') + if (authHeader?.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7) + if (token === configToken) { + isTokenValid = true + } + } + } + + if (!isTokenValid) { + const { NextResponse } = await import('next/server') + return new NextResponse('Unauthorized - Invalid authentication token', { + status: 401, + }) + } + } else { + // Auth required but no token configured + const { NextResponse } = await import('next/server') + return new NextResponse('Unauthorized - Authentication required but not configured', { + status: 401, + }) + } + } + } + return null + } + ), + checkWebhookPreprocessing: vi.fn().mockResolvedValue(null), + formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => { + const { NextResponse } = require('next/server') + return NextResponse.json({ error }, { status }) + }), + shouldSkipWebhookEvent: vi.fn().mockReturnValue(false), + handlePreDeploymentVerification: vi.fn().mockReturnValue(null), + queueWebhookExecution: vi.fn().mockImplementation(async () => { + // Call processWebhookMock so tests can verify it was called + processWebhookMock() + const { NextResponse } = await import('next/server') + return NextResponse.json({ message: 'Webhook processed' }) + }), +})) + vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: vi.fn().mockReturnValue({}), })) @@ -165,6 +271,10 @@ vi.mock('postgres', () => vi.fn().mockReturnValue({})) vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test' import { POST } from '@/app/api/webhooks/trigger/[path]/route' diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 96ad8492f1..f3aba1e7b4 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -3,11 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' import { checkWebhookPreprocessing, - findWebhookAndWorkflow, + findAllWebhooksForPath, + formatProviderErrorResponse, + handlePreDeploymentVerification, handleProviderChallenges, handleProviderReachabilityTest, parseWebhookBody, queueWebhookExecution, + shouldSkipWebhookEvent, verifyProviderAuth, } from '@/lib/webhooks/processor' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' @@ -22,19 +25,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const requestId = generateRequestId() const { path } = await params - // Handle Microsoft Graph subscription validation - const url = new URL(request.url) - const validationToken = url.searchParams.get('validationToken') - - if (validationToken) { - logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) - return new NextResponse(validationToken, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }) - } - - // Handle other GET-based verifications if needed + // Handle provider-specific GET verifications (Microsoft Graph, WhatsApp, etc.) const challengeResponse = await handleProviderChallenges({}, request, requestId, path) if (challengeResponse) { return challengeResponse @@ -50,26 +41,10 @@ export async function POST( const requestId = generateRequestId() const { path } = await params - // Log ALL incoming webhook requests for debugging - logger.info(`[${requestId}] Incoming webhook request`, { - path, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - }) - - // Handle Microsoft Graph subscription validation (some environments send POST with validationToken) - try { - const url = new URL(request.url) - const validationToken = url.searchParams.get('validationToken') - if (validationToken) { - logger.info(`[${requestId}] Microsoft Graph subscription validation (POST) for path: ${path}`) - return new NextResponse(validationToken, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }) - } - } catch { - // ignore URL parsing errors; proceed to normal handling + // Handle provider challenges before body parsing (Microsoft Graph validationToken, etc.) + const earlyChallenge = await handleProviderChallenges({}, request, requestId, path) + if (earlyChallenge) { + return earlyChallenge } const parseResult = await parseWebhookBody(request, requestId) @@ -86,118 +61,118 @@ export async function POST( return challengeResponse } - const findResult = await findWebhookAndWorkflow({ requestId, path }) + // Find all webhooks for this path (supports credential set fan-out where multiple webhooks share a path) + const webhooksForPath = await findAllWebhooksForPath({ requestId, path }) - if (!findResult) { + if (webhooksForPath.length === 0) { logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`) - return new NextResponse('Not Found', { status: 404 }) } - const { webhook: foundWebhook, workflow: foundWorkflow } = findResult - - // Log HubSpot webhook details for debugging - if (foundWebhook.provider === 'hubspot') { - const events = Array.isArray(body) ? body : [body] - const firstEvent = events[0] - - logger.info(`[${requestId}] HubSpot webhook received`, { - path, - subscriptionType: firstEvent?.subscriptionType, - objectId: firstEvent?.objectId, - portalId: firstEvent?.portalId, - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId: foundWebhook.providerConfig?.triggerId, - eventCount: events.length, - }) - } + // Process each webhook + // For credential sets with shared paths, each webhook represents a different credential + const responses: NextResponse[] = [] + + for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooksForPath) { + const authError = await verifyProviderAuth( + foundWebhook, + foundWorkflow, + request, + rawBody, + requestId + ) + if (authError) { + // For multi-webhook, log and continue to next webhook + if (webhooksForPath.length > 1) { + logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`) + continue + } + return authError + } - const authError = await verifyProviderAuth( - foundWebhook, - foundWorkflow, - request, - rawBody, - requestId - ) - if (authError) { - return authError - } + const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId) + if (reachabilityResponse) { + // Reachability test should return immediately for the first webhook + return reachabilityResponse + } - const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId) - if (reachabilityResponse) { - return reachabilityResponse - } + let preprocessError: NextResponse | null = null + try { + preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) + if (preprocessError) { + if (webhooksForPath.length > 1) { + logger.warn( + `[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next` + ) + continue + } + return preprocessError + } + } catch (error) { + logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + webhookId: foundWebhook.id, + workflowId: foundWorkflow.id, + }) - let preprocessError: NextResponse | null = null - try { - preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) - if (preprocessError) { - return preprocessError - } - } catch (error) { - logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - }) + if (webhooksForPath.length > 1) { + continue + } - if (foundWebhook.provider === 'microsoft-teams') { - return NextResponse.json( - { - type: 'message', - text: 'An unexpected error occurred during preprocessing', - }, - { status: 500 } + return formatProviderErrorResponse( + foundWebhook, + 'An unexpected error occurred during preprocessing', + 500 ) } - return NextResponse.json( - { error: 'An unexpected error occurred during preprocessing' }, - { status: 500 } - ) - } + if (foundWebhook.blockId) { + const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) + if (!blockExists) { + const preDeploymentResponse = handlePreDeploymentVerification(foundWebhook, requestId) + if (preDeploymentResponse) { + return preDeploymentResponse + } - if (foundWebhook.blockId) { - const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) - if (!blockExists) { - // For Grain, if block doesn't exist in deployment, treat as verification request - // Grain validates webhook URLs during creation, and the block may not be deployed yet - if (foundWebhook.provider === 'grain') { logger.info( - `[${requestId}] Grain webhook verification - block not in deployment, returning 200 OK` + `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}` ) - return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' }) + if (webhooksForPath.length > 1) { + continue + } + return new NextResponse('Trigger block not found in deployment', { status: 404 }) } + } - logger.info( - `[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}` - ) - return new NextResponse('Trigger block not found in deployment', { status: 404 }) + if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) { + continue } - } - if (foundWebhook.provider === 'stripe') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const eventTypes = providerConfig.eventTypes + const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { + requestId, + path, + testMode: false, + executionTarget: 'deployed', + }) + responses.push(response) + } - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type + // Return the last successful response, or a combined response for multiple webhooks + if (responses.length === 0) { + return new NextResponse('No webhooks processed successfully', { status: 500 }) + } - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Stripe event type '${eventType}' not in allowed list, skipping execution` - ) - return new NextResponse('Event type filtered', { status: 200 }) - } - } + if (responses.length === 1) { + return responses[0] } - return queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { - requestId, - path, - testMode: false, - executionTarget: 'deployed', + // For multiple webhooks, return success if at least one succeeded + logger.info( + `[${requestId}] Processed ${responses.length} webhooks for path: ${path} (credential set fan-out)` + ) + return NextResponse.json({ + success: true, + webhooksProcessed: responses.length, }) } diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index 43957ad95a..2cadeff341 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -317,6 +317,8 @@ interface WebhookMetadata { providerConfig: Record } +const CREDENTIAL_SET_PREFIX = 'credentialSet:' + function buildWebhookMetadata(block: BlockState): WebhookMetadata | null { const triggerId = getSubBlockValue(block, 'triggerId') || @@ -328,9 +330,17 @@ function buildWebhookMetadata(block: BlockState): WebhookMetadata | null { const triggerDef = triggerId ? getTrigger(triggerId) : undefined const provider = triggerDef?.provider || null + // Handle credential sets vs individual credentials + const isCredentialSet = triggerCredentials?.startsWith(CREDENTIAL_SET_PREFIX) + const credentialSetId = isCredentialSet + ? triggerCredentials!.slice(CREDENTIAL_SET_PREFIX.length) + : undefined + const credentialId = isCredentialSet ? undefined : triggerCredentials + const providerConfig = { ...(typeof triggerConfig === 'object' ? triggerConfig : {}), - ...(triggerCredentials ? { credentialId: triggerCredentials } : {}), + ...(credentialId ? { credentialId } : {}), + ...(credentialSetId ? { credentialSetId } : {}), ...(triggerId ? { triggerId } : {}), } @@ -347,6 +357,54 @@ async function upsertWebhookRecord( webhookId: string, metadata: WebhookMetadata ): Promise { + const providerConfig = metadata.providerConfig as Record + const credentialSetId = providerConfig?.credentialSetId as string | undefined + + // For credential sets, delegate to the sync function which handles fan-out + if (credentialSetId && metadata.provider) { + const { syncWebhooksForCredentialSet } = await import('@/lib/webhooks/utils.server') + const { getProviderIdFromServiceId } = await import('@/lib/oauth') + + const oauthProviderId = getProviderIdFromServiceId(metadata.provider) + const requestId = crypto.randomUUID().slice(0, 8) + + // Extract base config (without credential-specific fields) + const { + credentialId: _cId, + credentialSetId: _csId, + userId: _uId, + ...baseConfig + } = providerConfig + + try { + await syncWebhooksForCredentialSet({ + workflowId, + blockId: block.id, + provider: metadata.provider, + basePath: metadata.triggerPath, + credentialSetId, + oauthProviderId, + providerConfig: baseConfig as Record, + requestId, + }) + + logger.info('Synced credential set webhooks during workflow save', { + workflowId, + blockId: block.id, + credentialSetId, + }) + } catch (error) { + logger.error('Failed to sync credential set webhooks during workflow save', { + workflowId, + blockId: block.id, + credentialSetId, + error, + }) + } + return + } + + // For individual credentials, use the existing single webhook logic const [existing] = await db.select().from(webhook).where(eq(webhook.id, webhookId)).limit(1) if (existing) { @@ -381,6 +439,7 @@ async function upsertWebhookRecord( path: metadata.triggerPath, provider: metadata.provider, providerConfig: metadata.providerConfig, + credentialSetId: null, isActive: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx new file mode 100644 index 0000000000..4bddd16265 --- /dev/null +++ b/apps/sim/app/credential-account/[token]/page.tsx @@ -0,0 +1,269 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Mail } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { GmailIcon, OutlookIcon } from '@/components/icons' +import { client, useSession } from '@/lib/auth/auth-client' +import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers' +import { InviteLayout, InviteStatusCard } from '@/app/invite/components' + +interface InvitationInfo { + credentialSetName: string + organizationName: string + providerId: string | null + email: string | null +} + +type AcceptedState = 'connecting' | 'already-connected' + +export default function CredentialAccountInvitePage() { + const params = useParams() + const router = useRouter() + const token = params.token as string + + const { data: session, isPending: sessionLoading } = useSession() + + const [invitation, setInvitation] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [accepting, setAccepting] = useState(false) + const [acceptedState, setAcceptedState] = useState(null) + + useEffect(() => { + async function fetchInvitation() { + try { + const res = await fetch(`/api/credential-sets/invite/${token}`) + if (!res.ok) { + const data = await res.json() + setError(data.error || 'Failed to load invitation') + return + } + const data = await res.json() + setInvitation(data.invitation) + } catch { + setError('Failed to load invitation') + } finally { + setLoading(false) + } + } + + fetchInvitation() + }, [token]) + + const handleAccept = useCallback(async () => { + if (!session?.user?.id) { + // Include invite_flow=true so the login page preserves callbackUrl when linking to signup + const callbackUrl = encodeURIComponent(`/credential-account/${token}`) + router.push(`/login?invite_flow=true&callbackUrl=${callbackUrl}`) + return + } + + setAccepting(true) + try { + const res = await fetch(`/api/credential-sets/invite/${token}`, { + method: 'POST', + }) + + if (!res.ok) { + const data = await res.json() + setError(data.error || 'Failed to accept invitation') + return + } + + const data = await res.json() + const credentialSetProviderId = data.providerId || invitation?.providerId + + // Check if user already has this provider connected + let isAlreadyConnected = false + if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) { + try { + const connectionsRes = await fetch('/api/auth/oauth/connections') + if (connectionsRes.ok) { + const connectionsData = await connectionsRes.json() + const connections = connectionsData.connections || [] + isAlreadyConnected = connections.some( + (conn: { provider: string; accounts?: { id: string }[] }) => + conn.provider === credentialSetProviderId && + conn.accounts && + conn.accounts.length > 0 + ) + } + } catch { + // If we can't check connections, proceed with OAuth flow + } + } + + if (isAlreadyConnected) { + // Already connected - redirect to workspace + setAcceptedState('already-connected') + setTimeout(() => { + router.push('/workspace') + }, 2000) + } else if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) { + // Not connected - start OAuth flow + setAcceptedState('connecting') + + // Small delay to show success message before redirect + setTimeout(async () => { + try { + await client.oauth2.link({ + providerId: credentialSetProviderId, + callbackURL: `${window.location.origin}/workspace`, + }) + } catch (oauthError) { + // OAuth redirect will happen, this catch is for any pre-redirect errors + console.error('OAuth initiation error:', oauthError) + // If OAuth fails, redirect to workspace where they can connect manually + router.push('/workspace') + } + }, 1500) + } else { + // No provider specified - just redirect to workspace + router.push('/workspace') + } + } catch { + setError('Failed to accept invitation') + } finally { + setAccepting(false) + } + }, [session?.user?.id, token, router, invitation?.providerId]) + + const providerName = invitation?.providerId + ? getProviderDisplayName(invitation.providerId) + : 'email' + + const ProviderIcon = + invitation?.providerId === 'outlook' + ? OutlookIcon + : invitation?.providerId === 'google-email' + ? GmailIcon + : Mail + + const providerWithIcon = ( + + + {providerName} + + ) + + const getCallbackUrl = () => `/credential-account/${token}` + + if (loading || sessionLoading) { + return ( + + + + ) + } + + if (error) { + return ( + + router.push('/'), + }, + ]} + /> + + ) + } + + if (acceptedState === 'already-connected') { + return ( + + + + ) + } + + if (acceptedState === 'connecting') { + return ( + + + + ) + } + + // Not logged in + if (!session?.user) { + const callbackUrl = encodeURIComponent(getCallbackUrl()) + + return ( + + router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), + }, + { + label: 'Create an account', + onClick: () => + router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`), + variant: 'outline' as const, + }, + { + label: 'Return to Home', + onClick: () => router.push('/'), + variant: 'ghost' as const, + }, + ]} + /> + + ) + } + + // Logged in - show invitation + return ( + + + You've been invited to join {invitation?.credentialSetName} by{' '} + {invitation?.organizationName}. + {invitation?.providerId && ( + <> You'll be asked to connect your {providerWithIcon} account after accepting. + )} + + } + icon='mail' + actions={[ + { + label: `Accept & Connect ${providerName}`, + onClick: handleAccept, + disabled: accepting, + loading: accepting, + }, + { + label: 'Return to Home', + onClick: () => router.push('/'), + variant: 'ghost' as const, + }, + ]} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index d902200dd2..4a4f112e23 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { ExternalLink } from 'lucide-react' +import { ExternalLink, Users } from 'lucide-react' import { Button, Combobox } from '@/components/emcn/components' +import { getSubscriptionStatus } from '@/lib/billing/client' +import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' import { getCanonicalScopesForProvider, getProviderIdFromServiceId, @@ -15,7 +17,11 @@ import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId] import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' +import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants' +import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' +import { useOrganizations } from '@/hooks/queries/organization' +import { useSubscriptionData } from '@/hooks/queries/subscription' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -45,6 +51,19 @@ export function CredentialSelector({ const requiredScopes = subBlock.requiredScopes || [] const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId || '' + const supportsCredentialSets = subBlock.supportsCredentialSets || false + + const { data: organizationsData } = useOrganizations() + const { data: subscriptionData } = useSubscriptionData() + const activeOrganization = organizationsData?.activeOrganization + const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data) + const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise + const canUseCredentialSets = supportsCredentialSets && hasTeamPlan && !!activeOrganization?.id + + const { data: credentialSets = [] } = useCredentialSets( + activeOrganization?.id, + canUseCredentialSets + ) const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, isPreview }) const hasDependencies = dependsOn.length > 0 @@ -52,7 +71,12 @@ export function CredentialSelector({ const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied) const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue - const selectedId = typeof effectiveValue === 'string' ? effectiveValue : '' + const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : '' + const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET.PREFIX) + const selectedId = isCredentialSetSelected ? '' : rawSelectedId + const selectedCredentialSetId = isCredentialSetSelected + ? rawSelectedId.slice(CREDENTIAL_SET.PREFIX.length) + : '' const effectiveProviderId = useMemo( () => getProviderIdFromServiceId(serviceId) as OAuthProvider, @@ -87,11 +111,20 @@ export function CredentialSelector({ const hasForeignMeta = foreignCredentials.length > 0 const isForeign = Boolean(selectedId && !selectedCredential && hasForeignMeta) + const selectedCredentialSet = useMemo( + () => credentialSets.find((cs) => cs.id === selectedCredentialSetId), + [credentialSets, selectedCredentialSetId] + ) + + const isForeignCredentialSet = Boolean(isCredentialSetSelected && !selectedCredentialSet) + const resolvedLabel = useMemo(() => { + if (selectedCredentialSet) return selectedCredentialSet.name + if (isForeignCredentialSet) return CREDENTIAL.FOREIGN_LABEL if (selectedCredential) return selectedCredential.name - if (isForeign) return 'Saved by collaborator' + if (isForeign) return CREDENTIAL.FOREIGN_LABEL return '' - }, [selectedCredential, isForeign]) + }, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign]) useEffect(() => { if (!isEditing) { @@ -148,6 +181,15 @@ export function CredentialSelector({ [isPreview, setStoreValue] ) + const handleCredentialSetSelect = useCallback( + (credentialSetId: string) => { + if (isPreview) return + setStoreValue(`${CREDENTIAL_SET.PREFIX}${credentialSetId}`) + setIsEditing(false) + }, + [isPreview, setStoreValue] + ) + const handleAddCredential = useCallback(() => { setShowOAuthModal(true) }, []) @@ -176,7 +218,56 @@ export function CredentialSelector({ .join(' ') }, []) - const comboboxOptions = useMemo(() => { + const { comboboxOptions, comboboxGroups } = useMemo(() => { + const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId) + // Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility + const matchesProvider = (csProviderId: string | null) => { + if (!csProviderId || !pollingProviderId) return false + if (csProviderId === pollingProviderId) return true + // Handle legacy 'gmail' mapping to 'google-email' + if (pollingProviderId === 'google-email' && csProviderId === 'gmail') return true + return false + } + const filteredCredentialSets = pollingProviderId + ? credentialSets.filter((cs) => matchesProvider(cs.providerId)) + : [] + + if (canUseCredentialSets && filteredCredentialSets.length > 0) { + const groups = [] + + groups.push({ + section: 'Polling Groups', + items: filteredCredentialSets.map((cs) => ({ + label: cs.name, + value: `${CREDENTIAL_SET.PREFIX}${cs.id}`, + })), + }) + + const credentialItems = credentials.map((cred) => ({ + label: cred.name, + value: cred.id, + })) + + if (credentialItems.length > 0) { + groups.push({ + section: 'Personal Credential', + items: credentialItems, + }) + } else { + groups.push({ + section: 'Personal Credential', + items: [ + { + label: `Connect ${getProviderName(provider)} account`, + value: '__connect_account__', + }, + ], + }) + } + + return { comboboxOptions: [], comboboxGroups: groups } + } + const options = credentials.map((cred) => ({ label: cred.name, value: cred.id, @@ -189,14 +280,32 @@ export function CredentialSelector({ }) } - return options - }, [credentials, provider, getProviderName]) + return { comboboxOptions: options, comboboxGroups: undefined } + }, [ + credentials, + provider, + effectiveProviderId, + getProviderName, + canUseCredentialSets, + credentialSets, + ]) const selectedCredentialProvider = selectedCredential?.provider ?? provider const overlayContent = useMemo(() => { if (!inputValue) return null + if (isCredentialSetSelected && selectedCredentialSet) { + return ( +
+
+ +
+ {inputValue} +
+ ) + } + return (
@@ -205,7 +314,13 @@ export function CredentialSelector({ {inputValue}
) - }, [getProviderIcon, inputValue, selectedCredentialProvider]) + }, [ + getProviderIcon, + inputValue, + selectedCredentialProvider, + isCredentialSetSelected, + selectedCredentialSet, + ]) const handleComboboxChange = useCallback( (value: string) => { @@ -214,6 +329,16 @@ export function CredentialSelector({ return } + if (value.startsWith(CREDENTIAL_SET.PREFIX)) { + const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length) + const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId) + if (matchedSet) { + setInputValue(matchedSet.name) + handleCredentialSetSelect(credentialSetId) + return + } + } + const matchedCred = credentials.find((c) => c.id === value) if (matchedCred) { setInputValue(matchedCred.name) @@ -224,15 +349,16 @@ export function CredentialSelector({ setIsEditing(true) setInputValue(value) }, - [credentials, handleAddCredential, handleSelect] + [credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect] ) return (
{needsUpdate && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx index 761935d7d3..d202efb775 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tool-credential-selector.tsx @@ -10,6 +10,7 @@ import { parseProvider, } from '@/lib/oauth' import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' +import { CREDENTIAL } from '@/executor/constants' import { useOAuthCredentialDetail, useOAuthCredentials } from '@/hooks/queries/oauth-credentials' import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -95,7 +96,7 @@ export function ToolCredentialSelector({ const resolvedLabel = useMemo(() => { if (selectedCredential) return selectedCredential.name - if (isForeign) return 'Saved by collaborator' + if (isForeign) return CREDENTIAL.FOREIGN_LABEL return '' }, [selectedCredential, isForeign]) @@ -210,7 +211,7 @@ export function ToolCredentialSelector({ placeholder={label} disabled={disabled} editable={true} - filterOptions={true} + filterOptions={!isForeign} isLoading={credentialsLoading} overlayContent={overlayContent} className={selectedId ? 'pl-[28px]' : ''} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx new file mode 100644 index 0000000000..5fadc9e7bd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credential-sets/credential-sets.tsx @@ -0,0 +1,1170 @@ +'use client' + +import { type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { Paperclip, Plus, Search, X } from 'lucide-react' +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Button, + Input, + Label, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from '@/components/emcn' +import { GmailIcon, OutlookIcon } from '@/components/icons' +import { Input as BaseInput, Skeleton } from '@/components/ui' +import { useSession } from '@/lib/auth/auth-client' +import { getSubscriptionStatus } from '@/lib/billing/client' +import { cn } from '@/lib/core/utils/cn' +import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers' +import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { getUserRole } from '@/lib/workspaces/organization' +import { EmailTag } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components' +import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color' +import { + type CredentialSet, + useAcceptCredentialSetInvitation, + useCancelCredentialSetInvitation, + useCreateCredentialSet, + useCreateCredentialSetInvitation, + useCredentialSetInvitations, + useCredentialSetInvitationsDetail, + useCredentialSetMembers, + useCredentialSetMemberships, + useCredentialSets, + useDeleteCredentialSet, + useLeaveCredentialSet, + useRemoveCredentialSetMember, + useResendCredentialSetInvitation, +} from '@/hooks/queries/credential-sets' +import { useOrganizations } from '@/hooks/queries/organization' +import { useSubscriptionData } from '@/hooks/queries/subscription' + +const logger = createLogger('EmailPolling') + +function CredentialSetsSkeleton() { + return ( +
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ ) +} + +export function CredentialSets() { + const { data: session } = useSession() + const { data: organizationsData } = useOrganizations() + const { data: subscriptionData } = useSubscriptionData() + + const activeOrganization = organizationsData?.activeOrganization + const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data) + const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise + const userRole = getUserRole(activeOrganization, session?.user?.email) + const isAdmin = userRole === 'admin' || userRole === 'owner' + const canManageCredentialSets = hasTeamPlan && isAdmin && !!activeOrganization?.id + + const { data: memberships = [], isPending: membershipsLoading } = useCredentialSetMemberships() + const { data: invitations = [], isPending: invitationsLoading } = useCredentialSetInvitations() + const { data: ownedSets = [], isPending: ownedSetsLoading } = useCredentialSets( + activeOrganization?.id, + canManageCredentialSets + ) + + const acceptInvitation = useAcceptCredentialSetInvitation() + const createCredentialSet = useCreateCredentialSet() + const createInvitation = useCreateCredentialSetInvitation() + + const [searchTerm, setSearchTerm] = useState('') + const [showCreateModal, setShowCreateModal] = useState(false) + const [viewingSet, setViewingSet] = useState(null) + const [newSetName, setNewSetName] = useState('') + const [newSetDescription, setNewSetDescription] = useState('') + const [newSetProvider, setNewSetProvider] = useState('google-email') + const [createError, setCreateError] = useState(null) + const [emails, setEmails] = useState([]) + const [invalidEmails, setInvalidEmails] = useState([]) + const [duplicateEmails, setDuplicateEmails] = useState([]) + const [inputValue, setInputValue] = useState('') + const [isDragging, setIsDragging] = useState(false) + const fileInputRef = useRef(null) + const [leavingMembership, setLeavingMembership] = useState<{ + credentialSetId: string + name: string + } | null>(null) + + const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id) + const { data: pendingInvitations = [], isPending: pendingInvitationsLoading } = + useCredentialSetInvitationsDetail(viewingSet?.id) + const removeMember = useRemoveCredentialSetMember() + const leaveCredentialSet = useLeaveCredentialSet() + const deleteCredentialSet = useDeleteCredentialSet() + const cancelInvitation = useCancelCredentialSetInvitation() + const resendInvitation = useResendCredentialSetInvitation() + + const [deletingSet, setDeletingSet] = useState<{ id: string; name: string } | null>(null) + const [deletingSetIds, setDeletingSetIds] = useState>(new Set()) + const [cancellingInvitations, setCancellingInvitations] = useState>(new Set()) + const [resendingInvitations, setResendingInvitations] = useState>(new Set()) + const [resendCooldowns, setResendCooldowns] = useState>({}) + + const extractEmailsFromText = useCallback((text: string): string[] => { + const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g + const matches = text.match(emailRegex) || [] + return [...new Set(matches.map((e) => e.toLowerCase()))] + }, []) + + const addEmail = useCallback( + (email: string) => { + if (!email.trim()) return false + + const normalized = email.trim().toLowerCase() + const validation = quickValidateEmail(normalized) + const isValid = validation.isValid + + if ( + emails.includes(normalized) || + invalidEmails.includes(normalized) || + duplicateEmails.includes(normalized) + ) { + return false + } + + const isPendingInvitation = pendingInvitations.some( + (inv) => inv.email?.toLowerCase() === normalized + ) + if (isPendingInvitation) { + setDuplicateEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) + setInputValue('') + return false + } + + const isActiveMember = members.some( + (m) => m.userEmail?.toLowerCase() === normalized && m.status === 'active' + ) + if (isActiveMember) { + setDuplicateEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) + setInputValue('') + return false + } + + if (!isValid) { + setInvalidEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) + setInputValue('') + return false + } + + setEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) + setInputValue('') + return true + }, + [emails, invalidEmails, duplicateEmails, pendingInvitations, members] + ) + + const removeEmail = useCallback((index: number) => { + setEmails((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const removeInvalidEmail = useCallback((index: number) => { + setInvalidEmails((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const removeDuplicateEmail = useCallback((index: number) => { + setDuplicateEmails((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const handleEmailKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + if (inputValue.trim()) { + addEmail(inputValue) + } + return + } + + if ([',', ' '].includes(e.key) && inputValue.trim()) { + e.preventDefault() + addEmail(inputValue) + } + + if (e.key === 'Backspace' && !inputValue) { + if (duplicateEmails.length > 0) { + removeDuplicateEmail(duplicateEmails.length - 1) + } else if (invalidEmails.length > 0) { + removeInvalidEmail(invalidEmails.length - 1) + } else if (emails.length > 0) { + removeEmail(emails.length - 1) + } + } + }, + [ + inputValue, + addEmail, + duplicateEmails, + invalidEmails, + emails, + removeDuplicateEmail, + removeInvalidEmail, + removeEmail, + ] + ) + + const handleEmailPaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault() + const pastedText = e.clipboardData.getData('text') + const pastedEmails = extractEmailsFromText(pastedText) + + pastedEmails.forEach((email) => { + addEmail(email) + }) + }, + [addEmail, extractEmailsFromText] + ) + + const handleFileDrop = useCallback( + async (file: File) => { + try { + const text = await file.text() + const extractedEmails = extractEmailsFromText(text) + extractedEmails.forEach((email) => { + addEmail(email) + }) + } catch (error) { + logger.error('Error reading dropped file', error) + } + }, + [extractEmailsFromText, addEmail] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = Array.from(e.dataTransfer.files) + const validFiles = files.filter( + (f) => + f.type === 'text/csv' || + f.type === 'text/plain' || + f.name.endsWith('.csv') || + f.name.endsWith('.txt') + ) + + for (const file of validFiles) { + await handleFileDrop(file) + } + }, + [handleFileDrop] + ) + + const handleFileInputChange = useCallback( + async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files) return + + for (const file of Array.from(files)) { + await handleFileDrop(file) + } + + // Reset input so the same file can be selected again + e.target.value = '' + }, + [handleFileDrop] + ) + + const handleRemoveMember = useCallback( + async (memberId: string) => { + if (!viewingSet) return + try { + await removeMember.mutateAsync({ + credentialSetId: viewingSet.id, + memberId, + }) + } catch (error) { + logger.error('Failed to remove member', error) + } + }, + [viewingSet, removeMember] + ) + + const handleLeave = useCallback((credentialSetId: string, name: string) => { + setLeavingMembership({ credentialSetId, name }) + }, []) + + const confirmLeave = useCallback(async () => { + if (!leavingMembership) return + try { + await leaveCredentialSet.mutateAsync(leavingMembership.credentialSetId) + setLeavingMembership(null) + } catch (error) { + logger.error('Failed to leave polling group', error) + } + }, [leavingMembership, leaveCredentialSet]) + + const handleAcceptInvitation = useCallback( + async (token: string) => { + try { + await acceptInvitation.mutateAsync(token) + } catch (error) { + logger.error('Failed to accept invitation', error) + } + }, + [acceptInvitation] + ) + + const handleCreateCredentialSet = useCallback(async () => { + if (!newSetName.trim() || !activeOrganization?.id) return + setCreateError(null) + try { + const result = await createCredentialSet.mutateAsync({ + organizationId: activeOrganization.id, + name: newSetName.trim(), + description: newSetDescription.trim() || undefined, + providerId: newSetProvider, + }) + setShowCreateModal(false) + setNewSetName('') + setNewSetDescription('') + setNewSetProvider('google-email') + + // Open detail view for the newly created group + if (result?.credentialSet) { + setViewingSet(result.credentialSet) + } + } catch (error) { + logger.error('Failed to create polling group', error) + if (error instanceof Error) { + setCreateError(error.message) + } else { + setCreateError('Failed to create polling group') + } + } + }, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet]) + + const handleInviteMembers = useCallback(async () => { + if (!viewingSet?.id) return + + // Add any pending input value first + if (inputValue.trim()) { + addEmail(inputValue) + } + + if (emails.length === 0) return + + try { + for (const email of emails) { + await createInvitation.mutateAsync({ + credentialSetId: viewingSet.id, + email, + }) + } + setEmails([]) + setInvalidEmails([]) + setDuplicateEmails([]) + setInputValue('') + } catch (error) { + logger.error('Failed to create invitations', error) + } + }, [viewingSet?.id, emails, inputValue, addEmail, createInvitation]) + + const handleCloseCreateModal = useCallback(() => { + setShowCreateModal(false) + setNewSetName('') + setNewSetDescription('') + setNewSetProvider('google-email') + setCreateError(null) + }, []) + + const handleBackToList = useCallback(() => { + setViewingSet(null) + setEmails([]) + setInvalidEmails([]) + setDuplicateEmails([]) + setInputValue('') + }, []) + + const handleCancelInvitation = useCallback( + async (invitationId: string) => { + if (!viewingSet?.id) return + + setCancellingInvitations((prev) => new Set([...prev, invitationId])) + try { + await cancelInvitation.mutateAsync({ + credentialSetId: viewingSet.id, + invitationId, + }) + } catch (error) { + logger.error('Failed to cancel invitation', error) + } finally { + setCancellingInvitations((prev) => { + const next = new Set(prev) + next.delete(invitationId) + return next + }) + } + }, + [viewingSet?.id, cancelInvitation] + ) + + const handleResendInvitation = useCallback( + async (invitationId: string, email: string) => { + if (!viewingSet?.id) return + + const secondsLeft = resendCooldowns[invitationId] + if (secondsLeft && secondsLeft > 0) return + + setResendingInvitations((prev) => new Set([...prev, invitationId])) + try { + await resendInvitation.mutateAsync({ + credentialSetId: viewingSet.id, + invitationId, + email, + }) + + // Start 60s cooldown + setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) + const interval = setInterval(() => { + setResendCooldowns((prev) => { + const current = prev[invitationId] + if (current === undefined) return prev + if (current <= 1) { + const next = { ...prev } + delete next[invitationId] + clearInterval(interval) + return next + } + return { ...prev, [invitationId]: current - 1 } + }) + }, 1000) + } catch (error) { + logger.error('Failed to resend invitation', error) + } finally { + setResendingInvitations((prev) => { + const next = new Set(prev) + next.delete(invitationId) + return next + }) + } + }, + [viewingSet?.id, resendInvitation, resendCooldowns] + ) + + const handleDeleteClick = useCallback((set: CredentialSet) => { + setDeletingSet({ id: set.id, name: set.name }) + }, []) + + const confirmDelete = useCallback(async () => { + if (!deletingSet || !activeOrganization?.id) return + setDeletingSetIds((prev) => new Set(prev).add(deletingSet.id)) + try { + await deleteCredentialSet.mutateAsync({ + credentialSetId: deletingSet.id, + organizationId: activeOrganization.id, + }) + setDeletingSet(null) + } catch (error) { + logger.error('Failed to delete polling group', error) + } finally { + setDeletingSetIds((prev) => { + const next = new Set(prev) + next.delete(deletingSet.id) + return next + }) + } + }, [deletingSet, activeOrganization?.id, deleteCredentialSet]) + + const getProviderIcon = (providerId: string | null) => { + if (providerId === 'outlook') return + return + } + + // All hooks must be called before any early returns + const activeMemberships = useMemo( + () => memberships.filter((m) => m.status === 'active'), + [memberships] + ) + + const filteredInvitations = useMemo(() => { + if (!searchTerm.trim()) return invitations + const searchLower = searchTerm.toLowerCase() + return invitations.filter( + (inv) => + inv.credentialSetName.toLowerCase().includes(searchLower) || + inv.organizationName.toLowerCase().includes(searchLower) + ) + }, [invitations, searchTerm]) + + const filteredMemberships = useMemo(() => { + if (!searchTerm.trim()) return activeMemberships + const searchLower = searchTerm.toLowerCase() + return activeMemberships.filter( + (m) => + m.credentialSetName.toLowerCase().includes(searchLower) || + m.organizationName.toLowerCase().includes(searchLower) + ) + }, [activeMemberships, searchTerm]) + + const filteredOwnedSets = useMemo(() => { + if (!searchTerm.trim()) return ownedSets + const searchLower = searchTerm.toLowerCase() + return ownedSets.filter((set) => set.name.toLowerCase().includes(searchLower)) + }, [ownedSets, searchTerm]) + + const hasNoContent = + invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0 + const hasNoResults = + searchTerm.trim() && + filteredInvitations.length === 0 && + filteredMemberships.length === 0 && + filteredOwnedSets.length === 0 && + !hasNoContent + + // Early returns AFTER all hooks + if (membershipsLoading || invitationsLoading) { + return + } + + // Detail view for a polling group + if (viewingSet) { + const activeMembers = members.filter((m) => m.status === 'active') + const totalCount = activeMembers.length + pendingInvitations.length + + return ( + <> +
+
+
+ {/* Group Info */} +
+
+ + Group Name + + + {viewingSet.name} + +
+
+
+ + Provider + +
+ {getProviderIcon(viewingSet.providerId)} + + {getProviderDisplayName(viewingSet.providerId as PollingProvider)} + +
+
+
+ + {/* Invite Section - Email Tags Input */} +
+ +
+ {isDragging && ( +
+ + Drop file here + +
+ )} + {invalidEmails.map((email, index) => ( + removeInvalidEmail(index)} + disabled={createInvitation.isPending} + isInvalid={true} + /> + ))} + {duplicateEmails.map((email, index) => ( +
+ {email} + duplicate + {!createInvitation.isPending && ( + + )} +
+ ))} + {emails.map((email, index) => ( + removeEmail(index)} + disabled={createInvitation.isPending} + /> + ))} +
+ setInputValue(e.target.value)} + onKeyDown={handleEmailKeyDown} + onPaste={handleEmailPaste} + onBlur={() => inputValue.trim() && addEmail(inputValue)} + placeholder={ + emails.length > 0 || invalidEmails.length > 0 || duplicateEmails.length > 0 + ? 'Add another email' + : 'Enter email addresses' + } + className='h-6 min-w-[140px] flex-1 border-none bg-transparent p-0 pl-[4px] text-[13px] outline-none placeholder:text-[var(--text-tertiary)]' + disabled={createInvitation.isPending} + /> + +
+
+ +
+ + {/* Members List - styled like team members */} +
+

Members

+ + {membersLoading || pendingInvitationsLoading ? ( +
+ {[1, 2].map((i) => ( +
+
+ +
+ + +
+
+
+ ))} +
+ ) : totalCount === 0 ? ( +

+ No members yet. Send invitations above. +

+ ) : ( +
+ {/* Active Members */} + {activeMembers.map((member) => { + const name = member.userName || 'Unknown' + const avatarInitial = name.charAt(0).toUpperCase() + + return ( +
+
+ + {member.userImage && ( + + )} + + {avatarInitial} + + + +
+
+ + {name} + + {member.credentials.length === 0 && ( + + Disconnected + + )} +
+
+ {member.userEmail} +
+
+
+ +
+ +
+
+ ) + })} + + {/* Pending Invitations */} + {pendingInvitations.map((invitation) => { + const email = invitation.email || 'Unknown' + const emailPrefix = email.split('@')[0] + const avatarInitial = emailPrefix.charAt(0).toUpperCase() + + return ( +
+
+ + + {avatarInitial} + + + +
+
+ + {emailPrefix} + + + Pending + +
+
+ {email} +
+
+
+ +
+ + +
+
+ ) + })} +
+ )} +
+
+
+ + {/* Footer Actions */} +
+ +
+
+ + ) + } + + return ( + <> +
+
+
+ + setSearchTerm(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+ {canManageCredentialSets && ( + + )} +
+ +
+ {hasNoContent && !canManageCredentialSets ? ( +
+ You're not a member of any polling groups yet. When someone invites you, it will + appear here. +
+ ) : hasNoResults ? ( +
+ No results found matching "{searchTerm}" +
+ ) : ( +
+ {filteredInvitations.length > 0 && ( +
+
+ Pending Invitations +
+ {filteredInvitations.map((invitation) => ( +
+
+
+ {getProviderIcon(invitation.providerId)} +
+
+ + {invitation.credentialSetName} + + + {invitation.organizationName} + +
+
+ +
+ ))} +
+ )} + + {filteredMemberships.length > 0 && ( +
+
+ My Memberships +
+ {filteredMemberships.map((membership) => ( +
+
+
+ {getProviderIcon(membership.providerId)} +
+
+ + {membership.credentialSetName} + + + {membership.organizationName} + +
+
+ +
+ ))} +
+ )} + + {canManageCredentialSets && + (filteredOwnedSets.length > 0 || + ownedSetsLoading || + (!searchTerm.trim() && ownedSets.length === 0)) && ( +
+
+ Manage +
+ {ownedSetsLoading ? ( + <> + {[1, 2].map((i) => ( +
+
+ +
+ + +
+
+
+ ))} + + ) : !searchTerm.trim() && ownedSets.length === 0 ? ( +
+ No polling groups created yet +
+ ) : ( + filteredOwnedSets.map((set) => ( +
+
+
+ {getProviderIcon(set.providerId)} +
+
+ {set.name} + + {set.memberCount} member{set.memberCount !== 1 ? 's' : ''} + +
+
+
+ + +
+
+ )) + )} +
+ )} +
+ )} +
+
+ + {/* Create Polling Group Modal */} + + + Create Polling Group + +
+
+ + { + setNewSetName(e.target.value) + if (createError) setCreateError(null) + }} + placeholder='e.g., Marketing Team' + /> +
+
+ + setNewSetDescription(e.target.value)} + placeholder='e.g., Poll emails for marketing automations' + /> +
+
+ +
+ + +
+

+ Members will connect their {getProviderDisplayName(newSetProvider)} account +

+
+ {createError &&

{createError}

} +
+
+ + + + +
+
+ + {/* Leave Confirmation Modal */} + setLeavingMembership(null)}> + + Leave Polling Group + +

+ Are you sure you want to leave{' '} + + {leavingMembership?.name} + + ? Your email account will no longer be polled in workflows using this group. +

+
+ + + + +
+
+ + {/* Delete Confirmation Modal */} + setDeletingSet(null)}> + + Delete Polling Group + +

+ Are you sure you want to delete{' '} + {deletingSet?.name}?{' '} + This action cannot be undone. +

+
+ + + + +
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts index 2c4d03228d..af86138a71 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts @@ -1,6 +1,7 @@ export { ApiKeys } from './api-keys/api-keys' export { BYOK } from './byok/byok' export { Copilot } from './copilot/copilot' +export { CredentialSets } from './credential-sets/credential-sets' export { CustomTools } from './custom-tools/custom-tools' export { EnvironmentVariables } from './environment/environment' export { Files as FileUploads } from './files/files' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx index 6d3edbd64f..e176e813e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx @@ -1,7 +1,7 @@ 'use client' import React, { useMemo, useState } from 'react' -import { CheckCircle, ChevronDown } from 'lucide-react' +import { ChevronDown } from 'lucide-react' import { Button, Checkbox, @@ -302,14 +302,11 @@ export function MemberInvitationCard({ {/* Success message */} {inviteSuccess && ( -
- -

- Invitation sent successfully - {selectedCount > 0 && - ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`} -

-
+

+ Invitation sent successfully + {selectedCount > 0 && + ` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`} +

)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx index d5d1016b59..317258bc1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members/team-members.tsx @@ -5,7 +5,11 @@ import { createLogger } from '@sim/logger' import { Avatar, AvatarFallback, AvatarImage, Badge, Button } from '@/components/emcn' import type { Invitation, Member, Organization } from '@/lib/workspaces/organization' import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color' -import { useCancelInvitation, useOrganizationMembers } from '@/hooks/queries/organization' +import { + useCancelInvitation, + useOrganizationMembers, + useResendInvitation, +} from '@/hooks/queries/organization' const logger = createLogger('TeamMembers') @@ -46,12 +50,16 @@ export function TeamMembers({ onRemoveMember, }: TeamMembersProps) { const [cancellingInvitations, setCancellingInvitations] = useState>(new Set()) + const [resendingInvitations, setResendingInvitations] = useState>(new Set()) + const [resentInvitations, setResentInvitations] = useState>(new Set()) + const [resendCooldowns, setResendCooldowns] = useState>({}) const { data: memberUsageResponse, isLoading: isLoadingUsage } = useOrganizationMembers( organization?.id || '' ) const cancelInvitationMutation = useCancelInvitation() + const resendInvitationMutation = useResendInvitation() const memberUsageData: Record = {} if (memberUsageResponse?.data) { @@ -140,6 +148,54 @@ export function TeamMembers({ } } + const handleResendInvitation = async (invitationId: string) => { + if (!organization?.id) return + + const secondsLeft = resendCooldowns[invitationId] + if (secondsLeft && secondsLeft > 0) return + + setResendingInvitations((prev) => new Set([...prev, invitationId])) + try { + await resendInvitationMutation.mutateAsync({ + invitationId, + orgId: organization.id, + }) + + setResentInvitations((prev) => new Set([...prev, invitationId])) + setTimeout(() => { + setResentInvitations((prev) => { + const next = new Set(prev) + next.delete(invitationId) + return next + }) + }, 4000) + + // Start 60s cooldown + setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) + const interval = setInterval(() => { + setResendCooldowns((prev) => { + const current = prev[invitationId] + if (current === undefined) return prev + if (current <= 1) { + const next = { ...prev } + delete next[invitationId] + clearInterval(interval) + return next + } + return { ...prev, [invitationId]: current - 1 } + }) + }, 1000) + } catch (error) { + logger.error('Failed to resend invitation', { error }) + } finally { + setResendingInvitations((prev) => { + const next = new Set(prev) + next.delete(invitationId) + return next + }) + } + } + return (
{/* Header */} @@ -148,13 +204,13 @@ export function TeamMembers({
{/* Members list */} -
+
{teamItems.map((item) => (
{/* Left section: Avatar + Name/Role + Action buttons */}
{/* Avatar */} - + {item.avatarUrl && } )} {item.type === 'invitation' && ( - + Pending )}
-
{item.email}
+
{item.email}
- {/* Action buttons */} - {isAdminOrOwner && ( - <> - {/* Admin/Owner can remove other members */} - {item.type === 'member' && - item.role !== 'owner' && - item.email !== currentUserEmail && ( - - )} - - {/* Admin can cancel invitations */} - {item.type === 'invitation' && ( + {/* Action buttons for members */} + {isAdminOrOwner && + item.type === 'member' && + item.role !== 'owner' && + item.email !== currentUserEmail && ( + + )} +
+ + {/* Right section */} + {isAdminOrOwner && ( +
+ {item.type === 'member' ? ( + <> +
Usage
+
+ {isLoadingUsage ? ( + + ) : ( + item.usage + )} +
+ + ) : ( +
+ - )} - - )} -
- - {/* Right section: Usage column (right-aligned) */} - {isAdminOrOwner && ( -
-
Usage
-
- {isLoadingUsage && item.type === 'member' ? ( - - ) : ( - item.usage - )} -
+
+ )}
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index b6521f7434..9fcced4690 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' import * as VisuallyHidden from '@radix-ui/react-visually-hidden' import { useQueryClient } from '@tanstack/react-query' -import { Files, KeySquare, LogIn, Server, Settings, User, Users, Wrench } from 'lucide-react' +import { Files, KeySquare, LogIn, Mail, Server, Settings, User, Users, Wrench } from 'lucide-react' import { Card, Connections, @@ -32,6 +32,7 @@ import { ApiKeys, BYOK, Copilot, + CredentialSets, CustomTools, EnvironmentVariables, FileUploads, @@ -63,6 +64,7 @@ type SettingsSection = | 'environment' | 'template-profile' | 'integrations' + | 'credential-sets' | 'apikeys' | 'byok' | 'files' @@ -116,6 +118,13 @@ const allNavigationItems: NavigationItem[] = [ { id: 'integrations', label: 'Integrations', icon: Connections, section: 'tools' }, { id: 'custom-tools', label: 'Custom Tools', icon: Wrench, section: 'tools' }, { id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' }, + { + id: 'credential-sets', + label: 'Email Polling', + icon: Mail, + section: 'system', + requiresTeam: true, + }, { id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' }, { id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' }, { id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' }, @@ -462,6 +471,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { registerCloseHandler={registerIntegrationsCloseHandler} /> )} + {activeSection === 'credential-sets' && } {activeSection === 'apikeys' && } {activeSection === 'files' && } {isBillingEnabled && activeSection === 'subscription' && } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index 62450150f7..a05eedaed4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -2,6 +2,7 @@ import React, { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { Paperclip, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, @@ -40,9 +41,12 @@ interface PendingInvitation { export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) { const formRef = useRef(null) + const fileInputRef = useRef(null) const [inputValue, setInputValue] = useState('') const [emails, setEmails] = useState([]) const [invalidEmails, setInvalidEmails] = useState([]) + const [duplicateEmails, setDuplicateEmails] = useState([]) + const [isDragging, setIsDragging] = useState(false) const [userPermissions, setUserPermissions] = useState([]) const [pendingInvitations, setPendingInvitations] = useState([]) const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false) @@ -134,13 +138,20 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr const validation = quickValidateEmail(normalized) const isValid = validation.isValid - if (emails.includes(normalized) || invalidEmails.includes(normalized)) { + if ( + emails.includes(normalized) || + invalidEmails.includes(normalized) || + duplicateEmails.includes(normalized) + ) { return false } const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized) if (hasPendingInvitation) { - setErrorMessage(`${normalized} already has a pending invitation`) + setDuplicateEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) setInputValue('') return false } @@ -149,7 +160,10 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr (user) => user.email === normalized ) if (isExistingMember) { - setErrorMessage(`${normalized} is already a member of this workspace`) + setDuplicateEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) setInputValue('') return false } @@ -161,13 +175,19 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr } if (!isValid) { - setInvalidEmails((prev) => [...prev, normalized]) + setInvalidEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) setInputValue('') return false } setErrorMessage(null) - setEmails((prev) => [...prev, normalized]) + setEmails((prev) => { + if (prev.includes(normalized)) return prev + return [...prev, normalized] + }) setUserPermissions((prev) => [ ...prev, @@ -180,7 +200,14 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr setInputValue('') return true }, - [emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email] + [ + emails, + invalidEmails, + duplicateEmails, + pendingInvitations, + workspacePermissions?.users, + session?.user?.email, + ] ) const removeEmail = useCallback( @@ -196,6 +223,80 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr setInvalidEmails((prev) => prev.filter((_, i) => i !== index)) }, []) + const removeDuplicateEmail = useCallback((index: number) => { + setDuplicateEmails((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const extractEmailsFromText = useCallback((text: string): string[] => { + const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g + const matches = text.match(emailRegex) || [] + return [...new Set(matches.map((e) => e.toLowerCase()))] + }, []) + + const handleFileDrop = useCallback( + async (file: File) => { + try { + const text = await file.text() + const extractedEmails = extractEmailsFromText(text) + extractedEmails.forEach((email) => { + addEmail(email) + }) + } catch (error) { + logger.error('Error reading dropped file', error) + } + }, + [extractEmailsFromText, addEmail] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + }, []) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + const files = Array.from(e.dataTransfer.files) + const validFiles = files.filter( + (f) => + f.type === 'text/csv' || + f.type === 'text/plain' || + f.name.endsWith('.csv') || + f.name.endsWith('.txt') + ) + + for (const file of validFiles) { + await handleFileDrop(file) + } + }, + [handleFileDrop] + ) + + const handleFileInputChange = useCallback( + async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files) return + + for (const file of Array.from(files)) { + await handleFileDrop(file) + } + + e.target.value = '' + }, + [handleFileDrop] + ) + const handlePermissionChange = useCallback( (identifier: string, permissionType: PermissionType) => { const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier) @@ -204,11 +305,9 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr setExistingUserPermissionChanges((prev) => { const newChanges = { ...prev } - // If the new permission matches the original, remove the change entry if (existingUser.permissionType === permissionType) { delete newChanges[identifier] } else { - // Otherwise, track the change newChanges[identifier] = { permissionType } } @@ -297,7 +396,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr setErrorMessage(null) try { - // Verify the user exists in workspace permissions const userRecord = workspacePermissions?.users?.find( (user) => user.userId === memberToRemove.userId ) @@ -322,7 +420,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr throw new Error(data.error || 'Failed to remove member') } - // Update the workspace permissions to remove the user if (workspacePermissions) { const updatedUsers = workspacePermissions.users.filter( (user) => user.userId !== memberToRemove.userId @@ -333,7 +430,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr }) } - // Clear any pending changes for this user setExistingUserPermissionChanges((prev) => { const updated = { ...prev } delete updated[memberToRemove.userId] @@ -384,7 +480,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr throw new Error(data.error || 'Failed to cancel invitation') } - // Remove the invitation from the pending invitations list setPendingInvitations((prev) => prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId) ) @@ -452,7 +547,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr delete next[invitationId] return next }) - // Start 60s cooldown setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) const interval = setInterval(() => { setResendCooldowns((prev) => { @@ -474,40 +568,52 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) { + if (e.key === 'Enter') { + e.preventDefault() + if (inputValue.trim()) { + addEmail(inputValue) + } + return + } + + if ([',', ' '].includes(e.key) && inputValue.trim()) { e.preventDefault() addEmail(inputValue) } if (e.key === 'Backspace' && !inputValue) { - if (invalidEmails.length > 0) { + if (duplicateEmails.length > 0) { + removeDuplicateEmail(duplicateEmails.length - 1) + } else if (invalidEmails.length > 0) { removeInvalidEmail(invalidEmails.length - 1) } else if (emails.length > 0) { removeEmail(emails.length - 1) } } }, - [inputValue, addEmail, invalidEmails, emails, removeInvalidEmail, removeEmail] + [ + inputValue, + addEmail, + duplicateEmails, + invalidEmails, + emails, + removeDuplicateEmail, + removeInvalidEmail, + removeEmail, + ] ) const handlePaste = useCallback( (e: React.ClipboardEvent) => { e.preventDefault() const pastedText = e.clipboardData.getData('text') - const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean) + const pastedEmails = extractEmailsFromText(pastedText) - let addedCount = 0 pastedEmails.forEach((email) => { - if (addEmail(email)) { - addedCount++ - } + addEmail(email) }) - - if (addedCount === 0 && pastedEmails.length === 1) { - setInputValue(inputValue + pastedEmails[0]) - } }, - [addEmail, inputValue] + [addEmail, extractEmailsFromText] ) const handleSubmit = useCallback( @@ -518,7 +624,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr addEmail(inputValue) } - // Clear messages at start of submission setErrorMessage(null) setSuccessMessage(null) @@ -644,10 +749,11 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr ) const resetState = useCallback(() => { - // Batch state updates using React's automatic batching in React 18+ setInputValue('') setEmails([]) setInvalidEmails([]) + setDuplicateEmails([]) + setIsDragging(false) setUserPermissions([]) setPendingInvitations([]) setIsPendingInvitationsLoading(false) @@ -718,7 +824,29 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr tabIndex={-1} readOnly /> -
+ +
+ {isDragging && ( +
+ + Drop file here + +
+ )} {invalidEmails.map((email, index) => ( ))} + {duplicateEmails.map((email, index) => ( +
+ {email} + duplicate + {!isSubmitting && userPerms.canAdmin && ( + + )} +
+ ))} {emails.map((email, index) => ( ))} - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - onBlur={() => inputValue.trim() && addEmail(inputValue)} - placeholder={ - !userPerms.canAdmin - ? 'Only administrators can invite new members' - : emails.length > 0 || invalidEmails.length > 0 - ? 'Add another email' - : 'Enter emails' - } - className={cn( - 'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0', - emails.length > 0 || invalidEmails.length > 0 ? 'pl-[4px]' : 'pl-[4px]' +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + onBlur={() => inputValue.trim() && addEmail(inputValue)} + placeholder={ + !userPerms.canAdmin + ? 'Only administrators can invite new members' + : emails.length > 0 || + invalidEmails.length > 0 || + duplicateEmails.length > 0 + ? 'Add another email' + : 'Enter emails' + } + className={cn( + 'h-6 min-w-[140px] flex-1 border-none bg-transparent p-0 text-[13px] focus-visible:ring-0 focus-visible:ring-offset-0', + emails.length > 0 || invalidEmails.length > 0 || duplicateEmails.length > 0 + ? 'pl-[4px]' + : 'pl-[4px]' + )} + autoFocus={userPerms.canAdmin} + disabled={isSubmitting || !userPerms.canAdmin} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck={false} + data-lpignore='true' + data-form-type='other' + aria-autocomplete='none' + /> + {userPerms.canAdmin && ( + )} - autoFocus={userPerms.canAdmin} - disabled={isSubmitting || !userPerms.canAdmin} - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck={false} - data-lpignore='true' - data-form-type='other' - aria-autocomplete='none' - /> +
{errorMessage && ( diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 20389689f5..b0447e8a00 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -95,6 +95,7 @@ export type WebhookExecutionPayload = { testMode?: boolean executionTarget?: 'deployed' | 'live' credentialId?: string + credentialAccountUserId?: string } export async function executeWebhookJob(payload: WebhookExecutionPayload) { @@ -241,6 +242,7 @@ async function executeWebhookJobInternal( useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, + credentialAccountUserId: payload.credentialAccountUserId, workflowStateOverride: { blocks, edges, @@ -499,6 +501,7 @@ async function executeWebhookJobInternal( useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, + credentialAccountUserId: payload.credentialAccountUserId, workflowStateOverride: { blocks, edges, @@ -508,7 +511,9 @@ async function executeWebhookJobInternal( }, } - const snapshot = new ExecutionSnapshot(metadata, workflow, input || {}, workflowVariables, []) + const triggerInput = input || {} + + const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, []) const executionResult = await executeWorkflowCore({ snapshot, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index fd2fe4f1ee..28605a440d 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -254,6 +254,8 @@ export interface SubBlockConfig { // OAuth specific properties - serviceId is the canonical identifier for OAuth services serviceId?: string requiredScopes?: string[] + // Whether this credential selector supports credential sets (for trigger blocks) + supportsCredentialSets?: boolean // File selector specific properties mimeType?: string // File upload specific properties diff --git a/apps/sim/components/emails/invitations/index.ts b/apps/sim/components/emails/invitations/index.ts index 2133bad90d..6fa64cdc31 100644 --- a/apps/sim/components/emails/invitations/index.ts +++ b/apps/sim/components/emails/invitations/index.ts @@ -1,3 +1,4 @@ export { BatchInvitationEmail } from './batch-invitation-email' export { InvitationEmail } from './invitation-email' +export { PollingGroupInvitationEmail } from './polling-group-invitation-email' export { WorkspaceInvitationEmail } from './workspace-invitation-email' diff --git a/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx new file mode 100644 index 0000000000..e87436a154 --- /dev/null +++ b/apps/sim/components/emails/invitations/polling-group-invitation-email.tsx @@ -0,0 +1,52 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { getBrandConfig } from '@/lib/branding/branding' + +interface PollingGroupInvitationEmailProps { + inviterName?: string + organizationName?: string + pollingGroupName?: string + provider?: 'google-email' | 'outlook' + inviteLink?: string +} + +export function PollingGroupInvitationEmail({ + inviterName = 'A team member', + organizationName = 'an organization', + pollingGroupName = 'a polling group', + provider = 'google-email', + inviteLink = '', +}: PollingGroupInvitationEmailProps) { + const brand = getBrandConfig() + const providerName = provider === 'google-email' ? 'Gmail' : 'Outlook' + + return ( + + Hello, + + {inviterName} from {organizationName} has invited you to + join the polling group {pollingGroupName} on {brand.name}. + + + + By accepting this invitation, your {providerName} account will be connected to enable email + polling for automated workflows. + + + + Accept Invitation + + + {/* Divider */} +
+ + + This invitation expires in 7 days. If you weren't expecting this email, you can safely + ignore it. + + + ) +} + +export default PollingGroupInvitationEmail diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index bd18eeedca..90522246aa 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -12,6 +12,7 @@ import { CareersConfirmationEmail, CareersSubmissionEmail } from '@/components/e import { BatchInvitationEmail, InvitationEmail, + PollingGroupInvitationEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' import { HelpConfirmationEmail } from '@/components/emails/support' @@ -184,6 +185,24 @@ export async function renderWorkspaceInvitationEmail( ) } +export async function renderPollingGroupInvitationEmail(params: { + inviterName: string + organizationName: string + pollingGroupName: string + provider: 'google-email' | 'outlook' + inviteLink: string +}): Promise { + return await render( + PollingGroupInvitationEmail({ + inviterName: params.inviterName, + organizationName: params.organizationName, + pollingGroupName: params.pollingGroupName, + provider: params.provider, + inviteLink: params.inviteLink, + }) + ) +} + export async function renderPaymentFailedEmail(params: { userName?: string amountDue: number diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index 26f451270b..bf8b9197b5 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -8,6 +8,7 @@ export type EmailSubjectType = | 'reset-password' | 'invitation' | 'batch-invitation' + | 'polling-group-invitation' | 'help-confirmation' | 'enterprise-subscription' | 'usage-threshold' @@ -38,6 +39,8 @@ export function getEmailSubject(type: EmailSubjectType): string { return `You've been invited to join a team on ${brandName}` case 'batch-invitation': return `You've been invited to join a team and workspaces on ${brandName}` + case 'polling-group-invitation': + return `You've been invited to join an email polling group on ${brandName}` case 'help-confirmation': return 'Your request has been received' case 'enterprise-subscription': diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index ca6be4c3ec..f483bbfc78 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -181,6 +181,22 @@ export const MCP = { TOOL_PREFIX: 'mcp-', } as const +export const CREDENTIAL_SET = { + PREFIX: 'credentialSet:', +} as const + +export const CREDENTIAL = { + FOREIGN_LABEL: 'Saved by collaborator', +} as const + +export function isCredentialSetValue(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX) +} + +export function extractCredentialSetId(value: string): string { + return value.slice(CREDENTIAL_SET.PREFIX.length) +} + export const MEMORY = { DEFAULT_SLIDING_WINDOW_SIZE: 10, DEFAULT_SLIDING_WINDOW_TOKENS: 4000, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index e4a6a53283..38d403f042 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -17,6 +17,7 @@ export interface ExecutionMetadata { isClientSession?: boolean pendingBlocks?: string[] resumeFromSnapshot?: boolean + credentialAccountUserId?: string workflowStateOverride?: { blocks: Record edges: Edge[] diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index f33b49195f..9c266e8b40 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -124,6 +124,7 @@ export interface ExecutionMetadata { isDebugSession?: boolean context?: ExecutionContext workflowConnections?: Array<{ source: string; target: string }> + credentialAccountUserId?: string status?: 'running' | 'paused' | 'completed' pausePoints?: string[] resumeChain?: { diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts new file mode 100644 index 0000000000..33da082f75 --- /dev/null +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -0,0 +1,370 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { fetchJson } from '@/hooks/selectors/helpers' + +export interface CredentialSet { + id: string + name: string + description: string | null + providerId: string | null + createdBy: string + createdAt: string + updatedAt: string + creatorName: string | null + creatorEmail: string | null + memberCount: number +} + +export interface CredentialSetMembership { + membershipId: string + status: string + joinedAt: string | null + credentialSetId: string + credentialSetName: string + credentialSetDescription: string | null + providerId: string | null + organizationId: string + organizationName: string +} + +export interface CredentialSetInvitation { + invitationId: string + token: string + status: string + expiresAt: string + createdAt: string + credentialSetId: string + credentialSetName: string + providerId: string | null + organizationId: string + organizationName: string + invitedByName: string | null + invitedByEmail: string | null +} + +interface CredentialSetsResponse { + credentialSets?: CredentialSet[] +} + +interface MembershipsResponse { + memberships?: CredentialSetMembership[] +} + +interface InvitationsResponse { + invitations?: CredentialSetInvitation[] +} + +export const credentialSetKeys = { + all: ['credentialSets'] as const, + list: (organizationId?: string) => ['credentialSets', 'list', organizationId ?? 'none'] as const, + detail: (id?: string) => ['credentialSets', 'detail', id ?? 'none'] as const, + memberships: () => ['credentialSets', 'memberships'] as const, + invitations: () => ['credentialSets', 'invitations'] as const, +} + +export async function fetchCredentialSets(organizationId: string): Promise { + if (!organizationId) return [] + const data = await fetchJson('/api/credential-sets', { + searchParams: { organizationId }, + }) + return data.credentialSets ?? [] +} + +export function useCredentialSets(organizationId?: string, enabled = true) { + return useQuery({ + queryKey: credentialSetKeys.list(organizationId), + queryFn: () => fetchCredentialSets(organizationId ?? ''), + enabled: Boolean(organizationId) && enabled, + staleTime: 60 * 1000, + }) +} + +interface CredentialSetDetailResponse { + credentialSet?: CredentialSet +} + +export async function fetchCredentialSetById(id: string): Promise { + if (!id) return null + const data = await fetchJson(`/api/credential-sets/${id}`) + return data.credentialSet ?? null +} + +export function useCredentialSetDetail(id?: string, enabled = true) { + return useQuery({ + queryKey: credentialSetKeys.detail(id), + queryFn: () => fetchCredentialSetById(id ?? ''), + enabled: Boolean(id) && enabled, + staleTime: 60 * 1000, + }) +} + +export function useCredentialSetMemberships() { + return useQuery({ + queryKey: credentialSetKeys.memberships(), + queryFn: async () => { + const data = await fetchJson('/api/credential-sets/memberships') + return data.memberships ?? [] + }, + staleTime: 60 * 1000, + }) +} + +export function useCredentialSetInvitations() { + return useQuery({ + queryKey: credentialSetKeys.invitations(), + queryFn: async () => { + const data = await fetchJson('/api/credential-sets/invitations') + return data.invitations ?? [] + }, + staleTime: 30 * 1000, + }) +} + +export function useAcceptCredentialSetInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (token: string) => { + const response = await fetch(`/api/credential-sets/invite/${token}`, { + method: 'POST', + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || 'Failed to accept invitation') + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) + queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() }) + }, + }) +} + +export interface CreateCredentialSetData { + organizationId: string + name: string + description?: string + providerId?: string +} + +export function useCreateCredentialSet() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: CreateCredentialSetData) => { + const response = await fetch('/api/credential-sets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to create credential set') + } + return response.json() + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: credentialSetKeys.list(variables.organizationId) }) + }, + }) +} + +export function useCreateCredentialSetInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { credentialSetId: string; email?: string }) => { + const response = await fetch(`/api/credential-sets/${data.credentialSetId}/invite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: data.email }), + }) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to create invitation') + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: credentialSetKeys.all }) + }, + }) +} + +export interface CredentialSetMember { + id: string + userId: string + status: string + joinedAt: string | null + createdAt: string + userName: string | null + userEmail: string | null + userImage: string | null + credentials: { providerId: string; accountId: string }[] +} + +interface MembersResponse { + members?: CredentialSetMember[] +} + +export function useCredentialSetMembers(credentialSetId?: string) { + return useQuery({ + queryKey: [...credentialSetKeys.detail(credentialSetId), 'members'], + queryFn: async () => { + const data = await fetchJson( + `/api/credential-sets/${credentialSetId}/members` + ) + return data.members ?? [] + }, + enabled: Boolean(credentialSetId), + staleTime: 30 * 1000, + }) +} + +export function useRemoveCredentialSetMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { credentialSetId: string; memberId: string }) => { + const response = await fetch( + `/api/credential-sets/${data.credentialSetId}/members?memberId=${data.memberId}`, + { method: 'DELETE' } + ) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to remove member') + } + return response.json() + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'members'], + }) + queryClient.invalidateQueries({ queryKey: credentialSetKeys.all }) + }, + }) +} + +export function useLeaveCredentialSet() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (credentialSetId: string) => { + const response = await fetch( + `/api/credential-sets/memberships?credentialSetId=${credentialSetId}`, + { method: 'DELETE' } + ) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || 'Failed to leave credential set') + } + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) + }, + }) +} + +export interface DeleteCredentialSetParams { + credentialSetId: string + organizationId: string +} + +export function useDeleteCredentialSet() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ credentialSetId }: DeleteCredentialSetParams) => { + const response = await fetch(`/api/credential-sets/${credentialSetId}`, { + method: 'DELETE', + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || 'Failed to delete credential set') + } + return response.json() + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: credentialSetKeys.list(variables.organizationId), + }) + queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) + }, + }) +} + +export interface CredentialSetInvitationDetail { + id: string + credentialSetId: string + email: string | null + token: string + status: string + expiresAt: string + createdAt: string + invitedBy: string +} + +interface InvitationsDetailResponse { + invitations?: CredentialSetInvitationDetail[] +} + +export function useCredentialSetInvitationsDetail(credentialSetId?: string) { + return useQuery({ + queryKey: [...credentialSetKeys.detail(credentialSetId), 'invitations'], + queryFn: async () => { + const data = await fetchJson( + `/api/credential-sets/${credentialSetId}/invite` + ) + return (data.invitations ?? []).filter((inv) => inv.status === 'pending') + }, + enabled: Boolean(credentialSetId), + staleTime: 30 * 1000, + }) +} + +export function useCancelCredentialSetInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { credentialSetId: string; invitationId: string }) => { + const response = await fetch( + `/api/credential-sets/${data.credentialSetId}/invite?invitationId=${data.invitationId}`, + { method: 'DELETE' } + ) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to cancel invitation') + } + return response.json() + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'], + }) + }, + }) +} + +export function useResendCredentialSetInvitation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { credentialSetId: string; invitationId: string; email: string }) => { + const response = await fetch( + `/api/credential-sets/${data.credentialSetId}/invite/${data.invitationId}`, + { method: 'POST' } + ) + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to resend invitation') + } + return response.json() + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: [...credentialSetKeys.detail(variables.credentialSetId), 'invitations'], + }) + }, + }) +} diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts index f692321653..414fae2d9c 100644 --- a/apps/sim/hooks/queries/oauth-credentials.ts +++ b/apps/sim/hooks/queries/oauth-credentials.ts @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query' import type { Credential } from '@/lib/oauth' +import { CREDENTIAL, CREDENTIAL_SET } from '@/executor/constants' +import { useCredentialSetDetail } from '@/hooks/queries/credential-sets' import { fetchJson } from '@/hooks/selectors/helpers' interface CredentialListResponse { @@ -61,14 +63,28 @@ export function useOAuthCredentialDetail( } export function useCredentialName(credentialId?: string, providerId?: string, workflowId?: string) { + // Check if this is a credential set value + const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false + const credentialSetId = isCredentialSet + ? credentialId?.slice(CREDENTIAL_SET.PREFIX.length) + : undefined + + // Fetch credential set by ID directly + const { data: credentialSetData, isFetching: credentialSetLoading } = useCredentialSetDetail( + credentialSetId, + isCredentialSet + ) + const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials( providerId, - Boolean(providerId) + Boolean(providerId) && !isCredentialSet ) const selectedCredential = credentials.find((cred) => cred.id === credentialId) - const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId) + const shouldFetchDetail = Boolean( + credentialId && !selectedCredential && providerId && workflowId && !isCredentialSet + ) const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail( shouldFetchDetail ? credentialId : undefined, @@ -77,12 +93,17 @@ export function useCredentialName(credentialId?: string, providerId?: string, wo ) const hasForeignMeta = foreignCredentials.length > 0 + const isForeignCredentialSet = isCredentialSet && !credentialSetData && !credentialSetLoading - const displayName = selectedCredential?.name ?? (hasForeignMeta ? 'Saved by collaborator' : null) + const displayName = + credentialSetData?.name ?? + selectedCredential?.name ?? + (hasForeignMeta ? CREDENTIAL.FOREIGN_LABEL : null) ?? + (isForeignCredentialSet ? CREDENTIAL.FOREIGN_LABEL : null) return { displayName, - isLoading: credentialsLoading || foreignLoading, + isLoading: credentialsLoading || foreignLoading || (isCredentialSet && credentialSetLoading), hasForeignMeta, } } diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index e3ed5b4c6d..7f4fbe7ee4 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -363,6 +363,32 @@ export function useCancelInvitation() { }) } +/** + * Resend invitation mutation + */ +interface ResendInvitationParams { + invitationId: string + orgId: string +} + +export function useResendInvitation() { + return useMutation({ + mutationFn: async ({ invitationId, orgId }: ResendInvitationParams) => { + const response = await fetch(`/api/organizations/${orgId}/invitations/${invitationId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || 'Failed to resend invitation') + } + + return response.json() + }, + }) +} + /** * Update seats mutation (handles both add and reduce) */ diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index 3e81c35ced..e71a0cedb3 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -10,6 +10,8 @@ import { getTrigger, isTriggerValid } from '@/triggers' const logger = createLogger('useWebhookManagement') +const CREDENTIAL_SET_PREFIX = 'credentialSet:' + interface UseWebhookManagementProps { blockId: string triggerId?: string @@ -169,7 +171,22 @@ export function useWebhookManagement({ if (webhook.providerConfig) { const effectiveTriggerId = resolveEffectiveTriggerId(blockId, triggerId, webhook) - useSubBlockStore.getState().setValue(blockId, 'triggerConfig', webhook.providerConfig) + // Filter out runtime/system fields from providerConfig before storing as triggerConfig + // These fields are managed by the system and should not be included in change detection + const { + credentialId: _credId, + credentialSetId: _credSetId, + userId: _userId, + historyId: _historyId, + lastCheckedTimestamp: _lastChecked, + setupCompleted: _setupCompleted, + externalId: _externalId, + triggerId: _triggerId, + blockId: _blockId, + ...userConfigurableFields + } = webhook.providerConfig as Record + + useSubBlockStore.getState().setValue(blockId, 'triggerConfig', userConfigurableFields) if (effectiveTriggerId) { populateTriggerFieldsFromConfig(blockId, webhook.providerConfig, effectiveTriggerId) @@ -220,9 +237,17 @@ export function useWebhookManagement({ } const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + + const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX) + const credentialSetId = isCredentialSet + ? selectedCredentialId!.slice(CREDENTIAL_SET_PREFIX.length) + : undefined + const credentialId = isCredentialSet ? undefined : selectedCredentialId + const webhookConfig = { ...(triggerConfig || {}), - ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), + ...(credentialId ? { credentialId } : {}), + ...(credentialSetId ? { credentialSetId } : {}), triggerId: effectiveTriggerId, } @@ -279,13 +304,20 @@ export function useWebhookManagement({ ): Promise => { const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX) + const credentialSetId = isCredentialSet + ? selectedCredentialId!.slice(CREDENTIAL_SET_PREFIX.length) + : undefined + const credentialId = isCredentialSet ? undefined : selectedCredentialId + const response = await fetch(`/api/webhooks/${webhookIdToUpdate}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ providerConfig: { ...triggerConfig, - ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), + ...(credentialId ? { credentialId } : {}), + ...(credentialSetId ? { credentialSetId } : {}), triggerId: effectiveTriggerId, }, }), diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 3922c4f0b5..926d833eb6 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -55,6 +55,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' +import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' import { SSO_TRUSTED_PROVIDERS } from './sso/constants' @@ -188,6 +189,49 @@ export const auth = betterAuth({ }) .where(eq(schema.account.id, existing.id)) + // Sync webhooks for credential sets after reconnecting + const requestId = crypto.randomUUID().slice(0, 8) + const userMemberships = await db + .select({ + credentialSetId: schema.credentialSetMember.credentialSetId, + providerId: schema.credentialSet.providerId, + }) + .from(schema.credentialSetMember) + .innerJoin( + schema.credentialSet, + eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id) + ) + .where( + and( + eq(schema.credentialSetMember.userId, account.userId), + eq(schema.credentialSetMember.status, 'active') + ) + ) + + for (const membership of userMemberships) { + if (membership.providerId === account.providerId) { + try { + await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId) + logger.info( + '[account.create.before] Synced webhooks after credential reconnect', + { + credentialSetId: membership.credentialSetId, + providerId: account.providerId, + } + ) + } catch (error) { + logger.error( + '[account.create.before] Failed to sync webhooks after credential reconnect', + { + credentialSetId: membership.credentialSetId, + providerId: account.providerId, + error, + } + ) + } + } + } + return false } @@ -235,6 +279,46 @@ export const auth = betterAuth({ await db.update(schema.account).set(updates).where(eq(schema.account.id, account.id)) } } + + // Sync webhooks for credential sets after connecting a new credential + const requestId = crypto.randomUUID().slice(0, 8) + const userMemberships = await db + .select({ + credentialSetId: schema.credentialSetMember.credentialSetId, + providerId: schema.credentialSet.providerId, + }) + .from(schema.credentialSetMember) + .innerJoin( + schema.credentialSet, + eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id) + ) + .where( + and( + eq(schema.credentialSetMember.userId, account.userId), + eq(schema.credentialSetMember.status, 'active') + ) + ) + + for (const membership of userMemberships) { + if (membership.providerId === account.providerId) { + try { + await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId) + logger.info('[account.create.after] Synced webhooks after credential connect', { + credentialSetId: membership.credentialSetId, + providerId: account.providerId, + }) + } catch (error) { + logger.error( + '[account.create.after] Failed to sync webhooks after credential connect', + { + credentialSetId: membership.credentialSetId, + providerId: account.providerId, + error, + } + ) + } + } + } }, }, }, diff --git a/apps/sim/lib/credential-sets/providers.ts b/apps/sim/lib/credential-sets/providers.ts new file mode 100644 index 0000000000..4de42ffb78 --- /dev/null +++ b/apps/sim/lib/credential-sets/providers.ts @@ -0,0 +1,27 @@ +export type PollingProvider = 'google-email' | 'outlook' + +export const POLLING_PROVIDERS: Record = { + 'google-email': { displayName: 'Gmail' }, + outlook: { displayName: 'Outlook' }, +} + +export function getProviderDisplayName(providerId: string): string { + if (providerId === 'google-email') return 'Gmail' + if (providerId === 'outlook') return 'Outlook' + return providerId +} + +export function isPollingProvider(provider: string): provider is PollingProvider { + return provider === 'google-email' || provider === 'outlook' +} + +/** + * Maps an OAuth provider ID to its corresponding polling provider ID. + * Since credential sets now store the OAuth provider ID directly, this is primarily + * used in the credential selector to match OAuth providers to credential sets. + */ +export function getPollingProviderFromOAuth(oauthProviderId: string): PollingProvider | null { + if (oauthProviderId === 'google-email') return 'google-email' + if (oauthProviderId === 'outlook') return 'outlook' + return null +} diff --git a/apps/sim/lib/webhooks/gmail-polling-service.ts b/apps/sim/lib/webhooks/gmail-polling-service.ts index cc62c30afc..d237dae75f 100644 --- a/apps/sim/lib/webhooks/gmail-polling-service.ts +++ b/apps/sim/lib/webhooks/gmail-polling-service.ts @@ -141,17 +141,19 @@ export async function pollGmailWebhooks() { try { const metadata = webhookData.providerConfig as any + // Each webhook now has its own credentialId (credential sets are fanned out at save time) const credentialId: string | undefined = metadata?.credentialId const userId: string | undefined = metadata?.userId if (!credentialId && !userId) { - logger.error(`[${requestId}] Missing credentialId and userId for webhook ${webhookId}`) + logger.error(`[${requestId}] Missing credential info for webhook ${webhookId}`) await markWebhookFailed(webhookId) failureCount++ return } let accessToken: string | null = null + if (credentialId) { const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) if (rows.length === 0) { @@ -165,13 +167,12 @@ export async function pollGmailWebhooks() { const ownerUserId = rows[0].userId accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId) } else if (userId) { + // Legacy fallback for webhooks without credentialId accessToken = await getOAuthToken(userId, 'google-email') } if (!accessToken) { - logger.error( - `[${requestId}] Failed to get Gmail access token for webhook ${webhookId} (cred or fallback)` - ) + logger.error(`[${requestId}] Failed to get Gmail access token for webhook ${webhookId}`) await markWebhookFailed(webhookId) failureCount++ return diff --git a/apps/sim/lib/webhooks/outlook-polling-service.ts b/apps/sim/lib/webhooks/outlook-polling-service.ts index 68f93385ac..448067a4e2 100644 --- a/apps/sim/lib/webhooks/outlook-polling-service.ts +++ b/apps/sim/lib/webhooks/outlook-polling-service.ts @@ -359,7 +359,19 @@ async function fetchNewOutlookEmails( const data = await response.json() const emails = data.value || [] - const filteredEmails = filterEmailsByFolder(emails, config) + let resolvedFolderIds: Map | undefined + if (config.folderIds && config.folderIds.length > 0) { + const hasWellKnownFolders = config.folderIds.some(isWellKnownFolderName) + if (hasWellKnownFolders) { + resolvedFolderIds = await resolveWellKnownFolderIds( + accessToken, + config.folderIds, + requestId + ) + } + } + + const filteredEmails = filterEmailsByFolder(emails, config, resolvedFolderIds) logger.info( `[${requestId}] Fetched ${emails.length} emails, ${filteredEmails.length} after filtering` @@ -373,18 +385,103 @@ async function fetchNewOutlookEmails( } } +const OUTLOOK_WELL_KNOWN_FOLDERS = new Set([ + 'inbox', + 'drafts', + 'sentitems', + 'deleteditems', + 'junkemail', + 'archive', + 'outbox', +]) + +function isWellKnownFolderName(folderId: string): boolean { + return OUTLOOK_WELL_KNOWN_FOLDERS.has(folderId.toLowerCase()) +} + +async function resolveWellKnownFolderId( + accessToken: string, + folderName: string, + requestId: string +): Promise { + try { + const response = await fetch(`https://graph.microsoft.com/v1.0/me/mailFolders/${folderName}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + logger.warn( + `[${requestId}] Failed to resolve well-known folder '${folderName}': ${response.status}` + ) + return null + } + + const folder = await response.json() + return folder.id || null + } catch (error) { + logger.error(`[${requestId}] Error resolving well-known folder '${folderName}':`, error) + return null + } +} + +async function resolveWellKnownFolderIds( + accessToken: string, + folderIds: string[], + requestId: string +): Promise> { + const resolvedIds = new Map() + + const wellKnownFolders = folderIds.filter(isWellKnownFolderName) + if (wellKnownFolders.length === 0) { + return resolvedIds + } + + const resolutions = await Promise.all( + wellKnownFolders.map(async (folderName) => { + const actualId = await resolveWellKnownFolderId(accessToken, folderName, requestId) + return { folderName, actualId } + }) + ) + + for (const { folderName, actualId } of resolutions) { + if (actualId) { + resolvedIds.set(folderName.toLowerCase(), actualId) + } + } + + logger.info( + `[${requestId}] Resolved ${resolvedIds.size}/${wellKnownFolders.length} well-known folders` + ) + + return resolvedIds +} + function filterEmailsByFolder( emails: OutlookEmail[], - config: OutlookWebhookConfig + config: OutlookWebhookConfig, + resolvedFolderIds?: Map ): OutlookEmail[] { if (!config.folderIds || !config.folderIds.length) { return emails } + const actualFolderIds = config.folderIds.map((configFolder) => { + if (resolvedFolderIds && isWellKnownFolderName(configFolder)) { + const resolvedId = resolvedFolderIds.get(configFolder.toLowerCase()) + if (resolvedId) { + return resolvedId + } + } + return configFolder + }) + return emails.filter((email) => { const emailFolderId = email.parentFolderId - const hasMatchingFolder = config.folderIds!.some((configFolder) => - emailFolderId.toLowerCase().includes(configFolder.toLowerCase()) + const hasMatchingFolder = actualFolderIds.some( + (folderId) => emailFolderId.toLowerCase() === folderId.toLowerCase() ) return config.folderFilterBehavior === 'INCLUDE' ? hasMatchingFolder : !hasMatchingFolder diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 800c15c236..65fbf43050 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -1,10 +1,12 @@ import { db, webhook, workflow } from '@sim/db' +import { credentialSet, subscription } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { tasks } from '@trigger.dev/sdk' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' -import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags' +import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils' +import { isProd, isTriggerDevEnabled } from '@/lib/core/config/feature-flags' import { preprocessExecution } from '@/lib/execution/preprocessing' import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' import { @@ -40,6 +42,48 @@ function getExternalUrl(request: NextRequest): string { return request.url } +async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ + valid: boolean + error?: string +}> { + if (!isProd) { + return { valid: true } + } + + const [set] = await db + .select({ organizationId: credentialSet.organizationId }) + .from(credentialSet) + .where(eq(credentialSet.id, credentialSetId)) + .limit(1) + + if (!set) { + return { valid: false, error: 'Credential set not found' } + } + + const [orgSub] = await db + .select() + .from(subscription) + .where(and(eq(subscription.referenceId, set.organizationId), eq(subscription.status, 'active'))) + .limit(1) + + if (!orgSub) { + return { + valid: false, + error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.', + } + } + + const hasTeamPlan = checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub) + if (!hasTeamPlan) { + return { + valid: false, + error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.', + } + } + + return { valid: true } +} + export async function parseWebhookBody( request: NextRequest, requestId: string @@ -109,6 +153,17 @@ export async function handleProviderChallenges( } const url = new URL(request.url) + + // Microsoft Graph subscription validation (can come as GET or POST) + const validationToken = url.searchParams.get('validationToken') + if (validationToken) { + logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) + return new NextResponse(validationToken, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + } + const mode = url.searchParams.get('hub.mode') const token = url.searchParams.get('hub.verify_token') const challenge = url.searchParams.get('hub.challenge') @@ -149,6 +204,61 @@ export function handleProviderReachabilityTest( return null } +/** + * Format error response based on provider requirements. + * Some providers (like Microsoft Teams) require specific response formats. + */ +export function formatProviderErrorResponse( + webhook: any, + error: string, + status: number +): NextResponse { + if (webhook.provider === 'microsoft-teams') { + return NextResponse.json({ type: 'message', text: error }, { status }) + } + return NextResponse.json({ error }, { status }) +} + +/** + * Check if a webhook event should be skipped based on provider-specific filtering. + * Returns true if the event should be skipped, false if it should be processed. + */ +export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: string): boolean { + const providerConfig = (webhook.providerConfig as Record) || {} + + if (webhook.provider === 'stripe') { + const eventTypes = providerConfig.eventTypes + if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { + const eventType = body?.type + if (eventType && !eventTypes.includes(eventType)) { + logger.info( + `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping` + ) + return true + } + } + } + + return false +} + +/** Providers that validate webhook URLs during creation, before workflow deployment */ +const PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION = new Set(['grain']) + +/** Returns 200 OK for providers that validate URLs before the workflow is deployed */ +export function handlePreDeploymentVerification( + webhook: any, + requestId: string +): NextResponse | null { + if (PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION.has(webhook.provider)) { + logger.info( + `[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation` + ) + return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' }) + } + return null +} + export async function findWebhookAndWorkflow( options: WebhookProcessorOptions ): Promise<{ webhook: any; workflow: any } | null> { @@ -193,6 +303,37 @@ export async function findWebhookAndWorkflow( return null } +/** + * Find ALL webhooks matching a path. + * Used for credential sets where multiple webhooks share the same path. + */ +export async function findAllWebhooksForPath( + options: WebhookProcessorOptions +): Promise> { + if (!options.path) { + return [] + } + + const results = await db + .select({ + webhook: webhook, + workflow: workflow, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.path, options.path), eq(webhook.isActive, true))) + + if (results.length === 0) { + logger.warn(`[${options.requestId}] No active webhooks found for path: ${options.path}`) + } else if (results.length > 1) { + logger.info( + `[${options.requestId}] Found ${results.length} webhooks for path: ${options.path} (credential set fan-out)` + ) + } + + return results +} + /** * Resolve {{VARIABLE}} references in a string value * @param value - String that may contain {{VARIABLE}} references @@ -774,9 +915,22 @@ export async function queueWebhookExecution( } } - // Extract credentialId from webhook config for credential-based webhooks + // Extract credentialId from webhook config + // Note: Each webhook now has its own credentialId (credential sets are fanned out at save time) const providerConfig = (foundWebhook.providerConfig as Record) || {} const credentialId = providerConfig.credentialId as string | undefined + const credentialSetId = providerConfig.credentialSetId as string | undefined + + // Verify billing for credential sets + if (credentialSetId) { + const billingCheck = await verifyCredentialSetBilling(credentialSetId) + if (!billingCheck.valid) { + logger.warn( + `[${options.requestId}] Credential set billing check failed: ${billingCheck.error}` + ) + return NextResponse.json({ error: billingCheck.error }, { status: 403 }) + } + } const payload = { webhookId: foundWebhook.id, diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index e22ddd1251..1ab4f792dc 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createPinnedUrl, validateUrlWithDNS } from '@/lib/core/security/input-validation' +import type { DbOrTx } from '@/lib/db/types' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookUtils') @@ -2477,7 +2478,314 @@ export interface AirtableChange { } /** - * Configure Gmail polling for a webhook + * Result of syncing webhooks for a credential set + */ +export interface CredentialSetWebhookSyncResult { + webhooks: Array<{ + id: string + credentialId: string + isNew: boolean + }> + created: number + updated: number + deleted: number +} + +/** + * Sync webhooks for a credential set. + * + * For credential sets, we create one webhook per credential in the set. + * Each webhook has its own state and credentialId. + * + * Path strategy: + * - Polling triggers (gmail, outlook): unique paths per credential (for independent polling) + * - External triggers (slack, etc.): shared path (external service sends to one URL) + * + * This function: + * 1. Gets all credentials in the credential set + * 2. Gets existing webhooks for this workflow+block with this credentialSetId + * 3. Creates webhooks for new credentials + * 4. Updates config for existing webhooks (preserving state) + * 5. Deletes webhooks for credentials no longer in the set + */ +export async function syncWebhooksForCredentialSet(params: { + workflowId: string + blockId: string + provider: string + basePath: string + credentialSetId: string + oauthProviderId: string + providerConfig: Record + requestId: string + tx?: DbOrTx +}): Promise { + const { + workflowId, + blockId, + provider, + basePath, + credentialSetId, + oauthProviderId, + providerConfig, + requestId, + tx, + } = params + + const dbCtx = tx ?? db + + const syncLogger = createLogger('CredentialSetWebhookSync') + syncLogger.info( + `[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}` + ) + + const { getCredentialsForCredentialSet } = await import('@/app/api/auth/oauth/utils') + const { nanoid } = await import('nanoid') + + // Polling providers get unique paths per credential (for independent state) + // External webhook providers share the same path (external service sends to one URL) + const pollingProviders = ['gmail', 'outlook', 'rss', 'imap'] + const useUniquePaths = pollingProviders.includes(provider) + + // Get all credentials in the set + const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId) + + if (credentials.length === 0) { + syncLogger.warn( + `[${requestId}] No credentials found in credential set ${credentialSetId} for provider ${oauthProviderId}` + ) + return { webhooks: [], created: 0, updated: 0, deleted: 0 } + } + + syncLogger.info( + `[${requestId}] Found ${credentials.length} credentials in set ${credentialSetId}` + ) + + // Get existing webhooks for this workflow+block that belong to this credential set + const existingWebhooks = await dbCtx + .select() + .from(webhook) + .where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId))) + + // Filter to only webhooks belonging to this credential set + const credentialSetWebhooks = existingWebhooks.filter((wh) => { + const config = wh.providerConfig as Record + return config?.credentialSetId === credentialSetId + }) + + syncLogger.info( + `[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set` + ) + + // Build maps for efficient lookup + const existingByCredentialId = new Map() + for (const wh of credentialSetWebhooks) { + const config = wh.providerConfig as Record + if (config?.credentialId) { + existingByCredentialId.set(config.credentialId, wh) + } + } + + const credentialIdsInSet = new Set(credentials.map((c) => c.credentialId)) + + const result: CredentialSetWebhookSyncResult = { + webhooks: [], + created: 0, + updated: 0, + deleted: 0, + } + + // Process each credential in the set + for (const cred of credentials) { + const existingWebhook = existingByCredentialId.get(cred.credentialId) + + if (existingWebhook) { + // Update existing webhook - preserve state fields + const existingConfig = existingWebhook.providerConfig as Record + + const updatedConfig = { + ...providerConfig, + basePath, // Store basePath for reliable reconstruction during membership sync + credentialId: cred.credentialId, + credentialSetId: credentialSetId, + // Preserve state fields from existing config + historyId: existingConfig.historyId, + lastCheckedTimestamp: existingConfig.lastCheckedTimestamp, + setupCompleted: existingConfig.setupCompleted, + externalId: existingConfig.externalId, + userId: cred.userId, + } + + await dbCtx + .update(webhook) + .set({ + providerConfig: updatedConfig, + isActive: true, + updatedAt: new Date(), + }) + .where(eq(webhook.id, existingWebhook.id)) + + result.webhooks.push({ + id: existingWebhook.id, + credentialId: cred.credentialId, + isNew: false, + }) + result.updated++ + + syncLogger.debug( + `[${requestId}] Updated webhook ${existingWebhook.id} for credential ${cred.credentialId}` + ) + } else { + // Create new webhook for this credential + const webhookId = nanoid() + const webhookPath = useUniquePaths ? `${basePath}-${cred.credentialId.slice(0, 8)}` : basePath + + const newConfig = { + ...providerConfig, + basePath, // Store basePath for reliable reconstruction during membership sync + credentialId: cred.credentialId, + credentialSetId: credentialSetId, + userId: cred.userId, + } + + await dbCtx.insert(webhook).values({ + id: webhookId, + workflowId, + blockId, + path: webhookPath, + provider, + providerConfig: newConfig, + credentialSetId, // Indexed column for efficient credential set queries + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + + result.webhooks.push({ + id: webhookId, + credentialId: cred.credentialId, + isNew: true, + }) + result.created++ + + syncLogger.debug( + `[${requestId}] Created webhook ${webhookId} for credential ${cred.credentialId}` + ) + } + } + + // Delete webhooks for credentials no longer in the set + for (const [credentialId, existingWebhook] of existingByCredentialId) { + if (!credentialIdsInSet.has(credentialId)) { + await dbCtx.delete(webhook).where(eq(webhook.id, existingWebhook.id)) + result.deleted++ + + syncLogger.debug( + `[${requestId}] Deleted webhook ${existingWebhook.id} for removed credential ${credentialId}` + ) + } + } + + syncLogger.info( + `[${requestId}] Credential set webhook sync complete: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted` + ) + + return result +} + +/** + * Sync all webhooks that use a specific credential set. + * Called when credential set membership changes (member added/removed). + * + * This finds all workflows with webhooks using this credential set and resyncs them. + */ +export async function syncAllWebhooksForCredentialSet( + credentialSetId: string, + requestId: string, + tx?: DbOrTx +): Promise<{ workflowsUpdated: number; totalCreated: number; totalDeleted: number }> { + const dbCtx = tx ?? db + const syncLogger = createLogger('CredentialSetMembershipSync') + syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`) + + const { getProviderIdFromServiceId } = await import('@/lib/oauth') + + // Find all webhooks that use this credential set using the indexed column + const webhooksForSet = await dbCtx + .select() + .from(webhook) + .where(eq(webhook.credentialSetId, credentialSetId)) + + if (webhooksForSet.length === 0) { + syncLogger.info(`[${requestId}] No webhooks found using credential set ${credentialSetId}`) + return { workflowsUpdated: 0, totalCreated: 0, totalDeleted: 0 } + } + + // Group webhooks by workflow+block to find unique triggers + const triggerGroups = new Map() + for (const wh of webhooksForSet) { + const key = `${wh.workflowId}:${wh.blockId}` + // Keep the first webhook as representative (they all have same config) + if (!triggerGroups.has(key)) { + triggerGroups.set(key, wh) + } + } + + syncLogger.info( + `[${requestId}] Found ${triggerGroups.size} triggers using credential set ${credentialSetId}` + ) + + let workflowsUpdated = 0 + let totalCreated = 0 + let totalDeleted = 0 + + for (const [key, representativeWebhook] of triggerGroups) { + if (!representativeWebhook.provider) { + syncLogger.warn(`[${requestId}] Skipping webhook without provider: ${key}`) + continue + } + + const config = representativeWebhook.providerConfig as Record + const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider) + + const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config + // Use stored basePath if available, otherwise fall back to blockId (for legacy webhooks) + const basePath = config.basePath || representativeWebhook.blockId || representativeWebhook.path + + try { + const result = await syncWebhooksForCredentialSet({ + workflowId: representativeWebhook.workflowId, + blockId: representativeWebhook.blockId || '', + provider: representativeWebhook.provider, + basePath, + credentialSetId, + oauthProviderId, + providerConfig: baseConfig, + requestId, + tx: dbCtx, + }) + + workflowsUpdated++ + totalCreated += result.created + totalDeleted += result.deleted + + syncLogger.debug( + `[${requestId}] Synced webhooks for ${key}: ${result.created} created, ${result.deleted} deleted` + ) + } catch (error) { + syncLogger.error(`[${requestId}] Error syncing webhooks for ${key}`, error) + } + } + + syncLogger.info( + `[${requestId}] Credential set membership sync complete: ${workflowsUpdated} workflows updated, ${totalCreated} webhooks created, ${totalDeleted} webhooks deleted` + ) + + return { workflowsUpdated, totalCreated, totalDeleted } +} + +/** + * Configure Gmail polling for a webhook. + * Each webhook has its own credentialId (credential sets are fanned out at save time). */ export async function configureGmailPolling(webhookData: any, requestId: string): Promise { const logger = createLogger('GmailWebhookSetup') @@ -2492,7 +2800,7 @@ export async function configureGmailPolling(webhookData: any, requestId: string) return false } - // Get userId from credential + // Verify credential exists and get userId const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) if (rows.length === 0) { logger.error( @@ -2502,6 +2810,8 @@ export async function configureGmailPolling(webhookData: any, requestId: string) } const effectiveUserId = rows[0].userId + + // Verify token can be refreshed const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) if (!accessToken) { logger.error( @@ -2528,14 +2838,14 @@ export async function configureGmailPolling(webhookData: any, requestId: string) providerConfig: { ...providerConfig, userId: effectiveUserId, - ...(credentialId ? { credentialId } : {}), + credentialId, maxEmailsPerPoll, pollingInterval, markAsRead: providerConfig.markAsRead || false, includeRawEmail: providerConfig.includeRawEmail || false, labelIds: providerConfig.labelIds || ['INBOX'], labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: now.toISOString(), + lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), setupCompleted: true, }, updatedAt: now, @@ -2557,7 +2867,8 @@ export async function configureGmailPolling(webhookData: any, requestId: string) } /** - * Configure Outlook polling for a webhook + * Configure Outlook polling for a webhook. + * Each webhook has its own credentialId (credential sets are fanned out at save time). */ export async function configureOutlookPolling( webhookData: any, @@ -2575,7 +2886,7 @@ export async function configureOutlookPolling( return false } - // Get userId from credential + // Verify credential exists and get userId const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) if (rows.length === 0) { logger.error( @@ -2585,6 +2896,8 @@ export async function configureOutlookPolling( } const effectiveUserId = rows[0].userId + + // Verify token can be refreshed const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) if (!accessToken) { logger.error( @@ -2593,30 +2906,28 @@ export async function configureOutlookPolling( return false } - const providerCfg = (webhookData.providerConfig as Record) || {} - const now = new Date() await db .update(webhook) .set({ providerConfig: { - ...providerCfg, + ...providerConfig, userId: effectiveUserId, - ...(credentialId ? { credentialId } : {}), + credentialId, maxEmailsPerPoll: - typeof providerCfg.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25 - : providerCfg.maxEmailsPerPoll || 25, + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : providerConfig.maxEmailsPerPoll || 25, pollingInterval: - typeof providerCfg.pollingInterval === 'string' - ? Number.parseInt(providerCfg.pollingInterval, 10) || 5 - : providerCfg.pollingInterval || 5, - markAsRead: providerCfg.markAsRead || false, - includeRawEmail: providerCfg.includeRawEmail || false, - folderIds: providerCfg.folderIds || ['inbox'], - folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: now.toISOString(), + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : providerConfig.pollingInterval || 5, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + folderIds: providerConfig.folderIds || ['inbox'], + folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), setupCompleted: true, }, updatedAt: now, diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 96481c586d..34bc5969f6 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -430,6 +430,7 @@ export async function saveWorkflowToNormalizedTables( path: wh.path, provider: wh.provider, providerConfig: wh.providerConfig, + credentialSetId: wh.credentialSetId, isActive: wh.isActive, createdAt: wh.createdAt, updatedAt: new Date(), diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f92f451807..5d91854c74 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -253,9 +253,8 @@ export async function executeTool( try { const baseUrl = getBaseUrl() - // Prepare the token payload const tokenPayload: OAuthTokenPayload = { - credentialId: contextParams.credential, + credentialId: contextParams.credential as string, } // Add workflowId if it exists in params, context, or executionContext diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 36f7cf366a..44071a2001 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -122,7 +122,9 @@ export interface TableRow { } export interface OAuthTokenPayload { - credentialId: string + credentialId?: string + credentialAccountUserId?: string + providerId?: string workflowId?: string } diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 54f0e297d9..ee8a8c9471 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -1,10 +1,28 @@ import { createLogger } from '@sim/logger' import { GmailIcon } from '@/components/icons' +import { isCredentialSetValue } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('GmailPollingTrigger') +// Gmail system labels that exist for all accounts (used as defaults for credential sets) +const GMAIL_SYSTEM_LABELS = [ + { id: 'INBOX', label: 'Inbox' }, + { id: 'SENT', label: 'Sent' }, + { id: 'DRAFT', label: 'Drafts' }, + { id: 'SPAM', label: 'Spam' }, + { id: 'TRASH', label: 'Trash' }, + { id: 'STARRED', label: 'Starred' }, + { id: 'IMPORTANT', label: 'Important' }, + { id: 'UNREAD', label: 'Unread' }, + { id: 'CATEGORY_PERSONAL', label: 'Category: Personal' }, + { id: 'CATEGORY_SOCIAL', label: 'Category: Social' }, + { id: 'CATEGORY_PROMOTIONS', label: 'Category: Promotions' }, + { id: 'CATEGORY_UPDATES', label: 'Category: Updates' }, + { id: 'CATEGORY_FORUMS', label: 'Category: Forums' }, +] + export const gmailPollingTrigger: TriggerConfig = { id: 'gmail_poller', name: 'Gmail Email Trigger', @@ -23,6 +41,7 @@ export const gmailPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, }, { id: 'labelIds', @@ -41,6 +60,10 @@ export const gmailPollingTrigger: TriggerConfig = { // Return a sentinel to prevent infinite retry loops when credential is missing throw new Error('No Gmail credential selected') } + // Return default system labels for credential sets (can't fetch user-specific labels for a pool) + if (isCredentialSetValue(credentialId)) { + return GMAIL_SYSTEM_LABELS + } try { const response = await fetch(`/api/tools/gmail/labels?credentialId=${credentialId}`) if (!response.ok) { diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index e298fdb53d..9f3d9b09b1 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -1,10 +1,22 @@ import { createLogger } from '@sim/logger' import { OutlookIcon } from '@/components/icons' +import { isCredentialSetValue } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('OutlookPollingTrigger') +// Outlook well-known folders that exist for all accounts (used as defaults for credential sets) +const OUTLOOK_SYSTEM_FOLDERS = [ + { id: 'inbox', label: 'Inbox' }, + { id: 'drafts', label: 'Drafts' }, + { id: 'sentitems', label: 'Sent Items' }, + { id: 'deleteditems', label: 'Deleted Items' }, + { id: 'junkemail', label: 'Junk Email' }, + { id: 'archive', label: 'Archive' }, + { id: 'outbox', label: 'Outbox' }, +] + export const outlookPollingTrigger: TriggerConfig = { id: 'outlook_poller', name: 'Outlook Email Trigger', @@ -23,6 +35,7 @@ export const outlookPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', + supportsCredentialSets: true, }, { id: 'folderIds', @@ -40,6 +53,10 @@ export const outlookPollingTrigger: TriggerConfig = { if (!credentialId) { throw new Error('No Outlook credential selected') } + // Return default system folders for credential sets (can't fetch user-specific folders for a pool) + if (isCredentialSetValue(credentialId)) { + return OUTLOOK_SYSTEM_FOLDERS + } try { const response = await fetch(`/api/tools/outlook/folders?credentialId=${credentialId}`) if (!response.ok) { diff --git a/bun.lock b/bun.lock index d62ed31d02..fc86d072df 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/packages/db/migrations/0135_stormy_puff_adder.sql b/packages/db/migrations/0135_stormy_puff_adder.sql new file mode 100644 index 0000000000..1e4e248529 --- /dev/null +++ b/packages/db/migrations/0135_stormy_puff_adder.sql @@ -0,0 +1,61 @@ +CREATE TYPE "public"."credential_set_invitation_status" AS ENUM('pending', 'accepted', 'expired', 'cancelled');--> statement-breakpoint +CREATE TYPE "public"."credential_set_member_status" AS ENUM('active', 'pending', 'revoked');--> statement-breakpoint +CREATE TABLE "credential_set" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "name" text NOT NULL, + "description" text, + "provider_id" text NOT NULL, + "created_by" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "credential_set_invitation" ( + "id" text PRIMARY KEY NOT NULL, + "credential_set_id" text NOT NULL, + "email" text, + "token" text NOT NULL, + "invited_by" text NOT NULL, + "status" "credential_set_invitation_status" DEFAULT 'pending' NOT NULL, + "expires_at" timestamp NOT NULL, + "accepted_at" timestamp, + "accepted_by_user_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "credential_set_invitation_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "credential_set_member" ( + "id" text PRIMARY KEY NOT NULL, + "credential_set_id" text NOT NULL, + "user_id" text NOT NULL, + "status" "credential_set_member_status" DEFAULT 'pending' NOT NULL, + "joined_at" timestamp, + "invited_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "webhook" ADD COLUMN "credential_set_id" text;--> statement-breakpoint +ALTER TABLE "credential_set" ADD CONSTRAINT "credential_set_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_set" ADD CONSTRAINT "credential_set_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_set_invitation" ADD CONSTRAINT "credential_set_invitation_accepted_by_user_id_user_id_fk" FOREIGN KEY ("accepted_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "credential_set_member" ADD CONSTRAINT "credential_set_member_invited_by_user_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "credential_set_organization_id_idx" ON "credential_set" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "credential_set_created_by_idx" ON "credential_set" USING btree ("created_by");--> statement-breakpoint +CREATE UNIQUE INDEX "credential_set_org_name_unique" ON "credential_set" USING btree ("organization_id","name");--> statement-breakpoint +CREATE INDEX "credential_set_provider_id_idx" ON "credential_set" USING btree ("provider_id");--> statement-breakpoint +CREATE INDEX "credential_set_invitation_set_id_idx" ON "credential_set_invitation" USING btree ("credential_set_id");--> statement-breakpoint +CREATE INDEX "credential_set_invitation_token_idx" ON "credential_set_invitation" USING btree ("token");--> statement-breakpoint +CREATE INDEX "credential_set_invitation_status_idx" ON "credential_set_invitation" USING btree ("status");--> statement-breakpoint +CREATE INDEX "credential_set_invitation_expires_at_idx" ON "credential_set_invitation" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "credential_set_member_set_id_idx" ON "credential_set_member" USING btree ("credential_set_id");--> statement-breakpoint +CREATE INDEX "credential_set_member_user_id_idx" ON "credential_set_member" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "credential_set_member_unique" ON "credential_set_member" USING btree ("credential_set_id","user_id");--> statement-breakpoint +CREATE INDEX "credential_set_member_status_idx" ON "credential_set_member" USING btree ("status");--> statement-breakpoint +ALTER TABLE "webhook" ADD CONSTRAINT "webhook_credential_set_id_credential_set_id_fk" FOREIGN KEY ("credential_set_id") REFERENCES "public"."credential_set"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "webhook_credential_set_id_idx" ON "webhook" USING btree ("credential_set_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0135_snapshot.json b/packages/db/migrations/meta/0135_snapshot.json new file mode 100644 index 0000000000..26f9899a23 --- /dev/null +++ b/packages/db/migrations/meta/0135_snapshot.json @@ -0,0 +1,9333 @@ +{ + "id": "27c797c9-6ab1-4fe8-96ae-9242c4d427f0", + "prevId": "30899c79-8b72-46e7-a7b0-d9fe760c59ea", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_user_provider_account_unique": { + "name": "account_user_provider_account_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set": { + "name": "credential_set", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_organization_id_idx": { + "name": "credential_set_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_member_set_id_idx": { + "name": "credential_set_member_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_namespace_unique": { + "name": "idempotency_key_namespace_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_namespace_idx": { + "name": "idempotency_key_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'20'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_files_key_unique": { + "name": "workspace_files_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": ["workflow", "wand", "copilot"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 26a68ca9ab..591a6db7fd 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -939,6 +939,13 @@ "when": 1766779827389, "tag": "0134_parallel_galactus", "breakpoints": true + }, + { + "idx": 135, + "version": "7", + "when": 1767737974016, + "tag": "0135_stormy_puff_adder", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 692fb11497..c03d4e65b3 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -527,6 +527,9 @@ export const webhook = pgTable( isActive: boolean('is_active').notNull().default(true), failedCount: integer('failed_count').default(0), // Track consecutive failures lastFailedAt: timestamp('last_failed_at'), // When the webhook last failed + credentialSetId: text('credential_set_id').references(() => credentialSet.id, { + onDelete: 'set null', + }), // For credential set webhooks - enables efficient queries createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, @@ -539,6 +542,8 @@ export const webhook = pgTable( table.workflowId, table.blockId ), + // Optimize queries for credential set webhooks + credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId), } } ) @@ -1777,3 +1782,98 @@ export const usageLog = pgTable( workflowIdIdx: index('usage_log_workflow_id_idx').on(table.workflowId), }) ) + +export const credentialSet = pgTable( + 'credential_set', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + name: text('name').notNull(), + description: text('description'), + providerId: text('provider_id').notNull(), + createdBy: text('created_by') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + organizationIdIdx: index('credential_set_organization_id_idx').on(table.organizationId), + createdByIdx: index('credential_set_created_by_idx').on(table.createdBy), + orgNameUnique: uniqueIndex('credential_set_org_name_unique').on( + table.organizationId, + table.name + ), + providerIdIdx: index('credential_set_provider_id_idx').on(table.providerId), + }) +) + +export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [ + 'active', + 'pending', + 'revoked', +]) + +export const credentialSetMember = pgTable( + 'credential_set_member', + { + id: text('id').primaryKey(), + credentialSetId: text('credential_set_id') + .notNull() + .references(() => credentialSet.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + status: credentialSetMemberStatusEnum('status').notNull().default('pending'), + joinedAt: timestamp('joined_at'), + invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + credentialSetIdIdx: index('credential_set_member_set_id_idx').on(table.credentialSetId), + userIdIdx: index('credential_set_member_user_id_idx').on(table.userId), + uniqueMembership: uniqueIndex('credential_set_member_unique').on( + table.credentialSetId, + table.userId + ), + statusIdx: index('credential_set_member_status_idx').on(table.status), + }) +) + +export const credentialSetInvitationStatusEnum = pgEnum('credential_set_invitation_status', [ + 'pending', + 'accepted', + 'expired', + 'cancelled', +]) + +export const credentialSetInvitation = pgTable( + 'credential_set_invitation', + { + id: text('id').primaryKey(), + credentialSetId: text('credential_set_id') + .notNull() + .references(() => credentialSet.id, { onDelete: 'cascade' }), + email: text('email'), + token: text('token').notNull().unique(), + invitedBy: text('invited_by') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + status: credentialSetInvitationStatusEnum('status').notNull().default('pending'), + expiresAt: timestamp('expires_at').notNull(), + acceptedAt: timestamp('accepted_at'), + acceptedByUserId: text('accepted_by_user_id').references(() => user.id, { + onDelete: 'set null', + }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + credentialSetIdIdx: index('credential_set_invitation_set_id_idx').on(table.credentialSetId), + tokenIdx: index('credential_set_invitation_token_idx').on(table.token), + statusIdx: index('credential_set_invitation_status_idx').on(table.status), + expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt), + }) +) From 26dff7cffe3b0cea7114dfe706690c089800e6c6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 7 Jan 2026 20:08:03 -0800 Subject: [PATCH 02/12] feat(bedrock): added aws bedrock as a model provider (#2722) --- apps/sim/app/api/providers/route.ts | 9 + apps/sim/blocks/blocks/agent.ts | 48 +- apps/sim/blocks/blocks/evaluator.ts | 119 +-- apps/sim/blocks/blocks/guardrails.ts | 68 +- apps/sim/blocks/blocks/router.ts | 196 +--- apps/sim/blocks/blocks/translate.ts | 110 +-- apps/sim/blocks/utils.ts | 177 ++++ apps/sim/components/icons.tsx | 19 + apps/sim/executor/execution/block-executor.ts | 2 +- .../executor/handlers/agent/agent-handler.ts | 6 + apps/sim/executor/handlers/agent/types.ts | 3 + .../handlers/evaluator/evaluator-handler.ts | 9 + .../handlers/router/router-handler.ts | 18 + apps/sim/lib/api-key/byok.ts | 5 + apps/sim/package.json | 1 + apps/sim/providers/bedrock/index.ts | 905 ++++++++++++++++++ apps/sim/providers/bedrock/utils.ts | 108 +++ apps/sim/providers/models.ts | 403 ++++++++ apps/sim/providers/registry.ts | 2 + apps/sim/providers/types.ts | 7 +- apps/sim/providers/utils.ts | 7 + apps/sim/tools/llm/chat.ts | 24 + bun.lock | 11 + 23 files changed, 1791 insertions(+), 466 deletions(-) create mode 100644 apps/sim/providers/bedrock/index.ts create mode 100644 apps/sim/providers/bedrock/utils.ts diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts index a78a5f999d..5c5c6798b8 100644 --- a/apps/sim/app/api/providers/route.ts +++ b/apps/sim/app/api/providers/route.ts @@ -41,6 +41,9 @@ export async function POST(request: NextRequest) { vertexProject, vertexLocation, vertexCredential, + bedrockAccessKeyId, + bedrockSecretKey, + bedrockRegion, responseFormat, workflowId, workspaceId, @@ -67,6 +70,9 @@ export async function POST(request: NextRequest) { hasVertexProject: !!vertexProject, hasVertexLocation: !!vertexLocation, hasVertexCredential: !!vertexCredential, + hasBedrockAccessKeyId: !!bedrockAccessKeyId, + hasBedrockSecretKey: !!bedrockSecretKey, + hasBedrockRegion: !!bedrockRegion, hasResponseFormat: !!responseFormat, workflowId, stream: !!stream, @@ -116,6 +122,9 @@ export async function POST(request: NextRequest) { azureApiVersion, vertexProject, vertexLocation, + bedrockAccessKeyId, + bedrockSecretKey, + bedrockRegion, responseFormat, workflowId, workspaceId, diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index 06f81a4768..88b727415a 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -94,7 +94,6 @@ export const AgentBlock: BlockConfig = { placeholder: 'Type or select a model...', required: true, defaultValue: 'claude-sonnet-4-5', - searchable: true, options: () => { const providersState = useProvidersStore.getState() const baseModels = providersState.providers.base.models @@ -329,6 +328,43 @@ export const AgentBlock: BlockConfig = { value: providers.vertex.models, }, }, + { + id: 'bedrockAccessKeyId', + title: 'AWS Access Key ID', + type: 'short-input', + password: true, + placeholder: 'Enter your AWS Access Key ID', + connectionDroppable: false, + required: true, + condition: { + field: 'model', + value: providers.bedrock.models, + }, + }, + { + id: 'bedrockSecretKey', + title: 'AWS Secret Access Key', + type: 'short-input', + password: true, + placeholder: 'Enter your AWS Secret Access Key', + connectionDroppable: false, + required: true, + condition: { + field: 'model', + value: providers.bedrock.models, + }, + }, + { + id: 'bedrockRegion', + title: 'AWS Region', + type: 'short-input', + placeholder: 'us-east-1', + connectionDroppable: false, + condition: { + field: 'model', + value: providers.bedrock.models, + }, + }, { id: 'tools', title: 'Tools', @@ -343,11 +379,11 @@ export const AgentBlock: BlockConfig = { password: true, connectionDroppable: false, required: true, - // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth) + // Hide API key for hosted models, Ollama models, vLLM models, Vertex models (uses OAuth), and Bedrock (uses AWS credentials) condition: isHosted ? { field: 'model', - value: [...getHostedModels(), ...providers.vertex.models], + value: [...getHostedModels(), ...providers.vertex.models, ...providers.bedrock.models], not: true, // Show for all models EXCEPT those listed } : () => ({ @@ -356,8 +392,9 @@ export const AgentBlock: BlockConfig = { ...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models, + ...providers.bedrock.models, ], - not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models + not: true, // Show for all models EXCEPT Ollama, vLLM, Vertex, and Bedrock models }), }, { @@ -634,6 +671,9 @@ Example 3 (Array Input): azureApiVersion: { type: 'string', description: 'Azure API version' }, vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, + bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, + bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' }, + bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' }, responseFormat: { type: 'json', description: 'JSON response format schema', diff --git a/apps/sim/blocks/blocks/evaluator.ts b/apps/sim/blocks/blocks/evaluator.ts index 402957bbdb..5d584d171e 100644 --- a/apps/sim/blocks/blocks/evaluator.ts +++ b/apps/sim/blocks/blocks/evaluator.ts @@ -1,27 +1,14 @@ import { createLogger } from '@sim/logger' import { ChartBarIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/feature-flags' import type { BlockConfig, ParamType } from '@/blocks/types' +import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' import type { ProviderId } from '@/providers/types' -import { - getBaseModelProviders, - getHostedModels, - getProviderIcon, - providers, -} from '@/providers/utils' +import { getBaseModelProviders, getProviderIcon } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' import type { ToolResponse } from '@/tools/types' const logger = createLogger('EvaluatorBlock') -const getCurrentOllamaModels = () => { - return useProvidersStore.getState().providers.ollama.models -} - -const getCurrentVLLMModels = () => { - return useProvidersStore.getState().providers.vllm.models -} - interface Metric { name: string description: string @@ -204,91 +191,7 @@ export const EvaluatorBlock: BlockConfig = { }) }, }, - { - id: 'vertexCredential', - title: 'Google Cloud Account', - type: 'oauth-input', - serviceId: 'vertex-ai', - requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], - placeholder: 'Select Google Cloud account', - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - connectionDroppable: false, - required: true, - // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth) - condition: isHosted - ? { - field: 'model', - value: [...getHostedModels(), ...providers.vertex.models], - not: true, // Show for all models EXCEPT those listed - } - : () => ({ - field: 'model', - value: [ - ...getCurrentOllamaModels(), - ...getCurrentVLLMModels(), - ...providers.vertex.models, - ], - not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models - }), - }, - { - id: 'azureEndpoint', - title: 'Azure OpenAI Endpoint', - type: 'short-input', - password: true, - placeholder: 'https://your-resource.openai.azure.com', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'azureApiVersion', - title: 'Azure API Version', - type: 'short-input', - placeholder: '2024-07-01-preview', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'vertexProject', - title: 'Vertex AI Project', - type: 'short-input', - placeholder: 'your-gcp-project-id', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'vertexLocation', - title: 'Vertex AI Location', - type: 'short-input', - placeholder: 'us-central1', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, + ...getProviderCredentialSubBlocks(), { id: 'temperature', title: 'Temperature', @@ -403,21 +306,7 @@ export const EvaluatorBlock: BlockConfig = { }, }, model: { type: 'string' as ParamType, description: 'AI model to use' }, - apiKey: { type: 'string' as ParamType, description: 'Provider API key' }, - azureEndpoint: { type: 'string' as ParamType, description: 'Azure OpenAI endpoint URL' }, - azureApiVersion: { type: 'string' as ParamType, description: 'Azure API version' }, - vertexProject: { - type: 'string' as ParamType, - description: 'Google Cloud project ID for Vertex AI', - }, - vertexLocation: { - type: 'string' as ParamType, - description: 'Google Cloud location for Vertex AI', - }, - vertexCredential: { - type: 'string' as ParamType, - description: 'Google Cloud OAuth credential ID for Vertex AI', - }, + ...PROVIDER_CREDENTIAL_INPUTS, temperature: { type: 'number' as ParamType, description: 'Response randomness level (low for consistent evaluation)', diff --git a/apps/sim/blocks/blocks/guardrails.ts b/apps/sim/blocks/blocks/guardrails.ts index 39914ced74..4ccf1ccecf 100644 --- a/apps/sim/blocks/blocks/guardrails.ts +++ b/apps/sim/blocks/blocks/guardrails.ts @@ -1,15 +1,10 @@ import { ShieldCheckIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/feature-flags' import type { BlockConfig } from '@/blocks/types' -import { getHostedModels, getProviderIcon } from '@/providers/utils' +import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' +import { getProviderIcon } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' import type { ToolResponse } from '@/tools/types' -const getCurrentOllamaModels = () => { - const providersState = useProvidersStore.getState() - return providersState.providers.ollama.models -} - export interface GuardrailsResponse extends ToolResponse { output: { passed: boolean @@ -120,8 +115,11 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, const providersState = useProvidersStore.getState() const baseModels = providersState.providers.base.models const ollamaModels = providersState.providers.ollama.models + const vllmModels = providersState.providers.vllm.models const openrouterModels = providersState.providers.openrouter.models - const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels])) + const allModels = Array.from( + new Set([...baseModels, ...ollamaModels, ...vllmModels, ...openrouterModels]) + ) return allModels.map((model) => { const icon = getProviderIcon(model) @@ -160,44 +158,19 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, value: ['hallucination'], }, }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - connectionDroppable: false, - required: true, - // Show API key field only for hallucination validation - // Hide for hosted models and Ollama models - condition: () => { - const baseCondition = { - field: 'validationType' as const, - value: ['hallucination'], - } - - if (isHosted) { - // In hosted mode, hide for hosted models - return { - ...baseCondition, - and: { - field: 'model' as const, - value: getHostedModels(), - not: true, // Show for all models EXCEPT hosted ones - }, + // Provider credential subblocks - only shown for hallucination validation + ...getProviderCredentialSubBlocks().map((subBlock) => ({ + ...subBlock, + // Combine with hallucination condition + condition: subBlock.condition + ? { + field: 'validationType' as const, + value: ['hallucination'], + and: + typeof subBlock.condition === 'function' ? subBlock.condition() : subBlock.condition, } - } - // In self-hosted mode, hide for Ollama models - return { - ...baseCondition, - and: { - field: 'model' as const, - value: getCurrentOllamaModels(), - not: true, // Show for all models EXCEPT Ollama ones - }, - } - }, - }, + : { field: 'validationType' as const, value: ['hallucination'] }, + })), { id: 'piiEntityTypes', title: 'PII Types to Detect', @@ -332,10 +305,7 @@ Return ONLY the regex pattern - no explanations, no quotes, no forward slashes, type: 'string', description: 'LLM model for hallucination scoring (default: gpt-4o-mini)', }, - apiKey: { - type: 'string', - description: 'API key for LLM provider (optional if using hosted)', - }, + ...PROVIDER_CREDENTIAL_INPUTS, piiEntityTypes: { type: 'json', description: 'PII entity types to detect (array of strings, empty = detect all)', diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts index 727c3c4682..ae6672a309 100644 --- a/apps/sim/blocks/blocks/router.ts +++ b/apps/sim/blocks/blocks/router.ts @@ -1,24 +1,11 @@ import { ConnectIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/feature-flags' import { AuthMode, type BlockConfig } from '@/blocks/types' +import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' import type { ProviderId } from '@/providers/types' -import { - getBaseModelProviders, - getHostedModels, - getProviderIcon, - providers, -} from '@/providers/utils' +import { getBaseModelProviders, getProviderIcon } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' import type { ToolResponse } from '@/tools/types' -const getCurrentOllamaModels = () => { - return useProvidersStore.getState().providers.ollama.models -} - -const getCurrentVLLMModels = () => { - return useProvidersStore.getState().providers.vllm.models -} - interface RouterResponse extends ToolResponse { output: { prompt: string @@ -168,23 +155,6 @@ const getModelOptions = () => { }) } -/** - * Helper to get API key condition for both router versions. - */ -const getApiKeyCondition = () => { - return isHosted - ? { - field: 'model', - value: [...getHostedModels(), ...providers.vertex.models], - not: true, - } - : () => ({ - field: 'model', - value: [...getCurrentOllamaModels(), ...getCurrentVLLMModels(), ...providers.vertex.models], - not: true, - }) -} - /** * Legacy Router Block (block-based routing). * Hidden from toolbar but still supported for existing workflows. @@ -221,76 +191,7 @@ export const RouterBlock: BlockConfig = { defaultValue: 'claude-sonnet-4-5', options: getModelOptions, }, - { - id: 'vertexCredential', - title: 'Google Cloud Account', - type: 'oauth-input', - serviceId: 'vertex-ai', - requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], - placeholder: 'Select Google Cloud account', - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - connectionDroppable: false, - required: true, - condition: getApiKeyCondition(), - }, - { - id: 'azureEndpoint', - title: 'Azure OpenAI Endpoint', - type: 'short-input', - password: true, - placeholder: 'https://your-resource.openai.azure.com', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'azureApiVersion', - title: 'Azure API Version', - type: 'short-input', - placeholder: '2024-07-01-preview', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'vertexProject', - title: 'Vertex AI Project', - type: 'short-input', - placeholder: 'your-gcp-project-id', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'vertexLocation', - title: 'Vertex AI Location', - type: 'short-input', - placeholder: 'us-central1', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, + ...getProviderCredentialSubBlocks(), { id: 'temperature', title: 'Temperature', @@ -335,15 +236,7 @@ export const RouterBlock: BlockConfig = { inputs: { prompt: { type: 'string', description: 'Routing prompt content' }, model: { type: 'string', description: 'AI model to use' }, - apiKey: { type: 'string', description: 'Provider API key' }, - azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' }, - azureApiVersion: { type: 'string', description: 'Azure API version' }, - vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, - vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, - vertexCredential: { - type: 'string', - description: 'Google Cloud OAuth credential ID for Vertex AI', - }, + ...PROVIDER_CREDENTIAL_INPUTS, temperature: { type: 'number', description: 'Response randomness level (low for consistent routing)', @@ -422,76 +315,7 @@ export const RouterV2Block: BlockConfig = { defaultValue: 'claude-sonnet-4-5', options: getModelOptions, }, - { - id: 'vertexCredential', - title: 'Google Cloud Account', - type: 'oauth-input', - serviceId: 'vertex-ai', - requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], - placeholder: 'Select Google Cloud account', - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - connectionDroppable: false, - required: true, - condition: getApiKeyCondition(), - }, - { - id: 'azureEndpoint', - title: 'Azure OpenAI Endpoint', - type: 'short-input', - password: true, - placeholder: 'https://your-resource.openai.azure.com', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'azureApiVersion', - title: 'Azure API Version', - type: 'short-input', - placeholder: '2024-07-01-preview', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'vertexProject', - title: 'Vertex AI Project', - type: 'short-input', - placeholder: 'your-gcp-project-id', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'vertexLocation', - title: 'Vertex AI Location', - type: 'short-input', - placeholder: 'us-central1', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, + ...getProviderCredentialSubBlocks(), ], tools: { access: [ @@ -520,15 +344,7 @@ export const RouterV2Block: BlockConfig = { context: { type: 'string', description: 'Context for routing decision' }, routes: { type: 'json', description: 'Route definitions with descriptions' }, model: { type: 'string', description: 'AI model to use' }, - apiKey: { type: 'string', description: 'Provider API key' }, - azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' }, - azureApiVersion: { type: 'string', description: 'Azure API version' }, - vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, - vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, - vertexCredential: { - type: 'string', - description: 'Google Cloud OAuth credential ID for Vertex AI', - }, + ...PROVIDER_CREDENTIAL_INPUTS, }, outputs: { context: { type: 'string', description: 'Context used for routing' }, diff --git a/apps/sim/blocks/blocks/translate.ts b/apps/sim/blocks/blocks/translate.ts index 44c646608a..d0d6477651 100644 --- a/apps/sim/blocks/blocks/translate.ts +++ b/apps/sim/blocks/blocks/translate.ts @@ -1,17 +1,9 @@ import { TranslateIcon } from '@/components/icons' -import { isHosted } from '@/lib/core/config/feature-flags' import { AuthMode, type BlockConfig } from '@/blocks/types' -import { getHostedModels, getProviderIcon, providers } from '@/providers/utils' +import { getProviderCredentialSubBlocks, PROVIDER_CREDENTIAL_INPUTS } from '@/blocks/utils' +import { getProviderIcon } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' -const getCurrentOllamaModels = () => { - return useProvidersStore.getState().providers.ollama.models -} - -const getCurrentVLLMModels = () => { - return useProvidersStore.getState().providers.vllm.models -} - const getTranslationPrompt = (targetLanguage: string) => `Translate the following text into ${targetLanguage || 'English'}. Output ONLY the translated text with no additional commentary, explanations, or notes.` @@ -59,91 +51,7 @@ export const TranslateBlock: BlockConfig = { }) }, }, - { - id: 'vertexCredential', - title: 'Google Cloud Account', - type: 'oauth-input', - serviceId: 'vertex-ai', - requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], - placeholder: 'Select Google Cloud account', - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - connectionDroppable: false, - required: true, - // Hide API key for hosted models, Ollama models, vLLM models, and Vertex models (uses OAuth) - condition: isHosted - ? { - field: 'model', - value: [...getHostedModels(), ...providers.vertex.models], - not: true, // Show for all models EXCEPT those listed - } - : () => ({ - field: 'model', - value: [ - ...getCurrentOllamaModels(), - ...getCurrentVLLMModels(), - ...providers.vertex.models, - ], - not: true, // Show for all models EXCEPT Ollama, vLLM, and Vertex models - }), - }, - { - id: 'azureEndpoint', - title: 'Azure OpenAI Endpoint', - type: 'short-input', - password: true, - placeholder: 'https://your-resource.openai.azure.com', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'azureApiVersion', - title: 'Azure API Version', - type: 'short-input', - placeholder: '2024-07-01-preview', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'vertexProject', - title: 'Vertex AI Project', - type: 'short-input', - placeholder: 'your-gcp-project-id', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'vertexLocation', - title: 'Vertex AI Location', - type: 'short-input', - placeholder: 'us-central1', - connectionDroppable: false, - required: true, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, + ...getProviderCredentialSubBlocks(), { id: 'systemPrompt', title: 'System Prompt', @@ -168,21 +76,15 @@ export const TranslateBlock: BlockConfig = { vertexProject: params.vertexProject, vertexLocation: params.vertexLocation, vertexCredential: params.vertexCredential, + bedrockRegion: params.bedrockRegion, + bedrockSecretKey: params.bedrockSecretKey, }), }, }, inputs: { context: { type: 'string', description: 'Text to translate' }, targetLanguage: { type: 'string', description: 'Target language' }, - apiKey: { type: 'string', description: 'Provider API key' }, - azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' }, - azureApiVersion: { type: 'string', description: 'Azure API version' }, - vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, - vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, - vertexCredential: { - type: 'string', - description: 'Google Cloud OAuth credential ID for Vertex AI', - }, + ...PROVIDER_CREDENTIAL_INPUTS, systemPrompt: { type: 'string', description: 'Translation instructions' }, }, outputs: { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 8a96ca2ae0..6d75c58619 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -1,4 +1,7 @@ +import { isHosted } from '@/lib/core/config/feature-flags' import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types' +import { getHostedModels, providers } from '@/providers/utils' +import { useProvidersStore } from '@/stores/providers/store' /** * Checks if a field is included in the dependsOn config. @@ -37,3 +40,177 @@ export function resolveOutputType( return resolvedOutputs } + +/** + * Helper to get current Ollama models from store + */ +const getCurrentOllamaModels = () => { + return useProvidersStore.getState().providers.ollama.models +} + +/** + * Helper to get current vLLM models from store + */ +const getCurrentVLLMModels = () => { + return useProvidersStore.getState().providers.vllm.models +} + +/** + * Get the API key condition for provider credential subblocks. + * Handles hosted vs self-hosted environments and excludes providers that don't need API key. + */ +export function getApiKeyCondition() { + return isHosted + ? { + field: 'model', + value: [...getHostedModels(), ...providers.vertex.models, ...providers.bedrock.models], + not: true, + } + : () => ({ + field: 'model', + value: [ + ...getCurrentOllamaModels(), + ...getCurrentVLLMModels(), + ...providers.vertex.models, + ...providers.bedrock.models, + ], + not: true, + }) +} + +/** + * Returns the standard provider credential subblocks used by LLM-based blocks. + * This includes: Vertex AI OAuth, API Key, Azure OpenAI, Vertex AI config, and Bedrock config. + * + * Usage: Spread into your block's subBlocks array after block-specific fields + */ +export function getProviderCredentialSubBlocks(): SubBlockConfig[] { + return [ + { + id: 'vertexCredential', + title: 'Google Cloud Account', + type: 'oauth-input', + serviceId: 'vertex-ai', + requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'], + placeholder: 'Select Google Cloud account', + required: true, + condition: { + field: 'model', + value: providers.vertex.models, + }, + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your API key', + password: true, + connectionDroppable: false, + required: true, + condition: getApiKeyCondition(), + }, + { + id: 'azureEndpoint', + title: 'Azure OpenAI Endpoint', + type: 'short-input', + password: true, + placeholder: 'https://your-resource.openai.azure.com', + connectionDroppable: false, + condition: { + field: 'model', + value: providers['azure-openai'].models, + }, + }, + { + id: 'azureApiVersion', + title: 'Azure API Version', + type: 'short-input', + placeholder: '2024-07-01-preview', + connectionDroppable: false, + condition: { + field: 'model', + value: providers['azure-openai'].models, + }, + }, + { + id: 'vertexProject', + title: 'Vertex AI Project', + type: 'short-input', + placeholder: 'your-gcp-project-id', + connectionDroppable: false, + required: true, + condition: { + field: 'model', + value: providers.vertex.models, + }, + }, + { + id: 'vertexLocation', + title: 'Vertex AI Location', + type: 'short-input', + placeholder: 'us-central1', + connectionDroppable: false, + required: true, + condition: { + field: 'model', + value: providers.vertex.models, + }, + }, + { + id: 'bedrockAccessKeyId', + title: 'AWS Access Key ID', + type: 'short-input', + password: true, + placeholder: 'Enter your AWS Access Key ID', + connectionDroppable: false, + required: true, + condition: { + field: 'model', + value: providers.bedrock.models, + }, + }, + { + id: 'bedrockSecretKey', + title: 'AWS Secret Access Key', + type: 'short-input', + password: true, + placeholder: 'Enter your AWS Secret Access Key', + connectionDroppable: false, + required: true, + condition: { + field: 'model', + value: providers.bedrock.models, + }, + }, + { + id: 'bedrockRegion', + title: 'AWS Region', + type: 'short-input', + placeholder: 'us-east-1', + connectionDroppable: false, + condition: { + field: 'model', + value: providers.bedrock.models, + }, + }, + ] +} + +/** + * Returns the standard input definitions for provider credentials. + * Use this in your block's inputs definition. + */ +export const PROVIDER_CREDENTIAL_INPUTS = { + apiKey: { type: 'string', description: 'Provider API key' }, + azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' }, + azureApiVersion: { type: 'string', description: 'Azure API version' }, + vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, + vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, + vertexCredential: { + type: 'string', + description: 'Google Cloud OAuth credential ID for Vertex AI', + }, + bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, + bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' }, + bedrockRegion: { type: 'string', description: 'AWS region for Bedrock' }, +} as const diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 192905bead..de0ab92021 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -4575,3 +4575,22 @@ export function FirefliesIcon(props: SVGProps) { ) } + +export function BedrockIcon(props: SVGProps) { + return ( + + + + + + + + + + + ) +} diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 1860dbc9fc..b454eca5c1 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -339,7 +339,7 @@ export class BlockExecutor { if (isTrigger) { const filtered: NormalizedBlockOutput = {} - const internalKeys = ['webhook', 'workflowId', 'input'] + const internalKeys = ['webhook', 'workflowId'] for (const [key, value] of Object.entries(output)) { if (internalKeys.includes(key)) continue filtered[key] = value diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 392a99da9a..2337cf4fb7 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -928,6 +928,9 @@ export class AgentBlockHandler implements BlockHandler { vertexProject: inputs.vertexProject, vertexLocation: inputs.vertexLocation, vertexCredential: inputs.vertexCredential, + bedrockAccessKeyId: inputs.bedrockAccessKeyId, + bedrockSecretKey: inputs.bedrockSecretKey, + bedrockRegion: inputs.bedrockRegion, responseFormat, workflowId: ctx.workflowId, workspaceId: ctx.workspaceId, @@ -1029,6 +1032,9 @@ export class AgentBlockHandler implements BlockHandler { azureApiVersion: providerRequest.azureApiVersion, vertexProject: providerRequest.vertexProject, vertexLocation: providerRequest.vertexLocation, + bedrockAccessKeyId: providerRequest.bedrockAccessKeyId, + bedrockSecretKey: providerRequest.bedrockSecretKey, + bedrockRegion: providerRequest.bedrockRegion, responseFormat: providerRequest.responseFormat, workflowId: providerRequest.workflowId, workspaceId: ctx.workspaceId, diff --git a/apps/sim/executor/handlers/agent/types.ts b/apps/sim/executor/handlers/agent/types.ts index 60694171ba..c3050f3a08 100644 --- a/apps/sim/executor/handlers/agent/types.ts +++ b/apps/sim/executor/handlers/agent/types.ts @@ -22,6 +22,9 @@ export interface AgentInputs { vertexProject?: string vertexLocation?: string vertexCredential?: string + bedrockAccessKeyId?: string + bedrockSecretKey?: string + bedrockRegion?: string reasoningEffort?: string verbosity?: string } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index e7a768ee33..c53486ec5b 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -32,6 +32,9 @@ export class EvaluatorBlockHandler implements BlockHandler { vertexProject: inputs.vertexProject, vertexLocation: inputs.vertexLocation, vertexCredential: inputs.vertexCredential, + bedrockAccessKeyId: inputs.bedrockAccessKeyId, + bedrockSecretKey: inputs.bedrockSecretKey, + bedrockRegion: inputs.bedrockRegion, } const providerId = getProviderFromModel(evaluatorConfig.model) @@ -128,6 +131,12 @@ export class EvaluatorBlockHandler implements BlockHandler { providerRequest.azureApiVersion = inputs.azureApiVersion } + if (providerId === 'bedrock') { + providerRequest.bedrockAccessKeyId = evaluatorConfig.bedrockAccessKeyId + providerRequest.bedrockSecretKey = evaluatorConfig.bedrockSecretKey + providerRequest.bedrockRegion = evaluatorConfig.bedrockRegion + } + const response = await fetch(url.toString(), { method: 'POST', headers: { diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 55524b7050..b00cc0f6ea 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -68,6 +68,9 @@ export class RouterBlockHandler implements BlockHandler { vertexProject: inputs.vertexProject, vertexLocation: inputs.vertexLocation, vertexCredential: inputs.vertexCredential, + bedrockAccessKeyId: inputs.bedrockAccessKeyId, + bedrockSecretKey: inputs.bedrockSecretKey, + bedrockRegion: inputs.bedrockRegion, } const providerId = getProviderFromModel(routerConfig.model) @@ -104,6 +107,12 @@ export class RouterBlockHandler implements BlockHandler { providerRequest.azureApiVersion = inputs.azureApiVersion } + if (providerId === 'bedrock') { + providerRequest.bedrockAccessKeyId = routerConfig.bedrockAccessKeyId + providerRequest.bedrockSecretKey = routerConfig.bedrockSecretKey + providerRequest.bedrockRegion = routerConfig.bedrockRegion + } + const response = await fetch(url.toString(), { method: 'POST', headers: { @@ -197,6 +206,9 @@ export class RouterBlockHandler implements BlockHandler { vertexProject: inputs.vertexProject, vertexLocation: inputs.vertexLocation, vertexCredential: inputs.vertexCredential, + bedrockAccessKeyId: inputs.bedrockAccessKeyId, + bedrockSecretKey: inputs.bedrockSecretKey, + bedrockRegion: inputs.bedrockRegion, } const providerId = getProviderFromModel(routerConfig.model) @@ -233,6 +245,12 @@ export class RouterBlockHandler implements BlockHandler { providerRequest.azureApiVersion = inputs.azureApiVersion } + if (providerId === 'bedrock') { + providerRequest.bedrockAccessKeyId = routerConfig.bedrockAccessKeyId + providerRequest.bedrockSecretKey = routerConfig.bedrockSecretKey + providerRequest.bedrockRegion = routerConfig.bedrockRegion + } + const response = await fetch(url.toString(), { method: 'POST', headers: { diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index 458da3452a..1a3403aec5 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -66,6 +66,11 @@ export async function getApiKeyWithBYOK( return { apiKey: userProvidedKey || 'empty', isBYOK: false } } + const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') + if (isBedrockModel) { + return { apiKey: 'bedrock-uses-own-credentials', isBYOK: false } + } + const isOpenAIModel = provider === 'openai' const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' diff --git a/apps/sim/package.json b/apps/sim/package.json index c6daca4e98..f627227d9a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@aws-sdk/client-bedrock-runtime": "3.940.0", "@aws-sdk/client-dynamodb": "3.940.0", "@aws-sdk/client-rds-data": "3.940.0", "@aws-sdk/client-s3": "^3.779.0", diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts new file mode 100644 index 0000000000..63816267dd --- /dev/null +++ b/apps/sim/providers/bedrock/index.ts @@ -0,0 +1,905 @@ +import { + type Message as BedrockMessage, + BedrockRuntimeClient, + type ContentBlock, + type ConversationRole, + ConverseCommand, + ConverseStreamCommand, + type SystemContentBlock, + type Tool, + type ToolConfiguration, + type ToolResultBlock, + type ToolUseBlock, +} from '@aws-sdk/client-bedrock-runtime' +import { createLogger } from '@sim/logger' +import type { StreamingExecution } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + checkForForcedToolUsage, + createReadableStreamFromBedrockStream, + generateToolUseId, + getBedrockInferenceProfileId, +} from '@/providers/bedrock/utils' +import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import type { + ProviderConfig, + ProviderRequest, + ProviderResponse, + TimeSegment, +} from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + prepareToolsWithUsageControl, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +const logger = createLogger('BedrockProvider') + +export const bedrockProvider: ProviderConfig = { + id: 'bedrock', + name: 'AWS Bedrock', + description: 'AWS Bedrock foundation models', + version: '1.0.0', + models: getProviderModels('bedrock'), + defaultModel: getProviderDefaultModel('bedrock'), + + executeRequest: async ( + request: ProviderRequest + ): Promise => { + if (!request.bedrockAccessKeyId) { + throw new Error('AWS Access Key ID is required for Bedrock') + } + + if (!request.bedrockSecretKey) { + throw new Error('AWS Secret Access Key is required for Bedrock') + } + + const region = request.bedrockRegion || 'us-east-1' + const bedrockModelId = getBedrockInferenceProfileId(request.model, region) + + logger.info('Bedrock request', { + requestModel: request.model, + inferenceProfileId: bedrockModelId, + region, + }) + + const client = new BedrockRuntimeClient({ + region, + credentials: { + accessKeyId: request.bedrockAccessKeyId || '', + secretAccessKey: request.bedrockSecretKey || '', + }, + }) + + const messages: BedrockMessage[] = [] + const systemContent: SystemContentBlock[] = [] + + if (request.systemPrompt) { + systemContent.push({ text: request.systemPrompt }) + } + + if (request.context) { + messages.push({ + role: 'user' as ConversationRole, + content: [{ text: request.context }], + }) + } + + if (request.messages) { + for (const msg of request.messages) { + if (msg.role === 'function' || msg.role === 'tool') { + const toolResultBlock: ToolResultBlock = { + toolUseId: msg.tool_call_id || msg.name || generateToolUseId('tool'), + content: [{ text: msg.content || '' }], + } + messages.push({ + role: 'user' as ConversationRole, + content: [{ toolResult: toolResultBlock }], + }) + } else if (msg.function_call || msg.tool_calls) { + const toolCall = msg.function_call || msg.tool_calls?.[0]?.function + if (toolCall) { + const toolUseBlock: ToolUseBlock = { + toolUseId: msg.tool_calls?.[0]?.id || generateToolUseId(toolCall.name), + name: toolCall.name, + input: JSON.parse(toolCall.arguments), + } + messages.push({ + role: 'assistant' as ConversationRole, + content: [{ toolUse: toolUseBlock }], + }) + } + } else { + const role: ConversationRole = msg.role === 'assistant' ? 'assistant' : 'user' + messages.push({ + role, + content: [{ text: msg.content || '' }], + }) + } + } + } + + if (messages.length === 0) { + messages.push({ + role: 'user' as ConversationRole, + content: [{ text: request.systemPrompt || 'Hello' }], + }) + systemContent.length = 0 + } + + let structuredOutputTool: Tool | undefined + const structuredOutputToolName = 'structured_output' + + if (request.responseFormat) { + const schema = request.responseFormat.schema || request.responseFormat + const schemaName = request.responseFormat.name || 'response' + + structuredOutputTool = { + toolSpec: { + name: structuredOutputToolName, + description: `Output the response as structured JSON matching the ${schemaName} schema. You MUST call this tool to provide your final response.`, + inputSchema: { + json: schema, + }, + }, + } + + logger.info(`Using Tool Use approach for structured outputs: ${schemaName}`) + } + + let bedrockTools: Tool[] | undefined + let toolChoice: any = { auto: {} } + let preparedTools: ReturnType | null = null + + if (request.tools?.length) { + bedrockTools = request.tools.map((tool) => ({ + toolSpec: { + name: tool.id, + description: tool.description, + inputSchema: { + json: { + type: 'object', + properties: tool.parameters.properties, + required: tool.parameters.required, + }, + }, + }, + })) + + try { + preparedTools = prepareToolsWithUsageControl( + bedrockTools.map((t) => ({ + name: t.toolSpec?.name || '', + description: t.toolSpec?.description || '', + input_schema: t.toolSpec?.inputSchema?.json, + })), + request.tools, + logger, + 'bedrock' + ) + + const { tools: filteredTools, toolChoice: tc } = preparedTools + + if (filteredTools?.length) { + bedrockTools = filteredTools.map((t: any) => ({ + toolSpec: { + name: t.name, + description: t.description, + inputSchema: { json: t.input_schema }, + }, + })) + + if (typeof tc === 'object' && tc !== null) { + if (tc.type === 'tool' && tc.name) { + toolChoice = { tool: { name: tc.name } } + logger.info(`Using Bedrock tool_choice format: force tool "${tc.name}"`) + } else if (tc.type === 'function' && tc.function?.name) { + toolChoice = { tool: { name: tc.function.name } } + logger.info(`Using Bedrock tool_choice format: force tool "${tc.function.name}"`) + } else { + toolChoice = { auto: {} } + } + } else if (tc === 'none') { + toolChoice = undefined + bedrockTools = undefined + } else { + toolChoice = { auto: {} } + } + } + } catch (error) { + logger.error('Error in prepareToolsWithUsageControl:', { error }) + toolChoice = { auto: {} } + } + } else if (structuredOutputTool) { + bedrockTools = [structuredOutputTool] + toolChoice = { tool: { name: structuredOutputToolName } } + logger.info('Using structured_output tool as only tool (forced)') + } + + const hasToolContentInMessages = messages.some((msg) => + msg.content?.some( + (block) => + ('toolUse' in block && block.toolUse) || ('toolResult' in block && block.toolResult) + ) + ) + + const toolConfig: ToolConfiguration | undefined = bedrockTools?.length + ? { + tools: bedrockTools, + toolChoice, + } + : hasToolContentInMessages && request.tools?.length + ? { + tools: request.tools.map((tool) => ({ + toolSpec: { + name: tool.id, + description: tool.description, + inputSchema: { + json: { + type: 'object', + properties: tool.parameters.properties, + required: tool.parameters.required, + }, + }, + }, + })), + toolChoice: { auto: {} }, + } + : undefined + + if (hasToolContentInMessages && !toolConfig) { + throw new Error( + 'Messages contain tool use/result blocks but no tools were provided. ' + + 'Bedrock requires toolConfig when processing messages with tool content.' + ) + } + + const systemPromptWithSchema = systemContent + + const inferenceConfig = { + temperature: Number.parseFloat(String(request.temperature ?? 0.7)), + maxTokens: Number.parseInt(String(request.maxTokens)) || 4096, + } + + const shouldStreamToolCalls = request.streamToolCalls ?? false + + if (request.stream && (!bedrockTools || bedrockTools.length === 0)) { + logger.info('Using streaming response for Bedrock request (no tools)') + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + const command = new ConverseStreamCommand({ + modelId: bedrockModelId, + messages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + }) + + const streamResponse = await client.send(command) + + if (!streamResponse.stream) { + throw new Error('No stream returned from Bedrock') + } + + const streamingResult = { + stream: createReadableStreamFromBedrockStream(streamResponse.stream, (content, usage) => { + streamingResult.execution.output.content = content + streamingResult.execution.output.tokens = { + input: usage.inputTokens, + output: usage.outputTokens, + total: usage.inputTokens + usage.outputTokens, + } + + const costResult = calculateCost(request.model, usage.inputTokens, usage.outputTokens) + streamingResult.execution.output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + + const streamEndTime = Date.now() + const streamEndTimeISO = new Date(streamEndTime).toISOString() + + if (streamingResult.execution.output.providerTiming) { + streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO + streamingResult.execution.output.providerTiming.duration = + streamEndTime - providerStartTime + + if (streamingResult.execution.output.providerTiming.timeSegments?.[0]) { + streamingResult.execution.output.providerTiming.timeSegments[0].endTime = + streamEndTime + streamingResult.execution.output.providerTiming.timeSegments[0].duration = + streamEndTime - providerStartTime + } + } + }), + execution: { + success: true, + output: { + content: '', + model: request.model, + tokens: { input: 0, output: 0, total: 0 }, + toolCalls: undefined, + providerTiming: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + timeSegments: [ + { + type: 'model', + name: 'Streaming response', + startTime: providerStartTime, + endTime: Date.now(), + duration: Date.now() - providerStartTime, + }, + ], + }, + cost: { + total: 0.0, + input: 0.0, + output: 0.0, + }, + }, + logs: [], + metadata: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + }, + isStreaming: true, + }, + } + + return streamingResult as StreamingExecution + } + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + + try { + const initialCallTime = Date.now() + const originalToolChoice = toolChoice + const forcedTools = preparedTools?.forcedTools || [] + let usedForcedTools: string[] = [] + + const command = new ConverseCommand({ + modelId: bedrockModelId, + messages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + toolConfig, + }) + + let currentResponse = await client.send(command) + const firstResponseTime = Date.now() - initialCallTime + + let content = '' + let hasExtractedStructuredOutput = false + if (currentResponse.output?.message?.content) { + const structuredOutputCall = currentResponse.output.message.content.find( + (block): block is ContentBlock & { toolUse: ToolUseBlock } => + 'toolUse' in block && block.toolUse?.name === structuredOutputToolName + ) + + if (structuredOutputCall && structuredOutputTool) { + content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2) + hasExtractedStructuredOutput = true + logger.info('Extracted structured output from tool call') + } else { + const textBlocks = currentResponse.output.message.content.filter( + (block): block is ContentBlock & { text: string } => 'text' in block + ) + content = textBlocks.map((block) => block.text).join('\n') + } + } + + const tokens = { + input: currentResponse.usage?.inputTokens || 0, + output: currentResponse.usage?.outputTokens || 0, + total: + (currentResponse.usage?.inputTokens || 0) + (currentResponse.usage?.outputTokens || 0), + } + + const initialCost = calculateCost( + request.model, + currentResponse.usage?.inputTokens || 0, + currentResponse.usage?.outputTokens || 0 + ) + const cost = { + input: initialCost.input, + output: initialCost.output, + total: initialCost.total, + } + + const toolCalls: any[] = [] + const toolResults: any[] = [] + const currentMessages = [...messages] + let iterationCount = 0 + let hasUsedForcedTool = false + let modelTime = firstResponseTime + let toolsTime = 0 + + const timeSegments: TimeSegment[] = [ + { + type: 'model', + name: 'Initial response', + startTime: initialCallTime, + endTime: initialCallTime + firstResponseTime, + duration: firstResponseTime, + }, + ] + + const initialToolUseContentBlocks = (currentResponse.output?.message?.content || []).filter( + (block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block + ) + const toolUseBlocks = initialToolUseContentBlocks.map((block) => ({ + name: block.toolUse.name || '', + })) + + const firstCheckResult = checkForForcedToolUsage( + toolUseBlocks, + originalToolChoice, + forcedTools, + usedForcedTools + ) + if (firstCheckResult) { + hasUsedForcedTool = firstCheckResult.hasUsedForcedTool + usedForcedTools = firstCheckResult.usedForcedTools + } + + while (iterationCount < MAX_TOOL_ITERATIONS) { + const textContentBlocks = (currentResponse.output?.message?.content || []).filter( + (block): block is ContentBlock & { text: string } => 'text' in block + ) + const textContent = textContentBlocks.map((block) => block.text).join('\n') + + if (textContent) { + content = textContent + } + + const toolUseContentBlocks = (currentResponse.output?.message?.content || []).filter( + (block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block + ) + const currentToolUses = toolUseContentBlocks.map((block) => block.toolUse) + + if (!currentToolUses || currentToolUses.length === 0) { + break + } + + const toolsStartTime = Date.now() + + const toolExecutionPromises = currentToolUses.map(async (toolUse: ToolUseBlock) => { + const toolCallStartTime = Date.now() + const toolName = toolUse.name || '' + const toolArgs = (toolUse.input as Record) || {} + const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) + + try { + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) return null + + const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) + const result = await executeTool(toolName, executionParams, true) + const toolCallEndTime = Date.now() + + return { + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } catch (error) { + const toolCallEndTime = Date.now() + logger.error('Error processing tool call:', { error, toolName }) + + return { + toolUseId, + toolName, + toolArgs, + toolParams: {}, + result: { + success: false, + output: undefined, + error: error instanceof Error ? error.message : 'Tool execution failed', + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + } + } + }) + + const executionResults = await Promise.allSettled(toolExecutionPromises) + + const assistantContent: ContentBlock[] = currentToolUses.map((toolUse: ToolUseBlock) => ({ + toolUse: { + toolUseId: toolUse.toolUseId, + name: toolUse.name, + input: toolUse.input, + }, + })) + currentMessages.push({ + role: 'assistant' as ConversationRole, + content: assistantContent, + }) + + const toolResultContent: ContentBlock[] = [] + + for (const settledResult of executionResults) { + if (settledResult.status === 'rejected' || !settledResult.value) continue + + const { + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime, + endTime, + duration, + } = settledResult.value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime, + endTime, + duration, + }) + + let resultContent: any + if (result.success) { + toolResults.push(result.output) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration, + result: resultContent, + success: result.success, + }) + + const toolResultBlock: ToolResultBlock = { + toolUseId, + content: [{ text: JSON.stringify(resultContent) }], + } + toolResultContent.push({ toolResult: toolResultBlock }) + } + + if (toolResultContent.length > 0) { + currentMessages.push({ + role: 'user' as ConversationRole, + content: toolResultContent, + }) + } + + const thisToolsTime = Date.now() - toolsStartTime + toolsTime += thisToolsTime + + let nextToolChoice = toolChoice + if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + + if (remainingTools.length > 0) { + nextToolChoice = { tool: { name: remainingTools[0] } } + logger.info(`Forcing next tool: ${remainingTools[0]}`) + } else { + nextToolChoice = { auto: {} } + logger.info('All forced tools have been used, switching to auto') + } + } else if (hasUsedForcedTool && typeof originalToolChoice === 'object') { + nextToolChoice = { auto: {} } + logger.info('Switching to auto tool choice after forced tool was used') + } + + const nextModelStartTime = Date.now() + + const nextCommand = new ConverseCommand({ + modelId: bedrockModelId, + messages: currentMessages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + toolConfig: bedrockTools?.length + ? { tools: bedrockTools, toolChoice: nextToolChoice } + : undefined, + }) + + currentResponse = await client.send(nextCommand) + + const nextToolUseContentBlocks = (currentResponse.output?.message?.content || []).filter( + (block): block is ContentBlock & { toolUse: ToolUseBlock } => 'toolUse' in block + ) + const nextToolUseBlocks = nextToolUseContentBlocks.map((block) => ({ + name: block.toolUse.name || '', + })) + + const nextCheckResult = checkForForcedToolUsage( + nextToolUseBlocks, + nextToolChoice, + forcedTools, + usedForcedTools + ) + if (nextCheckResult) { + hasUsedForcedTool = nextCheckResult.hasUsedForcedTool + usedForcedTools = nextCheckResult.usedForcedTools + } + + const nextModelEndTime = Date.now() + const thisModelTime = nextModelEndTime - nextModelStartTime + + timeSegments.push({ + type: 'model', + name: `Model response (iteration ${iterationCount + 1})`, + startTime: nextModelStartTime, + endTime: nextModelEndTime, + duration: thisModelTime, + }) + + modelTime += thisModelTime + + if (currentResponse.usage) { + tokens.input += currentResponse.usage.inputTokens || 0 + tokens.output += currentResponse.usage.outputTokens || 0 + tokens.total += + (currentResponse.usage.inputTokens || 0) + (currentResponse.usage.outputTokens || 0) + + const iterationCost = calculateCost( + request.model, + currentResponse.usage.inputTokens || 0, + currentResponse.usage.outputTokens || 0 + ) + cost.input += iterationCost.input + cost.output += iterationCost.output + cost.total += iterationCost.total + } + + iterationCount++ + } + + if (structuredOutputTool && request.tools?.length) { + logger.info('Making final call with forced structured_output tool') + + const structuredOutputStartTime = Date.now() + + const structuredOutputCommand = new ConverseCommand({ + modelId: bedrockModelId, + messages: currentMessages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + toolConfig: { + tools: [structuredOutputTool], + toolChoice: { tool: { name: structuredOutputToolName } }, + }, + }) + + const structuredResponse = await client.send(structuredOutputCommand) + const structuredOutputEndTime = Date.now() + + timeSegments.push({ + type: 'model', + name: 'Structured output extraction', + startTime: structuredOutputStartTime, + endTime: structuredOutputEndTime, + duration: structuredOutputEndTime - structuredOutputStartTime, + }) + + modelTime += structuredOutputEndTime - structuredOutputStartTime + + const structuredOutputCall = structuredResponse.output?.message?.content?.find( + (block): block is ContentBlock & { toolUse: ToolUseBlock } => + 'toolUse' in block && block.toolUse?.name === structuredOutputToolName + ) + + if (structuredOutputCall) { + content = JSON.stringify(structuredOutputCall.toolUse.input, null, 2) + hasExtractedStructuredOutput = true + logger.info('Extracted structured output from forced tool call') + } else { + logger.warn('Structured output tool was forced but no tool call found in response') + } + + if (structuredResponse.usage) { + tokens.input += structuredResponse.usage.inputTokens || 0 + tokens.output += structuredResponse.usage.outputTokens || 0 + tokens.total += + (structuredResponse.usage.inputTokens || 0) + + (structuredResponse.usage.outputTokens || 0) + + const structuredCost = calculateCost( + request.model, + structuredResponse.usage.inputTokens || 0, + structuredResponse.usage.outputTokens || 0 + ) + cost.input += structuredCost.input + cost.output += structuredCost.output + cost.total += structuredCost.total + } + } + + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + if (request.stream && !shouldStreamToolCalls && !hasExtractedStructuredOutput) { + logger.info('Using streaming for final Bedrock response after tool processing') + + const messagesHaveToolContent = currentMessages.some((msg) => + msg.content?.some( + (block) => + ('toolUse' in block && block.toolUse) || ('toolResult' in block && block.toolResult) + ) + ) + + const streamToolConfig: ToolConfiguration | undefined = + messagesHaveToolContent && request.tools?.length + ? { + tools: request.tools.map((tool) => ({ + toolSpec: { + name: tool.id, + description: tool.description, + inputSchema: { + json: { + type: 'object', + properties: tool.parameters.properties, + required: tool.parameters.required, + }, + }, + }, + })), + toolChoice: { auto: {} }, + } + : undefined + + const streamCommand = new ConverseStreamCommand({ + modelId: bedrockModelId, + messages: currentMessages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + toolConfig: streamToolConfig, + }) + + const streamResponse = await client.send(streamCommand) + + if (!streamResponse.stream) { + throw new Error('No stream returned from Bedrock') + } + + const streamingResult = { + stream: createReadableStreamFromBedrockStream( + streamResponse.stream, + (streamContent, usage) => { + streamingResult.execution.output.content = streamContent + streamingResult.execution.output.tokens = { + input: tokens.input + usage.inputTokens, + output: tokens.output + usage.outputTokens, + total: tokens.total + usage.inputTokens + usage.outputTokens, + } + + const streamCost = calculateCost(request.model, usage.inputTokens, usage.outputTokens) + streamingResult.execution.output.cost = { + input: cost.input + streamCost.input, + output: cost.output + streamCost.output, + total: cost.total + streamCost.total, + } + + const streamEndTime = Date.now() + const streamEndTimeISO = new Date(streamEndTime).toISOString() + + if (streamingResult.execution.output.providerTiming) { + streamingResult.execution.output.providerTiming.endTime = streamEndTimeISO + streamingResult.execution.output.providerTiming.duration = + streamEndTime - providerStartTime + } + } + ), + execution: { + success: true, + output: { + content: '', + model: request.model, + tokens: { + input: tokens.input, + output: tokens.output, + total: tokens.total, + }, + toolCalls: + toolCalls.length > 0 + ? { + list: toolCalls, + count: toolCalls.length, + } + : undefined, + providerTiming: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + cost: { + input: cost.input, + output: cost.output, + total: cost.total, + }, + }, + logs: [], + metadata: { + startTime: providerStartTimeISO, + endTime: new Date().toISOString(), + duration: Date.now() - providerStartTime, + }, + isStreaming: true, + }, + } + + return streamingResult as StreamingExecution + } + + return { + content, + model: request.model, + tokens, + toolCalls: + toolCalls.length > 0 + ? toolCalls.map((tc) => ({ + name: tc.name, + arguments: tc.arguments as Record, + startTime: tc.startTime, + endTime: tc.endTime, + duration: tc.duration, + result: tc.result, + })) + : undefined, + toolResults: toolResults.length > 0 ? toolResults : undefined, + timing: { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + timeSegments, + }, + } + } catch (error) { + const providerEndTime = Date.now() + const providerEndTimeISO = new Date(providerEndTime).toISOString() + const totalDuration = providerEndTime - providerStartTime + + logger.error('Error in Bedrock request:', { + error, + duration: totalDuration, + }) + + const enhancedError = new Error(error instanceof Error ? error.message : String(error)) + // @ts-ignore + enhancedError.timing = { + startTime: providerStartTimeISO, + endTime: providerEndTimeISO, + duration: totalDuration, + } + + throw enhancedError + } + }, +} diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts new file mode 100644 index 0000000000..0b92f247b4 --- /dev/null +++ b/apps/sim/providers/bedrock/utils.ts @@ -0,0 +1,108 @@ +import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' +import { createLogger } from '@sim/logger' +import { trackForcedToolUsage } from '@/providers/utils' + +const logger = createLogger('BedrockUtils') + +export interface BedrockStreamUsage { + inputTokens: number + outputTokens: number +} + +export function createReadableStreamFromBedrockStream( + bedrockStream: AsyncIterable, + onComplete?: (content: string, usage: BedrockStreamUsage) => void +): ReadableStream { + let fullContent = '' + let inputTokens = 0 + let outputTokens = 0 + + return new ReadableStream({ + async start(controller) { + try { + for await (const event of bedrockStream) { + if (event.contentBlockDelta?.delta?.text) { + const text = event.contentBlockDelta.delta.text + fullContent += text + controller.enqueue(new TextEncoder().encode(text)) + } else if (event.metadata?.usage) { + inputTokens = event.metadata.usage.inputTokens ?? 0 + outputTokens = event.metadata.usage.outputTokens ?? 0 + } + } + + if (onComplete) { + onComplete(fullContent, { inputTokens, outputTokens }) + } + + controller.close() + } catch (err) { + controller.error(err) + } + }, + }) +} + +export function checkForForcedToolUsage( + toolUseBlocks: Array<{ name: string }>, + toolChoice: any, + forcedTools: string[], + usedForcedTools: string[] +): { hasUsedForcedTool: boolean; usedForcedTools: string[] } | null { + if (typeof toolChoice === 'object' && toolChoice !== null && toolUseBlocks.length > 0) { + const adaptedToolCalls = toolUseBlocks.map((tool) => ({ name: tool.name })) + const adaptedToolChoice = toolChoice.tool + ? { function: { name: toolChoice.tool.name } } + : toolChoice + + return trackForcedToolUsage( + adaptedToolCalls, + adaptedToolChoice, + logger, + 'bedrock', + forcedTools, + usedForcedTools + ) + } + return null +} + +export function generateToolUseId(toolName: string): string { + return `${toolName}-${Date.now()}-${Math.random().toString(36).substring(2, 7)}` +} + +/** + * Converts a model ID to the Bedrock inference profile format. + * AWS Bedrock requires inference profile IDs (e.g., us.anthropic.claude-...) + * for on-demand invocation of newer models. + * + * @param modelId - The model ID (e.g., "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0") + * @param region - The AWS region (e.g., "us-east-1") + * @returns The inference profile ID (e.g., "us.anthropic.claude-sonnet-4-5-20250929-v1:0") + */ +export function getBedrockInferenceProfileId(modelId: string, region: string): string { + const baseModelId = modelId.startsWith('bedrock/') ? modelId.slice(8) : modelId + + if (/^(us-gov|us|eu|apac|au|ca|jp|global)\./.test(baseModelId)) { + return baseModelId + } + + let inferencePrefix: string + if (region.startsWith('us-gov-')) { + inferencePrefix = 'us-gov' + } else if (region.startsWith('us-') || region.startsWith('ca-')) { + inferencePrefix = 'us' + } else if (region.startsWith('eu-') || region === 'il-central-1') { + inferencePrefix = 'eu' + } else if (region.startsWith('ap-') || region.startsWith('me-')) { + inferencePrefix = 'apac' + } else if (region.startsWith('sa-')) { + inferencePrefix = 'us' + } else if (region.startsWith('af-')) { + inferencePrefix = 'eu' + } else { + inferencePrefix = 'us' + } + + return `${inferencePrefix}.${baseModelId}` +} diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 668e98e23e..eb655e66fb 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -11,6 +11,7 @@ import type React from 'react' import { AnthropicIcon, AzureIcon, + BedrockIcon, CerebrasIcon, DeepseekIcon, GeminiIcon, @@ -1632,6 +1633,408 @@ export const PROVIDER_DEFINITIONS: Record = { contextInformationAvailable: false, models: [], // Populated dynamically }, + bedrock: { + id: 'bedrock', + name: 'AWS Bedrock', + description: 'AWS Bedrock foundation models', + defaultModel: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + modelPatterns: [/^bedrock\//], + icon: BedrockIcon, + capabilities: { + temperature: { min: 0, max: 1 }, + toolUsageControl: true, + }, + models: [ + { + id: 'bedrock/anthropic.claude-opus-4-5-20251101-v1:0', + pricing: { + input: 5.0, + output: 25.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + }, + contextWindow: 200000, + }, + { + id: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + pricing: { + input: 3.0, + output: 15.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + }, + contextWindow: 200000, + }, + { + id: 'bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', + pricing: { + input: 1.0, + output: 5.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + }, + contextWindow: 200000, + }, + { + id: 'bedrock/anthropic.claude-opus-4-1-20250805-v1:0', + pricing: { + input: 15.0, + output: 75.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + nativeStructuredOutputs: true, + }, + contextWindow: 200000, + }, + { + id: 'bedrock/amazon.nova-2-pro-v1:0', + pricing: { + input: 1.0, + output: 4.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 1000000, + }, + { + id: 'bedrock/amazon.nova-2-lite-v1:0', + pricing: { + input: 0.08, + output: 0.32, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 1000000, + }, + { + id: 'bedrock/amazon.nova-premier-v1:0', + pricing: { + input: 2.5, + output: 10.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 1000000, + }, + { + id: 'bedrock/amazon.nova-pro-v1:0', + pricing: { + input: 0.8, + output: 3.2, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 300000, + }, + { + id: 'bedrock/amazon.nova-lite-v1:0', + pricing: { + input: 0.06, + output: 0.24, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 300000, + }, + { + id: 'bedrock/amazon.nova-micro-v1:0', + pricing: { + input: 0.035, + output: 0.14, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama4-maverick-17b-instruct-v1:0', + pricing: { + input: 0.24, + output: 0.97, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 1000000, + }, + { + id: 'bedrock/meta.llama4-scout-17b-instruct-v1:0', + pricing: { + input: 0.18, + output: 0.72, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 3500000, + }, + { + id: 'bedrock/meta.llama3-3-70b-instruct-v1:0', + pricing: { + input: 0.72, + output: 0.72, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama3-2-90b-instruct-v1:0', + pricing: { + input: 2.0, + output: 2.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama3-2-11b-instruct-v1:0', + pricing: { + input: 0.16, + output: 0.16, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama3-2-3b-instruct-v1:0', + pricing: { + input: 0.15, + output: 0.15, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama3-2-1b-instruct-v1:0', + pricing: { + input: 0.1, + output: 0.1, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama3-1-405b-instruct-v1:0', + pricing: { + input: 5.32, + output: 16.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama3-1-70b-instruct-v1:0', + pricing: { + input: 2.65, + output: 3.5, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/meta.llama3-1-8b-instruct-v1:0', + pricing: { + input: 0.3, + output: 0.6, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.mistral-large-3-675b-instruct', + pricing: { + input: 2.0, + output: 6.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.mistral-large-2411-v1:0', + pricing: { + input: 2.0, + output: 6.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.mistral-large-2407-v1:0', + pricing: { + input: 4.0, + output: 12.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.pixtral-large-2502-v1:0', + pricing: { + input: 2.0, + output: 6.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.magistral-small-2509', + pricing: { + input: 0.5, + output: 1.5, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.ministral-3-14b-instruct', + pricing: { + input: 0.2, + output: 0.2, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.ministral-3-8b-instruct', + pricing: { + input: 0.1, + output: 0.1, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.ministral-3-3b-instruct', + pricing: { + input: 0.04, + output: 0.04, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/mistral.mixtral-8x7b-instruct-v0:1', + pricing: { + input: 0.45, + output: 0.7, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 32000, + }, + { + id: 'bedrock/amazon.titan-text-premier-v1:0', + pricing: { + input: 0.5, + output: 1.5, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 32000, + }, + { + id: 'bedrock/cohere.command-r-plus-v1:0', + pricing: { + input: 3.0, + output: 15.0, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + { + id: 'bedrock/cohere.command-r-v1:0', + pricing: { + input: 0.5, + output: 1.5, + updatedAt: '2026-01-07', + }, + capabilities: { + temperature: { min: 0, max: 1 }, + }, + contextWindow: 128000, + }, + ], + }, } export function getProviderModels(providerId: string): string[] { diff --git a/apps/sim/providers/registry.ts b/apps/sim/providers/registry.ts index ed30f35900..1b12656b91 100644 --- a/apps/sim/providers/registry.ts +++ b/apps/sim/providers/registry.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { anthropicProvider } from '@/providers/anthropic' import { azureOpenAIProvider } from '@/providers/azure-openai' +import { bedrockProvider } from '@/providers/bedrock' import { cerebrasProvider } from '@/providers/cerebras' import { deepseekProvider } from '@/providers/deepseek' import { googleProvider } from '@/providers/google' @@ -30,6 +31,7 @@ const providerRegistry: Record = { 'azure-openai': azureOpenAIProvider, openrouter: openRouterProvider, ollama: ollamaProvider, + bedrock: bedrockProvider, } export async function getProviderExecutor( diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index e3b593aef2..3522a6f026 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -14,10 +14,8 @@ export type ProviderId = | 'ollama' | 'openrouter' | 'vllm' + | 'bedrock' -/** - * Model pricing information per million tokens - */ export interface ModelPricing { input: number // Per 1M tokens cachedInput?: number // Per 1M tokens (if supported) @@ -163,6 +161,9 @@ export interface ProviderRequest { azureApiVersion?: string vertexProject?: string vertexLocation?: string + bedrockAccessKeyId?: string + bedrockSecretKey?: string + bedrockRegion?: string reasoningEffort?: string verbosity?: string thinkingLevel?: string diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index cb5042c9a9..1826bb40cd 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -88,6 +88,7 @@ export const providers: Record = { 'azure-openai': buildProviderMetadata('azure-openai'), openrouter: buildProviderMetadata('openrouter'), ollama: buildProviderMetadata('ollama'), + bedrock: buildProviderMetadata('bedrock'), } export function updateOllamaProviderModels(models: string[]): void { @@ -622,6 +623,12 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str return userProvidedKey || 'empty' } + // Bedrock uses its own credentials (bedrockAccessKeyId/bedrockSecretKey), not apiKey + const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/') + if (isBedrockModel) { + return 'bedrock-uses-own-credentials' + } + const isOpenAIModel = provider === 'openai' const isClaudeModel = provider === 'anthropic' const isGeminiModel = provider === 'google' diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index a6863dafb5..ac45f63ef4 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -16,6 +16,9 @@ interface LLMChatParams { vertexProject?: string vertexLocation?: string vertexCredential?: string + bedrockAccessKeyId?: string + bedrockSecretKey?: string + bedrockRegion?: string } interface LLMChatResponse extends ToolResponse { @@ -98,6 +101,24 @@ export const llmChatTool: ToolConfig = { visibility: 'hidden', description: 'Google Cloud OAuth credential ID for Vertex AI', }, + bedrockAccessKeyId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'AWS Access Key ID for Bedrock', + }, + bedrockSecretKey: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'AWS Secret Access Key for Bedrock', + }, + bedrockRegion: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'AWS region for Bedrock (defaults to us-east-1)', + }, }, request: { @@ -122,6 +143,9 @@ export const llmChatTool: ToolConfig = { vertexProject: params.vertexProject, vertexLocation: params.vertexLocation, vertexCredential: params.vertexCredential, + bedrockAccessKeyId: params.bedrockAccessKeyId, + bedrockSecretKey: params.bedrockSecretKey, + bedrockRegion: params.bedrockRegion, } }, }, diff --git a/bun.lock b/bun.lock index fc86d072df..47bad97dd4 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@aws-sdk/client-bedrock-runtime": "3.940.0", "@aws-sdk/client-dynamodb": "3.940.0", "@aws-sdk/client-rds-data": "3.940.0", "@aws-sdk/client-s3": "^3.779.0", @@ -377,6 +378,8 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/eventstream-handler-node": "3.936.0", "@aws-sdk/middleware-eventstream": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/middleware-websocket": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/token-providers": "3.940.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Gs6UUQP1zt8vahOxJ3BADcb3B+2KldUNA3bKa+KdK58de7N7tLJFJfZuXhFGGtwyNPh1aw6phtdP6dauq3OLWA=="], + "@aws-sdk/client-dynamodb": ["@aws-sdk/client-dynamodb@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-endpoint-discovery": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ=="], "@aws-sdk/client-rds-data": ["@aws-sdk/client-rds-data@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-68NH61MvS48CVPfzBNCPdCG4KnNjM+Uj/3DSw7rT9PJvdML9ARS4M2Uqco9POPw+Aj20KBumsEUd6FMVcYBXAA=="], @@ -411,12 +414,16 @@ "@aws-sdk/endpoint-cache": ["@aws-sdk/endpoint-cache@3.893.0", "", { "dependencies": { "mnemonist": "0.38.3", "tslib": "^2.6.2" } }, "sha512-KSwTfyLZyNLszz5f/yoLC+LC+CRKpeJii/+zVAy7JUOQsKhSykiRUPYUx7o2Sdc4oJfqqUl26A/jSttKYnYtAA=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/eventstream-codec": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-4zIbhdRmol2KosIHmU31ATvNP0tkJhDlRj9GuawVJoEnMvJA1pd2U3SRdiOImJU3j8pT46VeS4YMmYxfjGHByg=="], + "@aws-sdk/lib-dynamodb": ["@aws-sdk/lib-dynamodb@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/util-dynamodb": "3.940.0", "@smithy/core": "^3.18.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-dynamodb": "^3.940.0" } }, "sha512-5ApYAix2wvJuMszj1lrpg8lm4ipoZMFO8crxtzsdAvxM8TV5bKSRQQ2GA3CMIODrBuSzpXvWueHHrfkx05ZAQw=="], "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws-sdk/util-arn-parser": "3.957.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iczcn/QRIBSpvsdAS/rbzmoBpleX1JBjXvCynMbDceVLBIcVrwT1hXECrhtIC2cjh4HaLo9ClAbiOiWuqt+6MA=="], "@aws-sdk/middleware-endpoint-discovery": ["@aws-sdk/middleware-endpoint-discovery@3.936.0", "", { "dependencies": { "@aws-sdk/endpoint-cache": "3.893.0", "@aws-sdk/types": "3.936.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wNJZ8PDw0eQK2x4z1q8JqiDvw9l9xd36EoklVT2CIBt8FnqGdrMGjAx93RRbH3G6Fmvwoe+D3VJXbWHBlhD0Bw=="], + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-XQSH8gzLkk8CDUDxyt4Rdm9owTpRIPdtg2yw9Y2Wl5iSI55YQSiC3x8nM3c4Y4WqReJprunFPK225ZUDoYCfZA=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-AlbK3OeVNwZZil0wlClgeI/ISlOt/SPUxBsIns876IFaVu/Pj3DgImnYhpcJuFRek4r4XM51xzIaGQXM6GDHGg=="], "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.957.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/crc64-nvme": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA=="], @@ -437,6 +444,8 @@ "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.940.0", "", { "dependencies": { "@aws-sdk/core": "3.940.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@smithy/core": "^3.18.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-nJbLrUj6fY+l2W2rIB9P4Qvpiy0tnTdg/dmixRxrU1z3e8wBdspJlyE+AZN4fuVbeL6rrRrO/zxQC1bB3cw5IA=="], + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/util-format-url": "3.936.0", "@smithy/eventstream-codec": "^4.2.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-bPe3rqeugyj/MmjP0yBSZox2v1Wa8Dv39KN+RxVbQroLO8VUitBo6xyZ0oZebhZ5sASwSg58aDcMlX0uFLQnTA=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-x0mdv6DkjXqXEcQj3URbCltEzW6hoy/1uIL+i8gExP6YKrnhiZ7SzuB4gPls2UOpK5UqLiqXjhRLfBb1C9i4Dw=="], "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw=="], @@ -3649,6 +3658,8 @@ "@aws-sdk/middleware-ssec/@aws-sdk/types": ["@aws-sdk/types@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg=="], + "@aws-sdk/middleware-websocket/@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-MS5eSEtDUFIAMHrJaMERiHAvDPdfxc/T869ZjDNFAIiZhyc037REw0aoTNeimNXDNy2txRNZJaAUn/kE4RwN+g=="], + "@aws-sdk/s3-request-presigner/@aws-sdk/types": ["@aws-sdk/types@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg=="], "@aws-sdk/signature-v4-multi-region/@aws-sdk/types": ["@aws-sdk/types@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg=="], From 1b22d2ce8180a0f5769095ed380d69110f639ec3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 8 Jan 2026 10:50:29 +0530 Subject: [PATCH 03/12] fix(devcontainer): use bunx for concurrently command (#2723) --- apps/sim/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index f627227d9a..9dc7abd47c 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -11,7 +11,7 @@ "dev": "next dev --port 3000", "dev:webpack": "next dev --webpack", "dev:sockets": "bun run socket/index.ts", - "dev:full": "concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", + "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", "build": "next build", "start": "next start", "prepare": "cd ../.. && bun husky", From 05904a73b2637e41f1ce5e2a7c1a988f5b6e05f4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 8 Jan 2026 10:30:53 -0800 Subject: [PATCH 04/12] feat(i18n): update translations (#2721) Co-authored-by: icecrasher321 --- .../content/docs/de/mcp/deploy-workflows.mdx | 4 +- apps/docs/content/docs/de/mcp/index.mdx | 2 +- apps/docs/content/docs/de/triggers/index.mdx | 28 +++++++++++- .../content/docs/es/mcp/deploy-workflows.mdx | 4 +- apps/docs/content/docs/es/mcp/index.mdx | 4 +- apps/docs/content/docs/es/triggers/index.mdx | 34 ++++++++++++-- .../content/docs/fr/mcp/deploy-workflows.mdx | 6 +-- apps/docs/content/docs/fr/mcp/index.mdx | 2 +- apps/docs/content/docs/fr/triggers/index.mdx | 36 ++++++++++++--- .../content/docs/ja/mcp/deploy-workflows.mdx | 12 ++--- apps/docs/content/docs/ja/mcp/index.mdx | 6 +-- apps/docs/content/docs/ja/triggers/index.mdx | 38 +++++++++++++--- .../content/docs/zh/mcp/deploy-workflows.mdx | 8 ++-- apps/docs/content/docs/zh/mcp/index.mdx | 6 +-- apps/docs/content/docs/zh/triggers/index.mdx | 44 +++++++++++++++---- apps/docs/i18n.lock | 19 +++++--- 16 files changed, 196 insertions(+), 57 deletions(-) diff --git a/apps/docs/content/docs/de/mcp/deploy-workflows.mdx b/apps/docs/content/docs/de/mcp/deploy-workflows.mdx index 074d0a5849..a782869af7 100644 --- a/apps/docs/content/docs/de/mcp/deploy-workflows.mdx +++ b/apps/docs/content/docs/de/mcp/deploy-workflows.mdx @@ -17,7 +17,7 @@ MCP-Server gruppieren Ihre Workflow-Tools zusammen. Erstellen und verwalten Sie
-1. Navigieren Sie zu **Einstellungen → MCP-Server** +1. Navigieren Sie zu **Einstellungen → Bereitgestellte MCPs** 2. Klicken Sie auf **Server erstellen** 3. Geben Sie einen Namen und eine optionale Beschreibung ein 4. Kopieren Sie die Server-URL zur Verwendung in Ihren MCP-Clients @@ -79,7 +79,7 @@ Füge deinen API-Key-Header (`X-API-Key`) für authentifizierten Zugriff hinzu, ## Server-Verwaltung -In der Server-Detailansicht unter **Einstellungen → MCP-Server** kannst du: +In der Server-Detailansicht unter **Einstellungen → Bereitgestellte MCPs** können Sie: - **Tools anzeigen**: Alle Workflows sehen, die einem Server hinzugefügt wurden - **URL kopieren**: Die Server-URL für MCP-Clients abrufen diff --git a/apps/docs/content/docs/de/mcp/index.mdx b/apps/docs/content/docs/de/mcp/index.mdx index 12d5713bdd..77edb5b72c 100644 --- a/apps/docs/content/docs/de/mcp/index.mdx +++ b/apps/docs/content/docs/de/mcp/index.mdx @@ -27,7 +27,7 @@ MCP-Server stellen Sammlungen von Tools bereit, die Ihre Agenten nutzen können.
1. Navigieren Sie zu Ihren Workspace-Einstellungen -2. Gehen Sie zum Abschnitt **MCP-Server** +2. Gehen Sie zum Abschnitt **Bereitgestellte MCPs** 3. Klicken Sie auf **MCP-Server hinzufügen** 4. Geben Sie die Server-Konfigurationsdetails ein 5. Speichern Sie die Konfiguration diff --git a/apps/docs/content/docs/de/triggers/index.mdx b/apps/docs/content/docs/de/triggers/index.mdx index 8fc867bd5a..23b51adf27 100644 --- a/apps/docs/content/docs/de/triggers/index.mdx +++ b/apps/docs/content/docs/de/triggers/index.mdx @@ -22,7 +22,7 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl - Einheitlicher Einstiegspunkt, der Editor-Ausführungen, API-Bereitstellungen und Chat-Bereitstellungen unterstützt + Einheitlicher Einstiegspunkt, der Editor-Ausführungen, API-Deployments und Chat-Deployments unterstützt Externe Webhook-Payloads empfangen @@ -33,6 +33,9 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl RSS- und Atom-Feeds auf neue Inhalte überwachen + + Team-Gmail- und Outlook-Postfächer überwachen + ## Schneller Vergleich @@ -43,6 +46,7 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl | **Schedule** | Timer, der im Schedule-Block verwaltet wird | | **Webhook** | Bei eingehender HTTP-Anfrage | | **RSS Feed** | Neues Element im Feed veröffentlicht | +| **Email Polling Groups** | Neue E-Mail in Team-Gmail- oder Outlook-Postfächern empfangen | > Der Start-Block stellt immer `input`, `conversationId` und `files` Felder bereit. Füge benutzerdefinierte Felder zum Eingabeformat für zusätzliche strukturierte Daten hinzu. @@ -65,3 +69,25 @@ Wenn du im Editor auf **Run** klickst, wählt Sim automatisch aus, welcher Trigg Wenn dein Workflow mehrere Trigger hat, wird der Trigger mit der höchsten Priorität ausgeführt. Wenn du beispielsweise sowohl einen Start-Block als auch einen Webhook-Trigger hast, wird beim Klicken auf Run der Start-Block ausgeführt. **Externe Auslöser mit Mock-Payloads**: Wenn externe Auslöser (Webhooks und Integrationen) manuell ausgeführt werden, generiert Sim automatisch Mock-Payloads basierend auf der erwarteten Datenstruktur des Auslösers. Dies stellt sicher, dass nachgelagerte Blöcke während des Testens Variablen korrekt auflösen können. + +## E-Mail-Polling-Gruppen + +Polling-Gruppen ermöglichen es Ihnen, die Gmail- oder Outlook-Postfächer mehrerer Teammitglieder mit einem einzigen Trigger zu überwachen. Erfordert einen Team- oder Enterprise-Plan. + +**Erstellen einer Polling-Gruppe** (Admin/Owner) + +1. Gehen Sie zu **Einstellungen → E-Mail-Polling** +2. Klicken Sie auf **Erstellen** und wählen Sie Gmail oder Outlook +3. Geben Sie einen Namen für die Gruppe ein + +**Mitglieder einladen** + +1. Klicken Sie auf **Mitglieder hinzufügen** bei Ihrer Polling-Gruppe +2. Geben Sie E-Mail-Adressen ein (durch Komma oder Zeilenumbruch getrennt oder ziehen Sie eine CSV-Datei per Drag & Drop) +3. Klicken Sie auf **Einladungen senden** + +Eingeladene erhalten eine E-Mail mit einem Link, um ihr Konto zu verbinden. Sobald die Verbindung hergestellt ist, wird ihr Postfach automatisch in die Polling-Gruppe aufgenommen. Eingeladene müssen keine Mitglieder Ihrer Sim-Organisation sein. + +**Verwendung in einem Workflow** + +Wählen Sie beim Konfigurieren eines E-Mail-Triggers Ihre Polling-Gruppe aus dem Dropdown-Menü für Anmeldeinformationen anstelle eines einzelnen Kontos aus. Das System erstellt Webhooks für jedes Mitglied und leitet alle E-Mails durch Ihren Workflow. diff --git a/apps/docs/content/docs/es/mcp/deploy-workflows.mdx b/apps/docs/content/docs/es/mcp/deploy-workflows.mdx index 82869165fa..5e2c6c51ae 100644 --- a/apps/docs/content/docs/es/mcp/deploy-workflows.mdx +++ b/apps/docs/content/docs/es/mcp/deploy-workflows.mdx @@ -17,7 +17,7 @@ Los servidores MCP agrupan tus herramientas de flujo de trabajo. Créalos y gest