From a518e4311a6e7ea85036abf372ee5493959b634d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 30 Jun 2026 18:35:10 -0700 Subject: [PATCH 01/22] feat(slack): enable assistant-agent tools via assistant:write scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add assistant:write, app_mentions:read, and im:history to the Slack bot OAuth scopes so the Set Assistant Status / Title / Suggested Prompts tools (assistant.threads.*) work with users' existing Slack credentials — no new app or credentials required. Restore the action_assistant trigger capability (scope assistant:write) in the manifest generator. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01WpeT8J5yVCrrNQB9Hzm9uS --- apps/sim/lib/oauth/oauth.ts | 4 +++- apps/sim/triggers/slack/capabilities.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 8f6b17100fe..5e40b21dcd7 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -711,7 +711,9 @@ export const OAUTH_PROVIDERS: Record = { 'groups:write', 'chat:write', 'chat:write.public', - // TODO: Add 'assistant:write' once Slack app review is approved + 'assistant:write', + 'app_mentions:read', + 'im:history', 'im:write', 'im:read', 'users:read', diff --git a/apps/sim/triggers/slack/capabilities.ts b/apps/sim/triggers/slack/capabilities.ts index 317c240621f..1774ba11a9b 100644 --- a/apps/sim/triggers/slack/capabilities.ts +++ b/apps/sim/triggers/slack/capabilities.ts @@ -105,7 +105,16 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ scopes: ['channels:history', 'groups:history', 'im:history', 'mpim:history'], events: [], }, - // TODO: Restore the 'action_assistant' capability (scope 'assistant:write') once Slack app review is approved + { + id: 'action_assistant', + label: 'Drive assistant threads', + description: + 'Let the bot set the assistant status, title, and suggested prompts on assistant threads (assistant.threads.*).', + defaultChecked: true, + group: 'action', + scopes: ['assistant:write'], + events: [], + }, { id: 'action_read_files', label: 'Read file attachments', From ec1eb6222ccb451e368d4b143e723d55117b0012 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 30 Jun 2026 20:16:34 -0700 Subject: [PATCH 02/22] Add slack trigger --- apps/sim/app/api/webhooks/route.ts | 2 +- apps/sim/app/api/webhooks/slack/route.ts | 191 + apps/sim/blocks/blocks/slack.ts | 4 +- apps/sim/blocks/registry-maps.minimal.ts | 2 + apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/credentials/deletion.ts | 20 + apps/sim/lib/webhooks/deploy.ts | 125 +- apps/sim/lib/webhooks/processor.ts | 56 +- apps/sim/lib/webhooks/providers/registry.ts | 3 + apps/sim/lib/webhooks/providers/slack.ts | 91 +- apps/sim/lib/webhooks/utils.server.ts | 5 +- apps/sim/lib/workspace-events/emitter.ts | 2 +- apps/sim/lib/workspace-events/no-activity.ts | 2 +- apps/sim/triggers/registry.ts | 3 +- apps/sim/triggers/slack/index.ts | 1 + apps/sim/triggers/slack/oauth.ts | 135 + apps/sim/triggers/slack/shared.ts | 177 + apps/sim/triggers/slack/webhook.ts | 144 +- .../db/migrations/0253_fearless_gateway.sql | 3 + .../db/migrations/meta/0253_snapshot.json | 17030 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 18 +- 22 files changed, 17809 insertions(+), 213 deletions(-) create mode 100644 apps/sim/app/api/webhooks/slack/route.ts create mode 100644 apps/sim/triggers/slack/oauth.ts create mode 100644 apps/sim/triggers/slack/shared.ts create mode 100644 packages/db/migrations/0253_fearless_gateway.sql create mode 100644 packages/db/migrations/meta/0253_snapshot.json diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 8c7fdec2ee8..71bd4c4c971 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -246,7 +246,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .limit(1) if (existingForBlock.length > 0) { - finalPath = existingForBlock[0].path + finalPath = existingForBlock[0].path ?? '' logger.info( `[${requestId}] Reusing existing generated path for ${provider} trigger: ${finalPath}` ) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts new file mode 100644 index 00000000000..1b6d58865a2 --- /dev/null +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -0,0 +1,191 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' +import { env } from '@/lib/core/config/env' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + checkWebhookPreprocessing, + findWebhooksByRoutingKey, + parseWebhookBody, + queueWebhookExecution, +} from '@/lib/webhooks/processor' +import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' +import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' +import { SLACK_CHANNEL_SCOPED_EVENTS } from '@/triggers/slack/shared' + +const logger = createLogger('SlackAppWebhookAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 60 + +/** + * Maps an inbound Slack Events API payload to one of the selectable trigger + * event ids (see SLACK_TRIGGER_EVENT_OPTIONS). Returns null for payloads we do + * not surface as trigger operations. + */ +function resolveSlackEventKey(body: Record): string | null { + const event = body.event as Record | undefined + if (!event) return null + const type = event.type as string | undefined + + if (type === 'app_mention') return 'app_mention' + if (type === 'reaction_added') return 'reaction_added' + if (type === 'reaction_removed') return 'reaction_removed' + + if (type === 'message') { + switch (event.channel_type as string | undefined) { + case 'im': + return 'message.im' + case 'channel': + return 'message.channels' + case 'group': + return 'message.groups' + default: + return null + } + } + + return null +} + +/** True when the message originated from a bot (used to break agent loops). */ +function isBotMessage(body: Record): boolean { + const event = body.event as Record | undefined + if (!event) return false + return Boolean(event.bot_id) || event.subtype === 'bot_message' +} + +/** Channel the event occurred in (reactions carry it under `item.channel`). */ +function resolveEventChannel(body: Record): string | undefined { + const event = body.event as Record | undefined + if (!event) return undefined + if (typeof event.channel === 'string') return event.channel + const item = event.item as Record | undefined + return typeof item?.channel === 'string' ? item.channel : undefined +} + +function normalizeSelection(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String) + if (typeof value === 'string' && value.length > 0) return value.split(',').map((v) => v.trim()) + return [] +} + +/** + * Single ingest endpoint for the official Sim Slack app. Every workspace's + * events arrive here and are routed to listening workflows by Slack `team_id` + * after HMAC verification with the shared app signing secret. This is the + * request URL configured in the app's Event Subscriptions. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const ticket = tryAdmit() + if (!ticket) { + return admissionRejectedResponse() + } + + try { + return await handleSlackAppWebhook(request) + } finally { + ticket.release() + } +}) + +async function handleSlackAppWebhook(request: NextRequest): Promise { + const receivedAt = Date.now() + const requestId = generateRequestId() + + const parseResult = await parseWebhookBody(request, requestId) + if (parseResult instanceof NextResponse) { + return parseResult + } + const { body, rawBody } = parseResult + + // Slack's endpoint verification handshake — echo the challenge back. + const challenge = handleSlackChallenge(body) + if (challenge) { + return challenge + } + + const signingSecret = env.SLACK_SIGNING_SECRET + if (!signingSecret) { + logger.error(`[${requestId}] SLACK_SIGNING_SECRET is not configured`) + return new NextResponse('Slack app not configured', { status: 500 }) + } + + const authError = verifySlackRequestSignature(signingSecret, request, rawBody, requestId) + if (authError) { + return authError + } + + const payload = body as Record + const teamId = + typeof payload.team_id === 'string' && payload.team_id.length > 0 ? payload.team_id : null + if (!teamId) { + logger.warn(`[${requestId}] Slack event missing team_id`) + return new NextResponse(null, { status: 200 }) + } + + const webhooks = await findWebhooksByRoutingKey(teamId, requestId) + if (webhooks.length === 0) { + return new NextResponse(null, { status: 200 }) + } + + const eventKey = resolveSlackEventKey(payload) + const eventChannel = resolveEventChannel(payload) + const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') + const triggerTimestampMs = slackRequestTimestamp + ? Number(slackRequestTimestamp) * 1000 + : undefined + + for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooks) { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + + const selectedEvents = normalizeSelection(providerConfig.events) + if (eventKey && selectedEvents.length > 0 && !selectedEvents.includes(eventKey)) { + continue + } + + // Channel filter applies only to channel-scoped events, never to DMs. + if (eventKey && SLACK_CHANNEL_SCOPED_EVENTS.has(eventKey)) { + const selectedChannels = normalizeSelection(providerConfig.channelFilter) + if ( + selectedChannels.length > 0 && + (!eventChannel || !selectedChannels.includes(eventChannel)) + ) { + continue + } + } + + if (providerConfig.filterBotMessages !== false && isBotMessage(payload)) { + continue + } + + if (foundWebhook.blockId) { + const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) + if (!blockExists) { + logger.info( + `[${requestId}] Trigger block ${foundWebhook.blockId} not in deployment for ${foundWorkflow.id}` + ) + continue + } + } + + const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) + if (preprocessResult.error) { + logger.warn(`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}`) + continue + } + + await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { + requestId, + actorUserId: preprocessResult.actorUserId, + executionId: preprocessResult.executionId, + correlation: preprocessResult.correlation, + receivedAt, + triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined, + }) + } + + return new NextResponse(null, { status: 200 }) +} diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index bb663157a91..b608f7eed1f 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1344,7 +1344,7 @@ Do not include any explanations, markdown formatting, or other text outside the placeholder: 'Describe the view/modal you want to create...', }, }, - ...getTrigger('slack_webhook').subBlocks, + ...getTrigger('slack_oauth').subBlocks, ], tools: { access: [ @@ -2227,7 +2227,7 @@ Do not include any explanations, markdown formatting, or other text outside the // New: Trigger capabilities triggers: { enabled: true, - available: ['slack_webhook'], + available: ['slack_oauth'], }, } diff --git a/apps/sim/blocks/registry-maps.minimal.ts b/apps/sim/blocks/registry-maps.minimal.ts index 585da915b1c..9195eb5367b 100644 --- a/apps/sim/blocks/registry-maps.minimal.ts +++ b/apps/sim/blocks/registry-maps.minimal.ts @@ -23,6 +23,7 @@ import { RssBlock } from '@/blocks/blocks/rss' import { ScheduleBlock } from '@/blocks/blocks/schedule' import { SearchBlock } from '@/blocks/blocks/search' import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event' +import { SlackBlock } from '@/blocks/blocks/slack' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { TableBlock } from '@/blocks/blocks/table' import { TranslateBlock } from '@/blocks/blocks/translate' @@ -72,6 +73,7 @@ export const BLOCK_REGISTRY: Record = { schedule: ScheduleBlock, search: SearchBlock, sim_workspace_event: SimWorkspaceEventBlock, + slack: SlackBlock, start_trigger: StartTriggerBlock, table: TableBlock, translate: TranslateBlock, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index dd88a3ec5bb..805890cf3d5 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -368,6 +368,7 @@ export const env = createEnv({ DROPBOX_CLIENT_SECRET: z.string().optional(), // Dropbox OAuth client secret SLACK_CLIENT_ID: z.string().optional(), // Slack OAuth client ID SLACK_CLIENT_SECRET: z.string().optional(), // Slack OAuth client secret + SLACK_SIGNING_SECRET: z.string().optional(), // Official Sim Slack app signing secret (verifies inbound events for the native OAuth trigger) REDDIT_CLIENT_ID: z.string().optional(), // Reddit OAuth client ID REDDIT_CLIENT_SECRET: z.string().optional(), // Reddit OAuth client secret WEBFLOW_CLIENT_ID: z.string().optional(), // Webflow OAuth client ID diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index c8912a53765..2678675bd9d 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -90,9 +90,29 @@ export async function clearCredentialRefs( clearInPausedExecutions(credentialId, workspaceId, needle), clearInWorkflowCheckpoints(credentialId, workspaceId, needle), clearInKnowledgeConnectors(credentialId), + deactivateSlackAppWebhooks(credentialId), ]) } +/** + * Deactivates native Slack (`slack_app`) trigger webhooks bound to this + * credential so inbound events stop routing once the account is disconnected. + * The credential id lives in `providerConfig`, not a foreign key, so it is not + * covered by CASCADE. + */ +async function deactivateSlackAppWebhooks(credentialId: string): Promise { + await db + .update(schema.webhook) + .set({ isActive: false, updatedAt: new Date() }) + .where( + and( + eq(schema.webhook.provider, 'slack_app'), + eq(schema.webhook.isActive, true), + sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}` + ) + ) +} + async function clearInWorkflowBlocks( credentialId: string, workspaceId: string, diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 686c974df5a..8037b637b7d 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -13,11 +13,13 @@ import { shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' +import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack' import { findConflictingWebhookPathOwner, syncWebhooksForCredentialSet, } from '@/lib/webhooks/utils.server' import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -503,7 +505,8 @@ export async function saveTriggerWebhooksForDeploy({ type WebhookConfig = { provider: string providerConfig: Record - triggerPath: string + triggerPath: string | null + routingKey: string | null triggerDef: ReturnType } const webhookConfigs = new Map() @@ -518,7 +521,7 @@ export async function saveTriggerWebhooksForDeploy({ const triggerDef = getTrigger(triggerId) const provider = triggerDef.provider - const { providerConfig, missingFields, triggerPath } = buildProviderConfig( + const { providerConfig, missingFields, triggerPath, credentialId } = buildProviderConfig( block, triggerId, triggerDef @@ -545,24 +548,81 @@ export async function saveTriggerWebhooksForDeploy({ } } - const pathConflict = await findConflictingWebhookPathOwner({ - path: triggerPath, - workflowId, - }) - if (pathConflict) { - logger.warn( - `[${requestId}] Webhook path conflict for "${triggerPath}": already owned by workflow ${pathConflict}` - ) - return { - success: false, - error: { - message: `Webhook path "${triggerPath}" is already in use. Choose a different path.`, - status: 409, - }, + /** + * The unified Slack trigger (`slack_oauth`) resolves to one of two backends + * by App Type: `sim` routes inbound events on the official Sim app by Slack + * `team_id` (routingKey, no path); `custom` reuses the legacy per-workflow + * `slack` webhook (path + signing secret). The team_id is derived here from + * the connected account via `auth.test` — never from user input. + */ + let effectiveProvider = provider + let effectivePath: string | null = triggerPath + let routingKey: string | null = null + if (triggerId === 'slack_oauth') { + const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'sim' + if (appType === 'sim') { + if (!credentialId) { + return { + success: false, + error: { message: 'Select a Slack account for the trigger.', status: 400 }, + } + } + const botToken = await refreshAccessTokenIfNeeded(credentialId, userId, requestId) + if (!botToken) { + return { + success: false, + error: { + message: 'Could not access the connected Slack account. Reconnect it and try again.', + status: 400, + }, + } + } + try { + routingKey = await fetchSlackTeamId(botToken) + } catch (error: unknown) { + logger.error(`[${requestId}] Slack team_id resolution failed for ${block.id}`, error) + return { + success: false, + error: { + message: + 'Could not verify the connected Slack workspace. Reconnect it and try again.', + status: 400, + }, + } + } + effectiveProvider = 'slack_app' + effectivePath = null + } else { + effectiveProvider = 'slack' } } - webhookConfigs.set(block.id, { provider, providerConfig, triggerPath, triggerDef }) + if (effectivePath) { + const pathConflict = await findConflictingWebhookPathOwner({ + path: effectivePath, + workflowId, + }) + if (pathConflict) { + logger.warn( + `[${requestId}] Webhook path conflict for "${effectivePath}": already owned by workflow ${pathConflict}` + ) + return { + success: false, + error: { + message: `Webhook path "${effectivePath}" is already in use. Choose a different path.`, + status: 409, + }, + } + } + } + + webhookConfigs.set(block.id, { + provider: effectiveProvider, + providerConfig, + triggerPath: effectivePath, + routingKey, + triggerDef, + }) if (providerConfig.credentialSetId) { blocksNeedingCredentialSetSync.push(block) @@ -588,7 +648,7 @@ export async function saveTriggerWebhooksForDeploy({ forceRecreateSubscriptions || shouldRecreateExternalWebhookSubscription({ previousProvider: existingWh.provider as string, - nextProvider: provider, + nextProvider: effectiveProvider, previousConfig: existingConfig, nextConfig: providerConfig, }) @@ -652,7 +712,7 @@ export async function saveTriggerWebhooksForDeploy({ workflowId, blockId: block.id, provider, - triggerPath, + triggerPath: triggerPath ?? '', providerConfig, requestId, deploymentVersionId, @@ -683,7 +743,8 @@ export async function saveTriggerWebhooksForDeploy({ webhookId: string block: BlockState provider: string - triggerPath: string + triggerPath: string | null + routingKey: string | null updatedProviderConfig: Record externalSubscriptionCreated: boolean }> = [] @@ -693,7 +754,7 @@ export async function saveTriggerWebhooksForDeploy({ const config = webhookConfigs.get(block.id) if (!config) continue - const { provider, providerConfig, triggerPath } = config + const { provider, providerConfig, triggerPath, routingKey } = config const webhookId = generateShortId() const createPayload = { id: webhookId, @@ -703,13 +764,15 @@ export async function saveTriggerWebhooksForDeploy({ } try { - await pendingVerificationTracker.register({ - path: triggerPath, - provider, - workflowId, - blockId: block.id, - metadata: providerConfig, - }) + if (triggerPath) { + await pendingVerificationTracker.register({ + path: triggerPath, + provider, + workflowId, + blockId: block.id, + metadata: providerConfig, + }) + } const result = await createExternalWebhookSubscription( request, @@ -724,6 +787,7 @@ export async function saveTriggerWebhooksForDeploy({ block, provider, triggerPath, + routingKey, updatedProviderConfig: result.updatedProviderConfig as Record, externalSubscriptionCreated: result.externalSubscriptionCreated, }) @@ -783,6 +847,7 @@ export async function saveTriggerWebhooksForDeploy({ deploymentVersionId: deploymentVersionId || null, blockId: sub.block.id, path: sub.triggerPath, + routingKey: sub.routingKey, provider: sub.provider, providerConfig: sub.updatedProviderConfig, credentialSetId: @@ -900,7 +965,8 @@ async function persistCreatedWebhookRecordAfterCleanupFailure({ webhookId: string block: BlockState provider: string - triggerPath: string + triggerPath: string | null + routingKey: string | null updatedProviderConfig: Record } requestId: string @@ -912,6 +978,7 @@ async function persistCreatedWebhookRecordAfterCleanupFailure({ deploymentVersionId: deploymentVersionId || null, blockId: sub.block.id, path: sub.triggerPath, + routingKey: sub.routingKey, provider: sub.provider, providerConfig: sub.updatedProviderConfig, credentialSetId: (sub.updatedProviderConfig.credentialSetId as string | undefined) || null, diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index dd6629215b3..c8f41f2b513 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -406,6 +406,58 @@ export async function findAllWebhooksForPath( return results } +/** + * Finds all active `slack_app` webhooks for a Slack `team_id` (the routing key). + * + * Unlike path-based lookup, multi-workflow fan-out is legitimate here: a single + * Slack workspace can have many workflows listening on the native Sim app, so + * every matching workflow is returned. The routing key is server-derived at + * deploy time (Slack-attested `team_id`), not user-controlled, so the + * cross-tenant collision guard used for guessable paths does not apply. + */ +export async function findWebhooksByRoutingKey( + routingKey: string, + requestId: string +): Promise> { + if (!routingKey) { + return [] + } + + const results = await db + .select({ + webhook: webhook, + workflow: workflow, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.routingKey, routingKey), + eq(webhook.provider, 'slack_app'), + eq(webhook.isActive, true), + isNull(webhook.archivedAt), + isNull(workflow.archivedAt), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) + + if (results.length === 0) { + logger.warn(`[${requestId}] No active slack_app webhooks for routing key`) + } + + return results +} + function resolveEnvVars(value: string, envVars: Record): string { return resolveEnvVarReferences(value, envVars) as string } @@ -805,7 +857,7 @@ export async function processPolledWebhookEvent( source: 'webhook' as const, workflowId: foundWorkflow.id, webhookId: foundWebhook.id, - path: foundWebhook.path, + path: foundWebhook.path ?? '', provider, triggerType: 'webhook', } satisfies AsyncExecutionCorrelation) @@ -821,7 +873,7 @@ export async function processPolledWebhookEvent( provider, body, headers: { 'content-type': 'application/json' } as Record, - path: foundWebhook.path, + path: foundWebhook.path ?? '', blockId: foundWebhook.blockId ?? undefined, workspaceId, ...(credentialId ? { credentialId } : {}), diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 93a96a6fc4e..0d725999f92 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -103,6 +103,9 @@ const PROVIDER_HANDLERS: Record = { sendblue: sendblueHandler, servicenow: servicenowHandler, slack: slackHandler, + // Native OAuth Slack trigger — inbound events are verified in the shared + // /api/webhooks/slack route; the handler reuses Slack payload normalization. + slack_app: slackHandler, stripe: stripeHandler, table: tableProviderHandler, telegram: telegramHandler, diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 2d83b196dd1..c41141ea649 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -404,12 +404,29 @@ async function fetchSlackMessageText( /** Maximum allowed timestamp skew (5 minutes) per Slack docs. */ const SLACK_TIMESTAMP_MAX_SKEW = 300 +/** + * Resolve the Slack `team_id` for a bot token via `auth.test`. Used at deploy + * time to derive the tenant routing key for the native (`slack_app`) trigger — + * the id is Slack-attested, never taken from user input. Throws on failure so + * deploy fails fast rather than registering an unroutable trigger. + */ +export async function fetchSlackTeamId(botToken: string): Promise { + const response = await fetch('https://slack.com/api/auth.test', { + headers: { Authorization: `Bearer ${botToken}` }, + }) + const data = (await response.json()) as { ok?: boolean; team_id?: string; error?: string } + if (!data.ok || !data.team_id) { + throw new Error(`Slack auth.test failed: ${data.error || 'unknown error'}`) + } + return data.team_id +} + /** * Validate Slack request signature using HMAC-SHA256. * Basestring format: `v0:{timestamp}:{rawBody}` * Signature header format: `v0={hex}` */ -function validateSlackSignature( +export function validateSlackSignature( signingSecret: string, signature: string, timestamp: string, @@ -448,43 +465,53 @@ export function handleSlackChallenge(body: unknown): NextResponse | null { return null } +/** + * Verify a Slack request's timestamp + HMAC signature against a signing secret. + * Returns a 401 `NextResponse` on failure, or `null` when valid. Shared by the + * per-workflow webhook handler (secret from providerConfig) and the native app + * ingest route (secret from `SLACK_SIGNING_SECRET`). + */ +export function verifySlackRequestSignature( + signingSecret: string, + request: Request, + rawBody: string, + requestId: string +): NextResponse | null { + const signature = request.headers.get('x-slack-signature') + const timestamp = request.headers.get('x-slack-request-timestamp') + + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Slack webhook missing signature or timestamp header`) + return new NextResponse('Unauthorized - Missing Slack signature', { status: 401 }) + } + + const now = Math.floor(Date.now() / 1000) + const parsedTimestamp = Number(timestamp) + if (Number.isNaN(parsedTimestamp)) { + logger.warn(`[${requestId}] Slack webhook timestamp is not a valid number`, { timestamp }) + return new NextResponse('Unauthorized - Invalid timestamp', { status: 401 }) + } + const skew = Math.abs(now - parsedTimestamp) + if (skew > SLACK_TIMESTAMP_MAX_SKEW) { + logger.warn(`[${requestId}] Slack webhook timestamp too old`, { timestamp, now, skew }) + return new NextResponse('Unauthorized - Request timestamp too old', { status: 401 }) + } + + if (!validateSlackSignature(signingSecret, signature, timestamp, rawBody)) { + logger.warn(`[${requestId}] Slack signature verification failed`) + return new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 }) + } + + return null +} + export const slackHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const signingSecret = providerConfig.signingSecret as string | undefined if (!signingSecret) { return null } - - const signature = request.headers.get('x-slack-signature') - const timestamp = request.headers.get('x-slack-request-timestamp') - - if (!signature || !timestamp) { - logger.warn(`[${requestId}] Slack webhook missing signature or timestamp header`) - return new NextResponse('Unauthorized - Missing Slack signature', { status: 401 }) - } - - const now = Math.floor(Date.now() / 1000) - const parsedTimestamp = Number(timestamp) - if (Number.isNaN(parsedTimestamp)) { - logger.warn(`[${requestId}] Slack webhook timestamp is not a valid number`, { timestamp }) - return new NextResponse('Unauthorized - Invalid timestamp', { status: 401 }) - } - const skew = Math.abs(now - parsedTimestamp) - if (skew > SLACK_TIMESTAMP_MAX_SKEW) { - logger.warn(`[${requestId}] Slack webhook timestamp too old`, { - timestamp, - now, - skew, - }) - return new NextResponse('Unauthorized - Request timestamp too old', { status: 401 }) - } - - if (!validateSlackSignature(signingSecret, signature, timestamp, rawBody)) { - logger.warn(`[${requestId}] Slack signature verification failed`) - return new NextResponse('Unauthorized - Invalid Slack signature', { status: 401 }) - } - - return null + return verifySlackRequestSignature(signingSecret, request, rawBody, requestId) }, handleChallenge(body: unknown) { diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 8a34efc72a7..ebf693ba842 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -373,7 +373,10 @@ export async function syncAllWebhooksForCredentialSet( const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config const basePath = - (config.basePath as string) || representativeWebhook.blockId || representativeWebhook.path + (config.basePath as string) || + representativeWebhook.blockId || + representativeWebhook.path || + '' try { const syncResult = await syncWebhooksForCredentialSet({ diff --git a/apps/sim/lib/workspace-events/emitter.ts b/apps/sim/lib/workspace-events/emitter.ts index b2e0f5d1bbc..ce672aef57a 100644 --- a/apps/sim/lib/workspace-events/emitter.ts +++ b/apps/sim/lib/workspace-events/emitter.ts @@ -31,7 +31,7 @@ const SIM_RULE_COOLDOWN_MS = SIM_RULE_COOLDOWN_HOURS * 60 * 60 * 1000 /** Stable cooldown identity for a subscriber block, surviving redeploys. */ function subscriptionBlockKey(subscription: SimSubscription): string { - return subscription.webhook.blockId ?? subscription.webhook.path + return subscription.webhook.blockId ?? subscription.webhook.path ?? '' } /** diff --git a/apps/sim/lib/workspace-events/no-activity.ts b/apps/sim/lib/workspace-events/no-activity.ts index a9fbc744c14..a93a822a73c 100644 --- a/apps/sim/lib/workspace-events/no-activity.ts +++ b/apps/sim/lib/workspace-events/no-activity.ts @@ -156,7 +156,7 @@ async function checkWatchedWorkflow( ): Promise { result.checked++ - const blockKey = subscription.webhook.blockId ?? subscription.webhook.path + const blockKey = subscription.webhook.blockId ?? subscription.webhook.path ?? '' const cooldownMs = noActivityCooldownMs(config) const lastFiredAt = await readLastFiredAt( diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 3869d8a66bc..5bc749f07c5 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -364,7 +364,7 @@ import { servicenowWebhookTrigger, } from '@/triggers/servicenow' import { simWorkspaceEventTrigger } from '@/triggers/sim' -import { slackWebhookTrigger } from '@/triggers/slack' +import { slackOAuthTrigger, slackWebhookTrigger } from '@/triggers/slack' import { stripeWebhookTrigger } from '@/triggers/stripe' import { tableNewRowTrigger } from '@/triggers/table' import { telegramWebhookTrigger } from '@/triggers/telegram' @@ -406,6 +406,7 @@ import { } from '@/triggers/zoom' export const TRIGGER_REGISTRY: TriggerRegistry = { + slack_oauth: slackOAuthTrigger, slack_webhook: slackWebhookTrigger, airtable_webhook: airtableWebhookTrigger, ashby_application_submit: ashbyApplicationSubmitTrigger, diff --git a/apps/sim/triggers/slack/index.ts b/apps/sim/triggers/slack/index.ts index 93eee192e0d..bed2e924d71 100644 --- a/apps/sim/triggers/slack/index.ts +++ b/apps/sim/triggers/slack/index.ts @@ -1 +1,2 @@ +export { slackOAuthTrigger } from './oauth' export { slackWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts new file mode 100644 index 00000000000..a57726a0619 --- /dev/null +++ b/apps/sim/triggers/slack/oauth.ts @@ -0,0 +1,135 @@ +import { SlackIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import { SLACK_TRIGGER_EVENT_OPTIONS, SLACK_TRIGGER_OUTPUTS } from '@/triggers/slack/shared' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Unified Slack trigger. App Type selects how events arrive: + * - `sim` (default): the official Sim Slack app the user OAuth-connects — events + * route to this workflow by Slack `team_id` (stored as the webhook `routingKey`, + * derived at deploy time). No signing secret, bot token, or app setup. + * - `custom`: a bring-your-own Slack app — events arrive on a per-workflow webhook + * URL and are verified with the app's own signing secret. + */ +export const slackOAuthTrigger: TriggerConfig = { + id: 'slack_oauth', + name: 'Slack', + provider: 'slack_app', + description: 'Trigger from Slack events (mentions, messages, reactions)', + version: '1.0.0', + icon: SlackIcon, + + subBlocks: [ + { + id: 'events', + title: 'Operations', + type: 'dropdown', + multiSelect: true, + options: [...SLACK_TRIGGER_EVENT_OPTIONS], + placeholder: 'Select operations', + description: 'Which Slack events should trigger this workflow.', + required: true, + mode: 'trigger', + }, + { + id: 'triggerCredentials', + title: 'Slack Account', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + serviceId: 'slack', + requiredScopes: getScopesForService('slack'), + placeholder: 'Select Slack account', + required: true, + mode: 'trigger', + }, + { + id: 'channelFilter', + title: 'Channels (optional)', + type: 'channel-selector', + canonicalParamId: 'channelFilter', + multiSelect: true, + serviceId: 'slack', + selectorKey: 'slack.channels', + placeholder: 'Any channel the bot is in', + description: + 'Restrict channel, mention, and reaction events to specific channels. Leave empty to trigger on any channel the Sim bot has been added to. Does not apply to direct messages.', + dependsOn: ['triggerCredentials'], + required: false, + mode: 'trigger', + }, + { + id: 'appType', + title: 'App Type', + type: 'dropdown', + options: [ + { label: 'Sim', id: 'sim' }, + { label: 'Custom', id: 'custom' }, + ], + value: () => 'sim', + description: 'Use the official Sim Slack app, or your own custom Slack app.', + mode: 'trigger', + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { field: 'appType', value: 'custom' }, + }, + { + id: 'signingSecret', + title: 'Signing Secret', + type: 'short-input', + placeholder: 'Enter your Slack app signing secret', + description: 'The signing secret from your Slack app to validate request authenticity.', + password: true, + required: { field: 'appType', value: 'custom' }, + mode: 'trigger', + condition: { field: 'appType', value: 'custom' }, + }, + { + id: 'botToken', + title: 'Bot Token', + type: 'short-input', + placeholder: 'xoxb-...', + description: + 'The bot token from your Slack app. Required for downloading files attached to messages.', + password: true, + required: false, + mode: 'trigger', + condition: { field: 'appType', value: 'custom' }, + }, + { + id: 'filterBotMessages', + title: 'Filter bot messages', + type: 'switch', + defaultValue: true, + description: 'Ignore messages sent by bots (including this app) to prevent loops.', + required: false, + mode: 'trigger', + }, + { + id: 'setupWizard', + title: 'Slack App Setup', + type: 'modal', + modalId: 'slack-setup-wizard', + description: 'Walk through manifest creation, app install, and pasting credentials.', + hideFromPreview: true, + mode: 'trigger', + condition: { field: 'appType', value: 'custom' }, + }, + ], + + outputs: SLACK_TRIGGER_OUTPUTS, + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/slack/shared.ts b/apps/sim/triggers/slack/shared.ts new file mode 100644 index 00000000000..9665f2a414c --- /dev/null +++ b/apps/sim/triggers/slack/shared.ts @@ -0,0 +1,177 @@ +import type { TriggerOutput } from '@/triggers/types' + +/** + * Unified Slack trigger output shape, shared by the legacy bring-your-own-app + * webhook trigger and the native OAuth (`slack_app`) trigger. Both normalize + * Events API / interactivity / slash-command payloads into this shape via the + * Slack webhook provider handler, so downstream blocks resolve identically + * regardless of how the app was installed. + */ +export const SLACK_TRIGGER_OUTPUTS: Record = { + event: { + type: 'object', + description: 'Slack event data', + properties: { + event_type: { + type: 'string', + description: + 'Type of Slack payload: an Events API event (e.g., app_mention, message), an interactivity type (e.g., block_actions), or "slash_command" for slash commands', + }, + subtype: { + type: 'string', + description: + 'Message subtype (e.g., channel_join, channel_leave, bot_message, file_share). Null for regular user messages', + }, + channel: { + type: 'string', + description: 'Slack channel ID where the event occurred', + }, + channel_name: { + type: 'string', + description: 'Human-readable channel name', + }, + channel_type: { + type: 'string', + description: + 'Type of channel (e.g., channel, group, im, mpim). Useful for distinguishing DMs from public channels', + }, + user: { + type: 'string', + description: 'User ID who triggered the event', + }, + user_name: { + type: 'string', + description: 'Username who triggered the event', + }, + bot_id: { + type: 'string', + description: 'Bot ID if the message was sent by a bot. Null for human users', + }, + text: { + type: 'string', + description: + 'Message text content. For slash commands, the text after the command. For interactivity, the source message text (falls back to the triggering action value)', + }, + timestamp: { + type: 'string', + description: 'Message timestamp from the triggering event', + }, + thread_ts: { + type: 'string', + description: 'Parent thread timestamp (if message is in a thread)', + }, + team_id: { + type: 'string', + description: 'Slack workspace/team ID', + }, + event_id: { + type: 'string', + description: 'Unique event identifier', + }, + reaction: { + type: 'string', + description: + 'Emoji reaction name (e.g., thumbsup). Present for reaction_added/reaction_removed events', + }, + item_user: { + type: 'string', + description: + 'User ID of the original message author. Present for reaction_added/reaction_removed events', + }, + command: { + type: 'string', + description: + 'Slash command name including the leading slash (e.g., /deploy). Present for slash commands', + }, + action_id: { + type: 'string', + description: + 'action_id of the first interactive element triggered. Present for block_actions (button/select clicks)', + }, + action_value: { + type: 'string', + description: + 'Value carried by the first interactive element (button value, selected option, date, etc.). Present for block_actions', + }, + actions: { + type: 'json', + description: + 'Full array of interactive actions from the payload, preserving every element and its value. Present for block_actions', + }, + response_url: { + type: 'string', + description: + 'Temporary URL to post a response back to the originating message or command. Present for interactivity and slash commands', + }, + trigger_id: { + type: 'string', + description: + 'Short-lived trigger ID used to open a modal in response. Present for interactivity and slash commands', + }, + callback_id: { + type: 'string', + description: + 'Callback ID of the shortcut or view. Present for shortcuts and modal submissions', + }, + api_app_id: { + type: 'string', + description: 'Slack app ID. Present for interactivity and slash commands', + }, + message_ts: { + type: 'string', + description: + 'Timestamp of the message the interaction originated from. Present for block_actions', + }, + view: { + type: 'json', + description: + 'Full Slack view object for modal interactions: state.values (submitted input values), private_metadata, id, callback_id, and hash. Present for view_submission/view_closed; null otherwise', + }, + message: { + type: 'json', + description: + 'Full source message object the interaction came from, including its blocks and text. Present for block_actions on a message; null otherwise', + }, + state: { + type: 'json', + description: + 'Current values of all stateful elements in the surface (state.values) at the time of a block action — e.g. inputs read on a button click. Present for block_actions; null otherwise', + }, + hasFiles: { + type: 'boolean', + description: 'Whether the message has file attachments', + }, + files: { + type: 'file[]', + description: + 'File attachments downloaded from the message (if includeFiles is enabled and bot token is provided)', + }, + }, + }, +} + +/** + * Selectable trigger events for the native OAuth (`slack_app`) trigger. Each id + * is a Slack bot event that the shared Sim app subscribes to; the ingest route + * filters inbound events against the user's selection. + */ +export const SLACK_TRIGGER_EVENT_OPTIONS = [ + { label: 'App mention', id: 'app_mention' }, + { label: 'Direct message', id: 'message.im' }, + { label: 'Public channel message', id: 'message.channels' }, + { label: 'Private channel message', id: 'message.groups' }, + { label: 'Reaction added', id: 'reaction_added' }, + { label: 'Reaction removed', id: 'reaction_removed' }, +] as const + +/** + * Event ids that occur in a specific channel and therefore honor the channel + * filter. Direct-message events (`message.im`) are intentionally excluded. + */ +export const SLACK_CHANNEL_SCOPED_EVENTS = new Set([ + 'app_mention', + 'message.channels', + 'message.groups', + 'reaction_added', + 'reaction_removed', +]) diff --git a/apps/sim/triggers/slack/webhook.ts b/apps/sim/triggers/slack/webhook.ts index dad856e3222..6f41b7ee7ca 100644 --- a/apps/sim/triggers/slack/webhook.ts +++ b/apps/sim/triggers/slack/webhook.ts @@ -1,4 +1,5 @@ import { SlackIcon } from '@/components/icons' +import { SLACK_TRIGGER_OUTPUTS } from '@/triggers/slack/shared' import type { TriggerConfig } from '@/triggers/types' export const slackWebhookTrigger: TriggerConfig = { @@ -62,148 +63,7 @@ export const slackWebhookTrigger: TriggerConfig = { }, ], - outputs: { - event: { - type: 'object', - description: 'Slack event data', - properties: { - event_type: { - type: 'string', - description: - 'Type of Slack payload: an Events API event (e.g., app_mention, message), an interactivity type (e.g., block_actions), or "slash_command" for slash commands', - }, - subtype: { - type: 'string', - description: - 'Message subtype (e.g., channel_join, channel_leave, bot_message, file_share). Null for regular user messages', - }, - channel: { - type: 'string', - description: 'Slack channel ID where the event occurred', - }, - channel_name: { - type: 'string', - description: 'Human-readable channel name', - }, - channel_type: { - type: 'string', - description: - 'Type of channel (e.g., channel, group, im, mpim). Useful for distinguishing DMs from public channels', - }, - user: { - type: 'string', - description: 'User ID who triggered the event', - }, - user_name: { - type: 'string', - description: 'Username who triggered the event', - }, - bot_id: { - type: 'string', - description: 'Bot ID if the message was sent by a bot. Null for human users', - }, - text: { - type: 'string', - description: - 'Message text content. For slash commands, the text after the command. For interactivity, the source message text (falls back to the triggering action value)', - }, - timestamp: { - type: 'string', - description: 'Message timestamp from the triggering event', - }, - thread_ts: { - type: 'string', - description: 'Parent thread timestamp (if message is in a thread)', - }, - team_id: { - type: 'string', - description: 'Slack workspace/team ID', - }, - event_id: { - type: 'string', - description: 'Unique event identifier', - }, - reaction: { - type: 'string', - description: - 'Emoji reaction name (e.g., thumbsup). Present for reaction_added/reaction_removed events', - }, - item_user: { - type: 'string', - description: - 'User ID of the original message author. Present for reaction_added/reaction_removed events', - }, - command: { - type: 'string', - description: - 'Slash command name including the leading slash (e.g., /deploy). Present for slash commands', - }, - action_id: { - type: 'string', - description: - 'action_id of the first interactive element triggered. Present for block_actions (button/select clicks)', - }, - action_value: { - type: 'string', - description: - 'Value carried by the first interactive element (button value, selected option, date, etc.). Present for block_actions', - }, - actions: { - type: 'json', - description: - 'Full array of interactive actions from the payload, preserving every element and its value. Present for block_actions', - }, - response_url: { - type: 'string', - description: - 'Temporary URL to post a response back to the originating message or command. Present for interactivity and slash commands', - }, - trigger_id: { - type: 'string', - description: - 'Short-lived trigger ID used to open a modal in response. Present for interactivity and slash commands', - }, - callback_id: { - type: 'string', - description: - 'Callback ID of the shortcut or view. Present for shortcuts and modal submissions', - }, - api_app_id: { - type: 'string', - description: 'Slack app ID. Present for interactivity and slash commands', - }, - message_ts: { - type: 'string', - description: - 'Timestamp of the message the interaction originated from. Present for block_actions', - }, - view: { - type: 'json', - description: - 'Full Slack view object for modal interactions: state.values (submitted input values), private_metadata, id, callback_id, and hash. Present for view_submission/view_closed; null otherwise', - }, - message: { - type: 'json', - description: - 'Full source message object the interaction came from, including its blocks and text. Present for block_actions on a message; null otherwise', - }, - state: { - type: 'json', - description: - 'Current values of all stateful elements in the surface (state.values) at the time of a block action — e.g. inputs read on a button click. Present for block_actions; null otherwise', - }, - hasFiles: { - type: 'boolean', - description: 'Whether the message has file attachments', - }, - files: { - type: 'file[]', - description: - 'File attachments downloaded from the message (if includeFiles is enabled and bot token is provided)', - }, - }, - }, - }, + outputs: SLACK_TRIGGER_OUTPUTS, webhook: { method: 'POST', diff --git a/packages/db/migrations/0253_fearless_gateway.sql b/packages/db/migrations/0253_fearless_gateway.sql new file mode 100644 index 00000000000..4f03e992e58 --- /dev/null +++ b/packages/db/migrations/0253_fearless_gateway.sql @@ -0,0 +1,3 @@ +ALTER TABLE "webhook" ALTER COLUMN "path" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "webhook" ADD COLUMN "routing_key" text;--> statement-breakpoint +CREATE INDEX "webhook_routing_key_active_idx" ON "webhook" USING btree ("routing_key","provider") WHERE "webhook"."archived_at" IS NULL AND "webhook"."routing_key" IS NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0253_snapshot.json b/packages/db/migrations/meta/0253_snapshot.json new file mode 100644 index 00000000000..baf28e16d06 --- /dev/null +++ b/packages/db/migrations/meta/0253_snapshot.json @@ -0,0 +1,17030 @@ +{ + "id": "f7abf86f-fd6f-416f-b75c-45fbdda3e8cb", + "prevId": "1540e14e-d6da-4953-8899-2c1c439e7c16", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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": {} + } + }, + "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 + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "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()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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": "'[]'" + }, + "archived_at": { + "name": "archived_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": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_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": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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_user_workspace_idx": { + "name": "copilot_chats_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": {} + }, + "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": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "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" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_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.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "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": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "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": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_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()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "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": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_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": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "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_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "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_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_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.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "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 + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_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": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "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 + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "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_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "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" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "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.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "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_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": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "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": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "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()" + }, + "updated_at": { + "name": "updated_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": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "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": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "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": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_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": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_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": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "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": "cascade", + "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.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_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_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_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 + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "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_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "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_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "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.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "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 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "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": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "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 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "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 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_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": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "next_resume_at": { + "name": "next_resume_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": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "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.pending_credential_draft": { + "name": "pending_credential_draft", + "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": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_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()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "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 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "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()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_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": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_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.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": "'[]'" + }, + "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": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "impersonated_by": { + "name": "impersonated_by", + "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 + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "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 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "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": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "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 + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "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.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_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 + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_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_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_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": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "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 + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_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_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "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": "'5'" + }, + "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'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "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 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "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.user_table_definitions": { + "name": "user_table_definitions", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "archived_at": { + "name": "archived_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": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_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": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "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_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "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 + }, + "locked": { + "name": "locked", + "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 + }, + "description": { + "name": "description", + "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": false + }, + "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 + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "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": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "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": "set null", + "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": false + }, + "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": "set null", + "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 + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "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()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "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": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "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 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "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": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "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": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_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_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, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "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": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "infra_retry_count": { + "name": "infra_retry_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 + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_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_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "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_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "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 + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_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": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "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": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "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_file_folders": { + "name": "workspace_file_folders", + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_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": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_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": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "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" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "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_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "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_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "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()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": 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": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "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.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "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 1d448c60dfd..61a9c903ee9 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1765,6 +1765,13 @@ "when": 1782685998491, "tag": "0252_remove_a2a", "breakpoints": true + }, + { + "idx": 253, + "version": "7", + "when": 1782872623999, + "tag": "0253_fearless_gateway", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 383c1a3120a..52dcb6d63fd 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -738,7 +738,19 @@ export const webhook = pgTable( { onDelete: 'cascade' } ), blockId: text('block_id'), - path: text('path').notNull(), + /** + * URL-addressable webhook path. NULL for shared-app providers (e.g. the + * native Slack OAuth trigger) whose events arrive on a single shared + * endpoint and route by `routingKey` instead of a per-workflow path. + */ + path: text('path'), + /** + * Tenant routing key for shared-app providers. For `provider='slack_app'` + * this is the Slack `team_id`, derived server-side from the connected + * credential at deploy time — never user input. Inbound events match on + * this after HMAC verification. + */ + routingKey: text('routing_key'), provider: text('provider'), // e.g., "whatsapp", "github", etc. providerConfig: json('provider_config'), // Store provider-specific configuration isActive: boolean('is_active').notNull().default(true), @@ -763,6 +775,10 @@ export const webhook = pgTable( ), // Optimize queries for credential set webhooks credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId), + // Shared-app inbound routing (Slack native OAuth trigger). routingKey leads. + routingKeyActiveIdx: index('webhook_routing_key_active_idx') + .on(table.routingKey, table.provider) + .where(sql`${table.archivedAt} IS NULL AND ${table.routingKey} IS NOT NULL`), archivedAtPartialIdx: index('webhook_archived_at_partial_idx') .on(table.archivedAt) .where(sql`${table.archivedAt} IS NOT NULL`), From 5a36f48cb47dab178da5cacb3266e6ade1c1ef1f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 30 Jun 2026 20:43:48 -0700 Subject: [PATCH 03/22] fix channel picker in slack trigger --- .../workflow-preview/format-references.tsx | 2 -- apps/sim/app/api/webhooks/slack/route.ts | 22 +++++++++---------- .../editor/components/sub-block/sub-block.tsx | 4 +++- apps/sim/lib/webhooks/providers/slack.ts | 17 +++++++++++--- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx index 6a3c01ae21e..15bfeb5c049 100644 --- a/apps/docs/components/workflow-preview/format-references.tsx +++ b/apps/docs/components/workflow-preview/format-references.tsx @@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] { const isReference = (part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}')) return isReference ? ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) : ( - // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered {part} ) }) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 1b6d58865a2..bbf7425e1e9 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -10,7 +10,11 @@ import { parseWebhookBody, queueWebhookExecution, } from '@/lib/webhooks/processor' -import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' +import { + handleSlackChallenge, + resolveSlackEventChannel, + verifySlackRequestSignature, +} from '@/lib/webhooks/providers/slack' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' import { SLACK_CHANNEL_SCOPED_EVENTS } from '@/triggers/slack/shared' @@ -57,15 +61,6 @@ function isBotMessage(body: Record): boolean { return Boolean(event.bot_id) || event.subtype === 'bot_message' } -/** Channel the event occurred in (reactions carry it under `item.channel`). */ -function resolveEventChannel(body: Record): string | undefined { - const event = body.event as Record | undefined - if (!event) return undefined - if (typeof event.channel === 'string') return event.channel - const item = event.item as Record | undefined - return typeof item?.channel === 'string' ? item.channel : undefined -} - function normalizeSelection(value: unknown): string[] { if (Array.isArray(value)) return value.map(String) if (typeof value === 'string' && value.length > 0) return value.split(',').map((v) => v.trim()) @@ -132,7 +127,10 @@ async function handleSlackAppWebhook(request: NextRequest): Promise | undefined + ) + const isBot = isBotMessage(payload) const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') const triggerTimestampMs = slackRequestTimestamp ? Number(slackRequestTimestamp) * 1000 @@ -157,7 +155,7 @@ async function handleSlackAppWebhook(request: NextRequest): Promise { const authMethod = deps.authMethod as string const oauthCredential = - authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '') + authMethod === 'bot_token' + ? String(deps.botToken ?? '') + : String(deps.credential ?? deps.triggerCredentials ?? '') return { ...context, oauthCredential } }, } diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index c41141ea649..154af2a6dc3 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -453,6 +453,19 @@ export function validateSlackSignature( } } +/** + * Channel a Slack event occurred in. Reaction events carry the channel under + * `item.channel`; message/mention events under `channel`. + */ +export function resolveSlackEventChannel( + event: Record | undefined +): string | undefined { + if (!event) return undefined + if (typeof event.channel === 'string') return event.channel + const item = event.item as Record | undefined + return typeof item?.channel === 'string' ? item.channel : undefined +} + /** * Handle Slack verification challenges */ @@ -582,9 +595,7 @@ export const slackHandler: WebhookProviderHandler = { const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType) const item = rawEvent?.item as Record | undefined - const channel: string = isReactionEvent - ? (item?.channel as string) || '' - : (rawEvent?.channel as string) || '' + const channel: string = resolveSlackEventChannel(rawEvent) || '' const messageTs: string = isReactionEvent ? (item?.ts as string) || '' : (rawEvent?.ts as string) || (rawEvent?.event_ts as string) || '' From 7efbfd3ed8829cf87b4bbffcac59211d58e2cbbb Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 1 Jul 2026 10:53:43 -0700 Subject: [PATCH 04/22] improvement(slack-trigger): reorder app type, gate account to sim mode, add channel-id input --- apps/sim/app/api/webhooks/slack/route.ts | 7 +++- apps/sim/triggers/slack/oauth.ts | 35 ++++++++++++------- .../db/migrations/0253_fearless_gateway.sql | 4 ++- scripts/check-api-validation-contracts.ts | 6 +++- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index bbf7425e1e9..62d7835365f 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -145,8 +145,13 @@ async function handleSlackAppWebhook(request: NextRequest): Promise 0 && (!eventChannel || !selectedChannels.includes(eventChannel)) diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index a57726a0619..9b923861a1e 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -31,6 +31,18 @@ export const slackOAuthTrigger: TriggerConfig = { required: true, mode: 'trigger', }, + { + id: 'appType', + title: 'App Type', + type: 'dropdown', + options: [ + { label: 'Sim', id: 'sim' }, + { label: 'Custom', id: 'custom' }, + ], + value: () => 'sim', + description: 'Use the official Sim Slack app, or your own custom Slack app.', + mode: 'trigger', + }, { id: 'triggerCredentials', title: 'Slack Account', @@ -39,8 +51,9 @@ export const slackOAuthTrigger: TriggerConfig = { serviceId: 'slack', requiredScopes: getScopesForService('slack'), placeholder: 'Select Slack account', - required: true, + required: { field: 'appType', value: 'sim' }, mode: 'trigger', + condition: { field: 'appType', value: 'sim' }, }, { id: 'channelFilter', @@ -52,22 +65,20 @@ export const slackOAuthTrigger: TriggerConfig = { selectorKey: 'slack.channels', placeholder: 'Any channel the bot is in', description: - 'Restrict channel, mention, and reaction events to specific channels. Leave empty to trigger on any channel the Sim bot has been added to. Does not apply to direct messages.', + 'Restrict channel, mention, and reaction events to specific channels. Leave empty to trigger on any channel the bot has been added to. Does not apply to direct messages.', dependsOn: ['triggerCredentials'], required: false, mode: 'trigger', }, { - id: 'appType', - title: 'App Type', - type: 'dropdown', - options: [ - { label: 'Sim', id: 'sim' }, - { label: 'Custom', id: 'custom' }, - ], - value: () => 'sim', - description: 'Use the official Sim Slack app, or your own custom Slack app.', - mode: 'trigger', + id: 'manualChannelFilter', + title: 'Channel IDs', + type: 'short-input', + canonicalParamId: 'channelFilter', + placeholder: 'C0123456789, C0987654321', + description: 'Comma-separated channel IDs to restrict to. Set IDs directly here.', + required: false, + mode: 'trigger-advanced', }, { id: 'webhookUrlDisplay', diff --git a/packages/db/migrations/0253_fearless_gateway.sql b/packages/db/migrations/0253_fearless_gateway.sql index 4f03e992e58..39ff770e08b 100644 --- a/packages/db/migrations/0253_fearless_gateway.sql +++ b/packages/db/migrations/0253_fearless_gateway.sql @@ -1,3 +1,5 @@ ALTER TABLE "webhook" ALTER COLUMN "path" DROP NOT NULL;--> statement-breakpoint ALTER TABLE "webhook" ADD COLUMN "routing_key" text;--> statement-breakpoint -CREATE INDEX "webhook_routing_key_active_idx" ON "webhook" USING btree ("routing_key","provider") WHERE "webhook"."archived_at" IS NULL AND "webhook"."routing_key" IS NOT NULL; \ No newline at end of file +COMMIT;--> statement-breakpoint +SET lock_timeout = 0;--> statement-breakpoint +CREATE INDEX CONCURRENTLY IF NOT EXISTS "webhook_routing_key_active_idx" ON "webhook" USING btree ("routing_key","provider") WHERE "webhook"."archived_at" IS NULL AND "webhook"."routing_key" IS NOT NULL; \ No newline at end of file diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 5387f3cf638..479262a8537 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,7 +9,7 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 873, + totalRoutes: 874, zodRoutes: 873, nonZodRoutes: 0, } as const @@ -70,6 +70,10 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/knowledge/connectors/sync/route.ts', 'apps/sim/app/api/webhooks/outbox/process/route.ts', 'apps/sim/app/api/webhooks/cleanup/idempotency/route.ts', + // Shared Slack app event ingest. The body is an opaque, HMAC-verified Slack + // event envelope (varies per event type) read via parseWebhookBody; there is + // no client contract to bind — authenticity is enforced by signature. + 'apps/sim/app/api/webhooks/slack/route.ts', 'apps/sim/app/api/resume/poll/route.ts', // MCP routes that take only auth context (no client-supplied params/query/body). 'apps/sim/app/api/mcp/discover/route.ts', From 71beec41059cadc86306d9b283abe9b4bd333558 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 1 Jul 2026 11:20:55 -0700 Subject: [PATCH 05/22] fix(slack-trigger): drop unmapped events from filter, resolve oauth token for reaction text + file downloads --- apps/sim/app/api/webhooks/slack/route.ts | 4 +++- apps/sim/lib/webhooks/providers/slack.ts | 20 ++++++++++++++++++-- apps/sim/triggers/slack/oauth.ts | 9 +++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 62d7835365f..9b74883fc93 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -139,8 +139,10 @@ async function handleSlackAppWebhook(request: NextRequest): Promise) || {} + // Fire only for events that map to a selected Operation. Unmapped events + // (e.g. assistant_thread_*) and unselected events no-op — never bypass. const selectedEvents = normalizeSelection(providerConfig.events) - if (eventKey && selectedEvents.length > 0 && !selectedEvents.includes(eventKey)) { + if (selectedEvents.length > 0 && (!eventKey || !selectedEvents.includes(eventKey))) { continue } diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 154af2a6dc3..0aa1ee303c4 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -13,6 +13,7 @@ import type { FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Slack') @@ -559,10 +560,25 @@ export const slackHandler: WebhookProviderHandler = { return new NextResponse(null, { status: 200 }) }, - async formatInput({ body, webhook }: FormatInputContext): Promise { + async formatInput({ + body, + webhook, + workflow, + requestId, + }: FormatInputContext): Promise { const b = body as Record const providerConfig = (webhook.providerConfig as Record) || {} - const botToken = providerConfig.botToken as string | undefined + let botToken = providerConfig.botToken as string | undefined + // Native (slack_app) triggers carry an OAuth credential rather than a pasted + // bot token; resolve it so reaction-message text and file downloads work. + if (!botToken && typeof providerConfig.credentialId === 'string') { + botToken = + (await refreshAccessTokenIfNeeded( + providerConfig.credentialId, + workflow.userId, + requestId + )) ?? undefined + } const includeFiles = Boolean(providerConfig.includeFiles) // Slash commands: flat form fields identified by a leading-slash `command`. diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 9b923861a1e..20e16989a63 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -123,6 +123,15 @@ export const slackOAuthTrigger: TriggerConfig = { required: false, mode: 'trigger', }, + { + id: 'includeFiles', + title: 'Include file attachments', + type: 'switch', + defaultValue: false, + description: 'Download and include file attachments from messages. Requires files:read.', + required: false, + mode: 'trigger', + }, { id: 'setupWizard', title: 'Slack App Setup', From a0e723b5e3a78ad8f38285e33ea2ad1d4259e26b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 1 Jul 2026 11:32:03 -0700 Subject: [PATCH 06/22] fix(slack-trigger): empty operation selection fires nothing; resolve token via credential owner not execution actor --- apps/sim/app/api/webhooks/slack/route.ts | 5 ++-- apps/sim/lib/webhooks/providers/slack.ts | 35 ++++++++++++++---------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 9b74883fc93..d22fc469f32 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -140,9 +140,10 @@ async function handleSlackAppWebhook(request: NextRequest): Promise) || {} // Fire only for events that map to a selected Operation. Unmapped events - // (e.g. assistant_thread_*) and unselected events no-op — never bypass. + // (e.g. assistant_thread_*), unselected events, and an empty selection all + // no-op — never bypass the filter. const selectedEvents = normalizeSelection(providerConfig.events) - if (selectedEvents.length > 0 && (!eventKey || !selectedEvents.includes(eventKey))) { + if (!eventKey || !selectedEvents.includes(eventKey)) { continue } diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 0aa1ee303c4..467b9037c13 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -1,7 +1,10 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { toError } from '@sim/utils/errors' +import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { secureFetchWithPinnedIP, @@ -13,7 +16,7 @@ import type { FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookProvider:Slack') @@ -560,24 +563,28 @@ export const slackHandler: WebhookProviderHandler = { return new NextResponse(null, { status: 200 }) }, - async formatInput({ - body, - webhook, - workflow, - requestId, - }: FormatInputContext): Promise { + async formatInput({ body, webhook, requestId }: FormatInputContext): Promise { const b = body as Record const providerConfig = (webhook.providerConfig as Record) || {} let botToken = providerConfig.botToken as string | undefined // Native (slack_app) triggers carry an OAuth credential rather than a pasted - // bot token; resolve it so reaction-message text and file downloads work. + // bot token; resolve it via the credential's OWNER (not the execution actor + // in workflow.userId, who may not own the credential) so reaction-message + // text and file downloads work. if (!botToken && typeof providerConfig.credentialId === 'string') { - botToken = - (await refreshAccessTokenIfNeeded( - providerConfig.credentialId, - workflow.userId, - requestId - )) ?? undefined + const credentialId = providerConfig.credentialId + const resolved = await resolveOAuthAccountId(credentialId) + if (resolved?.accountId) { + const [owner] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (owner?.userId) { + botToken = + (await refreshAccessTokenIfNeeded(credentialId, owner.userId, requestId)) ?? undefined + } + } } const includeFiles = Boolean(providerConfig.includeFiles) From 310061d0ddbc8fb80ab53213009634b659578355 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 1 Jul 2026 11:45:32 -0700 Subject: [PATCH 07/22] fix(slack-trigger): ignore message edit/delete/system subtypes; prefer channel picker over stale manual ids --- apps/sim/app/api/webhooks/slack/route.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index d22fc469f32..608839f1f92 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -24,6 +24,9 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 60 +/** Message subtypes that represent real content (vs edits/deletes/system events). */ +const CONTENT_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broadcast']) + /** * Maps an inbound Slack Events API payload to one of the selectable trigger * event ids (see SLACK_TRIGGER_EVENT_OPTIONS). Returns null for payloads we do @@ -39,6 +42,13 @@ function resolveSlackEventKey(body: Record): string | null { if (type === 'reaction_removed') return 'reaction_removed' if (type === 'message') { + // Only genuine new messages trigger. Edits, deletes, and channel system + // messages (joins, topic/name changes, etc.) arrive as `message` with a + // subtype — ignore all but content subtypes. + const subtype = event.subtype as string | undefined + if (subtype && !CONTENT_MESSAGE_SUBTYPES.has(subtype)) { + return null + } switch (event.channel_type as string | undefined) { case 'im': return 'message.im' @@ -148,13 +158,15 @@ async function handleSlackAppWebhook(request: NextRequest): Promise 0 + ? pickerChannels + : normalizeSelection(providerConfig.manualChannelFilter) if ( selectedChannels.length > 0 && (!eventChannel || !selectedChannels.includes(eventChannel)) From 65c14152a38a0dd4530931d2c5a47efd56b41c89 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 6 Jul 2026 17:25:59 -0700 Subject: [PATCH 08/22] feat(slack-trigger): single-event model with contextual filters and full event catalog --- apps/sim/app/api/webhooks/slack/route.test.ts | 245 ++++++++++++++++++ apps/sim/app/api/webhooks/slack/route.ts | 245 ++++++++++++++---- .../panel/components/editor/editor.tsx | 6 +- apps/sim/blocks/types.ts | 2 +- apps/sim/lib/oauth/oauth.ts | 1 + apps/sim/lib/webhooks/deploy.ts | 17 +- apps/sim/lib/webhooks/providers/slack.ts | 34 ++- .../sim/lib/workflows/subblocks/visibility.ts | 18 +- apps/sim/tools/registry.minimal.ts | 89 ++++++- apps/sim/triggers/slack/capabilities.ts | 65 ++++- apps/sim/triggers/slack/oauth.ts | 107 +++++++- apps/sim/triggers/slack/shared.ts | 169 ++++++++++-- 12 files changed, 888 insertions(+), 110 deletions(-) create mode 100644 apps/sim/app/api/webhooks/slack/route.test.ts diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts new file mode 100644 index 00000000000..a40230f125b --- /dev/null +++ b/apps/sim/app/api/webhooks/slack/route.test.ts @@ -0,0 +1,245 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockParseWebhookBody, + mockFindWebhooksByRoutingKey, + mockCheckWebhookPreprocessing, + mockQueueWebhookExecution, + mockBlockExistsInDeployment, +} = vi.hoisted(() => ({ + mockParseWebhookBody: vi.fn(), + mockFindWebhooksByRoutingKey: vi.fn(), + mockCheckWebhookPreprocessing: vi.fn(), + mockQueueWebhookExecution: vi.fn(), + mockBlockExistsInDeployment: vi.fn(), +})) + +vi.mock('@/lib/core/admission/gate', () => ({ + tryAdmit: () => ({ release: vi.fn() }), + admissionRejectedResponse: () => new Response(null, { status: 503 }), +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: { SLACK_SIGNING_SECRET: 'test-secret' }, +})) + +vi.mock('@/lib/webhooks/processor', () => ({ + parseWebhookBody: mockParseWebhookBody, + findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey, + checkWebhookPreprocessing: mockCheckWebhookPreprocessing, + queueWebhookExecution: mockQueueWebhookExecution, +})) + +vi.mock('@/lib/webhooks/providers/slack', () => ({ + handleSlackChallenge: () => null, + verifySlackRequestSignature: () => null, + resolveSlackEventChannel: (event: Record | undefined) => { + if (!event) return undefined + if (typeof event.channel === 'string') return event.channel + const item = event.item as Record | undefined + return typeof item?.channel === 'string' ? item.channel : undefined + }, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + blockExistsInDeployment: mockBlockExistsInDeployment, +})) + +import { POST } from '@/app/api/webhooks/slack/route' + +const API_APP_ID = 'A_SELF' + +function makeRequest() { + return new Request('https://sim.test/api/webhooks/slack', { + method: 'POST', + headers: { 'x-slack-request-timestamp': '1700000000' }, + }) as unknown as import('next/server').NextRequest +} + +function slackBody(event: Record, extra: Record = {}) { + return { team_id: 'T1', api_app_id: API_APP_ID, event, ...extra } +} + +/** Drive the route with a single webhook whose providerConfig is `config`. */ +async function fireWith( + config: Record, + body: Record +): Promise { + mockParseWebhookBody.mockResolvedValue({ body, rawBody: JSON.stringify(body) }) + mockFindWebhooksByRoutingKey.mockResolvedValue([ + { + webhook: { id: 'wh1', blockId: 'blk1', providerConfig: config }, + workflow: { id: 'wf1' }, + }, + ]) + mockCheckWebhookPreprocessing.mockResolvedValue({ + actorUserId: 'u1', + executionId: 'e1', + correlation: {}, + }) + mockBlockExistsInDeployment.mockResolvedValue(true) + mockQueueWebhookExecution.mockClear() + + await POST(makeRequest()) + return mockQueueWebhookExecution.mock.calls.length > 0 +} + +describe('Slack app webhook route filtering', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fires a message event matching source=channel in a public channel', async () => { + const fired = await fireWith( + { eventType: 'message', source: ['channel'] }, + slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.1' }) + ) + expect(fired).toBe(true) + }) + + it('drops a DM when source is restricted to public channels', async () => { + const fired = await fireWith( + { eventType: 'message', source: ['channel'] }, + slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.1' }) + ) + expect(fired).toBe(false) + }) + + it('source=[public,private] fires on both channel types but drops DMs', async () => { + const source = ['channel', 'group'] + const publicMsg = await fireWith( + { eventType: 'message', source }, + slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.2' }) + ) + expect(publicMsg).toBe(true) + + const privateMsg = await fireWith( + { eventType: 'message', source }, + slackBody({ type: 'message', channel_type: 'group', channel: 'G1', ts: '1.3' }) + ) + expect(privateMsg).toBe(true) + + const dm = await fireWith( + { eventType: 'message', source }, + slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.4' }) + ) + expect(dm).toBe(false) + }) + + it('empty source matches any channel type', async () => { + const dm = await fireWith( + { eventType: 'message', source: [] }, + slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.5' }) + ) + expect(dm).toBe(true) + }) + + it('a channel filter never drops a DM allowed by Source', async () => { + const config = { eventType: 'message', source: ['im', 'channel'], channelFilter: ['C1'] } + // DM passes: the channel filter does not apply to DMs. + const dm = await fireWith( + config, + slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.6' }) + ) + expect(dm).toBe(true) + // Public message in the allowed channel fires. + const inChannel = await fireWith( + config, + slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.7' }) + ) + expect(inChannel).toBe(true) + // Public message in another channel is dropped by the channel filter. + const otherChannel = await fireWith( + config, + slackBody({ type: 'message', channel_type: 'channel', channel: 'C2', ts: '1.8' }) + ) + expect(otherChannel).toBe(false) + }) + + it('app_mention Threads=Only fires only on threaded mentions', async () => { + const topLevel = await fireWith( + { eventType: 'app_mention', threads: 'only' }, + slackBody({ type: 'app_mention', channel: 'C1', ts: '2.0' }) + ) + expect(topLevel).toBe(false) + + const threaded = await fireWith( + { eventType: 'app_mention', threads: 'only' }, + slackBody({ type: 'app_mention', channel: 'C1', ts: '2.1', thread_ts: '2.0' }) + ) + expect(threaded).toBe(true) + }) + + it('maps message_changed to message_edited and not to message', async () => { + const editBody = slackBody({ + type: 'message', + subtype: 'message_changed', + channel_type: 'channel', + channel: 'C1', + ts: '3.1', + }) + expect(await fireWith({ eventType: 'message_edited' }, editBody)).toBe(true) + expect(await fireWith({ eventType: 'message' }, editBody)).toBe(false) + }) + + it("self-drops the app's own message unless includeOwnMessages is set", async () => { + const ownBody = slackBody({ + type: 'message', + channel_type: 'channel', + channel: 'C1', + ts: '4.1', + app_id: API_APP_ID, + bot_id: 'B1', + }) + expect(await fireWith({ eventType: 'message' }, ownBody)).toBe(false) + expect(await fireWith({ eventType: 'message', includeOwnMessages: true }, ownBody)).toBe(true) + }) + + it("self-drops the app's own reaction via stored bot_user_id", async () => { + const body = slackBody({ + type: 'reaction_added', + reaction: 'thumbsup', + user: 'U_BOT', + item: { channel: 'C1', ts: '5.0' }, + }) + expect(await fireWith({ eventType: 'reaction_added', bot_user_id: 'U_BOT' }, body)).toBe(false) + expect(await fireWith({ eventType: 'reaction_added', bot_user_id: 'U_OTHER' }, body)).toBe(true) + }) + + it('applies the emoji filter to reaction events', async () => { + const body = slackBody({ + type: 'reaction_added', + reaction: 'eyes', + user: 'U1', + item: { channel: 'C1', ts: '6.0' }, + }) + expect(await fireWith({ eventType: 'reaction_added', emoji: 'thumbsup' }, body)).toBe(false) + expect(await fireWith({ eventType: 'reaction_added', emoji: 'eyes, thumbsup' }, body)).toBe( + true + ) + }) + + it('honors the legacy events array for pre-redesign webhooks', async () => { + const fired = await fireWith( + { events: ['message.channels'] }, + slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '7.1' }) + ) + expect(fired).toBe(true) + }) + + it('ignores other bots but not our own drop path when filterBotMessages is on', async () => { + const otherBot = slackBody({ + type: 'message', + channel_type: 'channel', + channel: 'C1', + ts: '8.1', + bot_id: 'B_OTHER', + app_id: 'A_OTHER', + }) + expect(await fireWith({ eventType: 'message' }, otherBot)).toBe(false) + expect(await fireWith({ eventType: 'message', filterBotMessages: false }, otherBot)).toBe(true) + }) +}) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 608839f1f92..d8edb536cfd 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -16,7 +16,7 @@ import { verifySlackRequestSignature, } from '@/lib/webhooks/providers/slack' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' -import { SLACK_CHANNEL_SCOPED_EVENTS } from '@/triggers/slack/shared' +import { type SlackEventFilter, slackEventSupportsFilter } from '@/triggers/slack/shared' const logger = createLogger('SlackAppWebhookAPI') @@ -24,64 +24,123 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 60 -/** Message subtypes that represent real content (vs edits/deletes/system events). */ -const CONTENT_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broadcast']) +/** Message subtypes that carry real content (vs system/join/topic messages). */ +const CONTENT_MESSAGE_SUBTYPES = new Set([ + 'file_share', + 'me_message', + 'thread_broadcast', + 'bot_message', +]) /** - * Maps an inbound Slack Events API payload to one of the selectable trigger - * event ids (see SLACK_TRIGGER_EVENT_OPTIONS). Returns null for payloads we do - * not surface as trigger operations. + * Maps an inbound Slack Events API payload to the trigger `eventType` id it + * satisfies (see SLACK_EVENT_CATALOG). Returns null for payloads we do not + * surface. For most events the Slack `event.type` is the id verbatim; `message` + * fans out to `message` / `message_edited` / `message_deleted` by subtype. */ function resolveSlackEventKey(body: Record): string | null { const event = body.event as Record | undefined if (!event) return null const type = event.type as string | undefined - if (type === 'app_mention') return 'app_mention' - if (type === 'reaction_added') return 'reaction_added' - if (type === 'reaction_removed') return 'reaction_removed' - - if (type === 'message') { - // Only genuine new messages trigger. Edits, deletes, and channel system - // messages (joins, topic/name changes, etc.) arrive as `message` with a - // subtype — ignore all but content subtypes. - const subtype = event.subtype as string | undefined - if (subtype && !CONTENT_MESSAGE_SUBTYPES.has(subtype)) { - return null - } - switch (event.channel_type as string | undefined) { - case 'im': - return 'message.im' - case 'channel': - return 'message.channels' - case 'group': - return 'message.groups' - default: - return null + switch (type) { + case 'app_mention': + case 'reaction_added': + case 'reaction_removed': + case 'file_shared': + case 'member_joined_channel': + case 'member_left_channel': + case 'channel_created': + case 'channel_archive': + case 'channel_rename': + case 'pin_added': + case 'pin_removed': + case 'team_join': + case 'app_home_opened': + case 'assistant_thread_started': + case 'assistant_thread_context_changed': + return type + case 'message': { + const subtype = event.subtype as string | undefined + if (subtype === 'message_changed') return 'message_edited' + if (subtype === 'message_deleted') return 'message_deleted' + // Edits/deletes are handled above; other non-content subtypes (joins, + // topic/name changes, etc.) are not surfaced. `bot_message` is content so + // the "Ignore bot messages" toggle can decide, rather than being dropped. + if (subtype && !CONTENT_MESSAGE_SUBTYPES.has(subtype)) return null + return 'message' } + default: + return null } - - return null } -/** True when the message originated from a bot (used to break agent loops). */ -function isBotMessage(body: Record): boolean { - const event = body.event as Record | undefined +/** True when the message originated from a bot (used to ignore other bots). */ +function isBotMessage(event: Record | undefined): boolean { if (!event) return false return Boolean(event.bot_id) || event.subtype === 'bot_message' } +/** + * True when the event was produced by this Slack app itself (its own message or + * bot output), identified by matching the producing `app_id` against the + * payload's `api_app_id`. Config-independent — used for the always-on self-drop. + */ +function isOwnAppEvent( + event: Record | undefined, + apiAppId: string | undefined +): boolean { + if (!event || !apiAppId) return false + const appId = + (event.app_id as string | undefined) ?? + ((event.bot_profile as Record | undefined)?.app_id as string | undefined) + return appId === apiAppId +} + +/** True when the event is a thread reply (Slack-canonical: thread_ts set and != ts). */ +function isThreadReply(event: Record | undefined): boolean { + if (!event) return false + const threadTs = event.thread_ts as string | undefined + const ts = event.ts as string | undefined + return typeof threadTs === 'string' && threadTs.length > 0 && threadTs !== ts +} + function normalizeSelection(value: unknown): string[] { if (Array.isArray(value)) return value.map(String) if (typeof value === 'string' && value.length > 0) return value.split(',').map((v) => v.trim()) return [] } +/** + * Back-compat matcher for pre-redesign webhooks that stored a multi-select + * `events` array of old ids (`message.im`, `message.channels`, ...). Maps the + * new `eventKey` back onto the legacy selection so existing deployments keep + * firing until they are re-deployed onto the single-event model. + */ +function matchesLegacyEvents( + rawEvents: unknown, + eventKey: string | null, + channelType: string | undefined +): boolean { + const events = normalizeSelection(rawEvents) + if (events.length === 0 || !eventKey) return false + for (const legacy of events) { + if (legacy === eventKey) return true + if (eventKey === 'message') { + if (legacy === 'message.im' && channelType === 'im') return true + if (legacy === 'message.channels' && channelType === 'channel') return true + if (legacy === 'message.groups' && channelType === 'group') return true + } + } + return false +} + /** * Single ingest endpoint for the official Sim Slack app. Every workspace's * events arrive here and are routed to listening workflows by Slack `team_id` - * after HMAC verification with the shared app signing secret. This is the - * request URL configured in the app's Event Subscriptions. + * (and Slack Connect `authorizations[].team_id`) after HMAC verification with + * the shared app signing secret. This is the request URL configured in the + * app's Event Subscriptions. */ export const POST = withRouteHandler(async (request: NextRequest) => { const ticket = tryAdmit() @@ -124,23 +183,50 @@ async function handleSlackAppWebhook(request: NextRequest): Promise - const teamId = - typeof payload.team_id === 'string' && payload.team_id.length > 0 ? payload.team_id : null - if (!teamId) { + + // Route by the installed workspace(s). For Slack Connect the outer `team_id` + // may be the sender's workspace, so every authorized installation is a + // routing candidate. Team ids are Slack-attested (post-signature), never user + // input. + const teamIds = new Set() + if (typeof payload.team_id === 'string' && payload.team_id.length > 0) { + teamIds.add(payload.team_id) + } + const authorizations = Array.isArray(payload.authorizations) ? payload.authorizations : [] + for (const authorization of authorizations) { + const teamId = (authorization as Record)?.team_id + if (typeof teamId === 'string' && teamId.length > 0) { + teamIds.add(teamId) + } + } + if (teamIds.size === 0) { logger.warn(`[${requestId}] Slack event missing team_id`) return new NextResponse(null, { status: 200 }) } - const webhooks = await findWebhooksByRoutingKey(teamId, requestId) + const webhooksById = new Map() + for (const teamId of teamIds) { + const found = await findWebhooksByRoutingKey(teamId, requestId) + for (const entry of found) { + webhooksById.set(entry.webhook.id, entry) + } + } + const webhooks = [...webhooksById.values()] if (webhooks.length === 0) { return new NextResponse(null, { status: 200 }) } + const rawEvent = payload.event as Record | undefined const eventKey = resolveSlackEventKey(payload) - const eventChannel = resolveSlackEventChannel( - payload.event as Record | undefined - ) - const isBot = isBotMessage(payload) + const apiAppId = typeof payload.api_app_id === 'string' ? payload.api_app_id : undefined + const eventChannel = resolveSlackEventChannel(rawEvent) + const channelType = rawEvent?.channel_type as string | undefined + const reaction = rawEvent?.reaction as string | undefined + const reactor = rawEvent?.user as string | undefined + const isReply = isThreadReply(rawEvent) + const isOwn = isOwnAppEvent(rawEvent, apiAppId) + const isBot = isBotMessage(rawEvent) + const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') const triggerTimestampMs = slackRequestTimestamp ? Number(slackRequestTimestamp) * 1000 @@ -148,20 +234,60 @@ async function handleSlackAppWebhook(request: NextRequest): Promise) || {} + const configuredEvent = + typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null - // Fire only for events that map to a selected Operation. Unmapped events - // (e.g. assistant_thread_*), unselected events, and an empty selection all - // no-op — never bypass the filter. - const selectedEvents = normalizeSelection(providerConfig.events) - if (!eventKey || !selectedEvents.includes(eventKey)) { + // Match the single configured event. Pre-redesign webhooks fall back to the + // legacy multi-select `events` array. + if (configuredEvent) { + if (!eventKey || eventKey !== configuredEvent) continue + } else if (!matchesLegacyEvents(providerConfig.events, eventKey, channelType)) { continue } - // Channel filter applies only to channel-scoped events, never to DMs. - // Channels come from the picker (channelFilter) or manual IDs - // (manualChannelFilter) — the basic/advanced sides of one canonical field. - // Prefer the picker when set so a stale manual value can't keep matching. - if (eventKey && SLACK_CHANNEL_SCOPED_EVENTS.has(eventKey)) { + const supports = (filter: SlackEventFilter): boolean => + configuredEvent !== null && slackEventSupportsFilter(configuredEvent, filter) + + // Source filter — restrict a message event to any of DM / public / private + // (multiselect by `channel_type`). Empty means any source. + if (supports('source')) { + const sources = normalizeSelection(providerConfig.source) + if (sources.length > 0 && (!channelType || !sources.includes(channelType))) continue + } + + // Threads filter — include / exclude / only. + if (supports('threads')) { + const threads = + typeof providerConfig.threads === 'string' ? providerConfig.threads : 'include' + if (threads === 'exclude' && isReply) continue + if (threads === 'only' && !isReply) continue + } + + // Emoji filter — restrict a reaction event to specific emoji names. + if (supports('emoji')) { + const emojis = normalizeSelection(providerConfig.emoji) + if (emojis.length > 0 && (!reaction || !emojis.includes(reaction))) continue + } + + // Name-contains filter — restrict channel_created to matching names. + if (supports('name')) { + const needle = + typeof providerConfig.nameContains === 'string' ? providerConfig.nameContains.trim() : '' + if (needle) { + const channel = rawEvent?.channel as Record | undefined + const name = typeof channel?.name === 'string' ? channel.name : '' + if (!name.includes(needle)) continue + } + } + + // Channel filter — picker (channelFilter) or manual IDs (manualChannelFilter), + // the basic/advanced sides of one canonical field. Prefer the picker so a + // stale manual value can't keep matching. DMs always skip it: a DM's channel + // is a DM id that can't be picked, so a DM allowed by Source must not be + // dropped by a channel filter meant for real channels. + const channelScoped = + channelType !== 'im' && (configuredEvent ? supports('channels') : Boolean(eventChannel)) + if (channelScoped) { const pickerChannels = normalizeSelection(providerConfig.channelFilter) const selectedChannels = pickerChannels.length > 0 @@ -175,7 +301,20 @@ async function handleSlackAppWebhook(request: NextRequest): Promise { for (const subBlock of subBlocksForCanonical) { - if (subBlock.mode !== 'advanced') continue + if (!isStandaloneAdvancedMode(subBlock.mode)) continue if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue if ( @@ -220,7 +221,8 @@ export function Editor() { for (const subBlock of subBlocks) { const isStandaloneAdvanced = - subBlock.mode === 'advanced' && !canonicalIndex.canonicalIdBySubBlockId[subBlock.id] + isStandaloneAdvancedMode(subBlock.mode) && + !canonicalIndex.canonicalIdBySubBlockId[subBlock.id] if (isStandaloneAdvanced) { advancedOnly.push(subBlock) diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index ed5ed73f234..c86b61082f7 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -258,7 +258,7 @@ export interface SubBlockConfig { id: string title?: string type: SubBlockType - mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is for advanced canonical pair members shown in trigger mode + mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is the advanced side of a trigger field — either a canonical pair member or a standalone field shown under the block-level advanced toggle canonicalParamId?: string /** Controls parameter visibility in agent/tool-input context */ paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden' diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 5e40b21dcd7..79f0409892d 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -724,6 +724,7 @@ export const OAUTH_PROVIDERS: Record = { 'canvases:write', 'reactions:write', 'reactions:read', + 'pins:read', ], }, }, diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index dfbc00026bc..87cfaa96b71 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -30,6 +30,7 @@ import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' import { getTrigger, isTriggerValid } from '@/triggers' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' +import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared' const logger = createLogger('DeployWebhookSync') const CREDENTIAL_SET_PREFIX = 'credentialSet:' @@ -591,6 +592,18 @@ export async function saveTriggerWebhooksForDeploy({ if (triggerId === 'slack_oauth') { const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'sim' if (appType === 'sim') { + const eventType = + typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null + if (eventType && !SIM_SUBSCRIBED_EVENTS.includes(eventType)) { + return { + success: false, + error: { + message: + 'This event is not available on the Sim Slack app. Use a custom app or choose a supported event.', + status: 400, + }, + } + } if (!credentialId) { return { success: false, @@ -608,7 +621,9 @@ export async function saveTriggerWebhooksForDeploy({ } } try { - routingKey = await fetchSlackTeamId(botToken) + const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken) + routingKey = teamId + if (botUserId) providerConfig.bot_user_id = botUserId } catch (error: unknown) { logger.error(`[${requestId}] Slack team_id resolution failed for ${block.id}`, error) return { diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 467b9037c13..16e7e894b6d 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -77,6 +77,7 @@ interface SlackTriggerEvent { trigger_id: string callback_id: string api_app_id: string + app_id: string message_ts: string /** * Full Slack view object for modal interactions (view_submission/view_closed): @@ -126,6 +127,7 @@ function createSlackEvent(): SlackTriggerEvent { trigger_id: '', callback_id: '', api_app_id: '', + app_id: '', message_ts: '', view: null, message: null, @@ -409,20 +411,28 @@ async function fetchSlackMessageText( const SLACK_TIMESTAMP_MAX_SKEW = 300 /** - * Resolve the Slack `team_id` for a bot token via `auth.test`. Used at deploy - * time to derive the tenant routing key for the native (`slack_app`) trigger — - * the id is Slack-attested, never taken from user input. Throws on failure so + * Resolve the Slack `team_id` and bot `user_id` for a bot token via `auth.test`. + * Used at deploy time to derive the tenant routing key for the native + * (`slack_app`) trigger and to store the bot user id for reaction self-drop — + * both are Slack-attested, never taken from user input. Throws on failure so * deploy fails fast rather than registering an unroutable trigger. */ -export async function fetchSlackTeamId(botToken: string): Promise { +export async function fetchSlackTeamId( + botToken: string +): Promise<{ teamId: string; userId: string | undefined }> { const response = await fetch('https://slack.com/api/auth.test', { headers: { Authorization: `Bearer ${botToken}` }, }) - const data = (await response.json()) as { ok?: boolean; team_id?: string; error?: string } + const data = (await response.json()) as { + ok?: boolean + team_id?: string + user_id?: string + error?: string + } if (!data.ok || !data.team_id) { throw new Error(`Slack auth.test failed: ${data.error || 'unknown error'}`) } - return data.team_id + return { teamId: data.team_id, userId: data.user_id } } /** @@ -458,8 +468,9 @@ export function validateSlackSignature( } /** - * Channel a Slack event occurred in. Reaction events carry the channel under - * `item.channel`; message/mention events under `channel`. + * Channel a Slack event occurred in. Message/mention events carry it under + * `channel`, reaction events under `item.channel`, and file/pin events under + * `channel_id`. */ export function resolveSlackEventChannel( event: Record | undefined @@ -467,7 +478,8 @@ export function resolveSlackEventChannel( if (!event) return undefined if (typeof event.channel === 'string') return event.channel const item = event.item as Record | undefined - return typeof item?.channel === 'string' ? item.channel : undefined + if (typeof item?.channel === 'string') return item.channel + return typeof event.channel_id === 'string' ? event.channel_id : undefined } /** @@ -650,6 +662,10 @@ export const slackHandler: WebhookProviderHandler = { event.thread_ts = asString(rawEvent?.thread_ts) event.team_id = asString(b?.team_id) || asString(rawEvent?.team) event.event_id = asString(b?.event_id) + event.api_app_id = asString(b?.api_app_id) + event.app_id = + asString(rawEvent?.app_id) || + asString((rawEvent?.bot_profile as Record | undefined)?.app_id) event.reaction = asString(rawEvent?.reaction) event.item_user = asString(rawEvent?.item_user) event.message_ts = messageTs diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 7cce5d9575e..95feaa5d08d 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -291,12 +291,22 @@ export function hasStandaloneAdvancedFields( canonicalIndex: CanonicalIndex ): boolean { for (const subBlock of subBlocks) { - if (subBlock.mode !== 'advanced') continue + if (!isStandaloneAdvancedMode(subBlock.mode)) continue if (!canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) return true } return false } +/** + * True for the modes that make a field advanced-only when it is not part of a + * canonical basic/advanced pair: a standalone `advanced` field, or a standalone + * `trigger-advanced` field (an advanced option on a trigger block). Both are + * hidden until the block-level advanced toggle is on. + */ +export function isStandaloneAdvancedMode(mode: SubBlockConfig['mode']): boolean { + return mode === 'advanced' || mode === 'trigger-advanced' +} + /** * Check if any advanced-only or canonical advanced values are present. */ @@ -319,7 +329,7 @@ export function hasAdvancedValues( continue } - if (subBlock.mode === 'advanced' && isNonEmptyValue(values[subBlock.id])) { + if (isStandaloneAdvancedMode(subBlock.mode) && isNonEmptyValue(values[subBlock.id])) { return true } } @@ -347,7 +357,9 @@ export function isSubBlockVisibleForMode( } if (subBlock.mode === 'basic' && displayAdvancedOptions) return false - if (subBlock.mode === 'advanced' && !displayAdvancedOptions) return false + // Standalone advanced-only fields (`advanced` or a trigger's `trigger-advanced`) + // hide until the block-level advanced toggle is on. + if (isStandaloneAdvancedMode(subBlock.mode) && !displayAdvancedOptions) return false return true } diff --git a/apps/sim/tools/registry.minimal.ts b/apps/sim/tools/registry.minimal.ts index d1b34868ec8..6667a326f8f 100644 --- a/apps/sim/tools/registry.minimal.ts +++ b/apps/sim/tools/registry.minimal.ts @@ -1,5 +1,49 @@ import { functionExecuteTool } from '@/tools/function' import { httpRequestTool } from '@/tools/http' +import { + slackAddReactionTool, + slackArchiveConversationTool, + slackCanvasTool, + slackCreateChannelCanvasTool, + slackCreateConversationTool, + slackDeleteCanvasTool, + slackDeleteMessageTool, + slackDeleteScheduledMessageTool, + slackDownloadTool, + slackEditCanvasTool, + slackEphemeralMessageTool, + slackGetCanvasTool, + slackGetChannelHistoryTool, + slackGetChannelInfoTool, + slackGetMessageTool, + slackGetPermalinkTool, + slackGetThreadRepliesTool, + slackGetThreadTool, + slackGetUserPresenceTool, + slackGetUserTool, + slackInviteToConversationTool, + slackListCanvasesTool, + slackListChannelsTool, + slackListMembersTool, + slackListScheduledMessagesTool, + slackListUsersTool, + slackLookupCanvasSectionsTool, + slackMessageReaderTool, + slackMessageTool, + slackOpenViewTool, + slackPublishViewTool, + slackPushViewTool, + slackRemoveReactionTool, + slackRenameConversationTool, + slackScheduleMessageTool, + slackSetConversationPurposeTool, + slackSetConversationTopicTool, + slackSetStatusTool, + slackSetSuggestedPromptsTool, + slackSetTitleTool, + slackUpdateMessageTool, + slackUpdateViewTool, +} from '@/tools/slack' import type { ToolConfig } from '@/tools/types' /** @@ -8,9 +52,52 @@ import type { ToolConfig } from '@/tools/types' * next.config.ts) so the local dev server never compiles the full ~247-tool * graph (~2,074 modules) that the shared workspace layout otherwise drags into * every route. Only these tools execute in minimal mode; unset the flag for the - * full set. NEVER aliased in production. + * full set. Slack tools are included so the Slack block (action + native trigger) + * works end-to-end in minimal dev. NEVER aliased in production. */ export const tools: Record = { http_request: httpRequestTool, function_execute: functionExecuteTool, + slack_add_reaction: slackAddReactionTool, + slack_archive_conversation: slackArchiveConversationTool, + slack_canvas: slackCanvasTool, + slack_create_channel_canvas: slackCreateChannelCanvasTool, + slack_create_conversation: slackCreateConversationTool, + slack_delete_canvas: slackDeleteCanvasTool, + slack_delete_message: slackDeleteMessageTool, + slack_delete_scheduled_message: slackDeleteScheduledMessageTool, + slack_download: slackDownloadTool, + slack_edit_canvas: slackEditCanvasTool, + slack_ephemeral_message: slackEphemeralMessageTool, + slack_get_canvas: slackGetCanvasTool, + slack_get_channel_history: slackGetChannelHistoryTool, + slack_get_channel_info: slackGetChannelInfoTool, + slack_get_message: slackGetMessageTool, + slack_get_permalink: slackGetPermalinkTool, + slack_get_thread_replies: slackGetThreadRepliesTool, + slack_get_thread: slackGetThreadTool, + slack_get_user_presence: slackGetUserPresenceTool, + slack_get_user: slackGetUserTool, + slack_invite_to_conversation: slackInviteToConversationTool, + slack_list_canvases: slackListCanvasesTool, + slack_list_channels: slackListChannelsTool, + slack_list_members: slackListMembersTool, + slack_list_scheduled_messages: slackListScheduledMessagesTool, + slack_list_users: slackListUsersTool, + slack_lookup_canvas_sections: slackLookupCanvasSectionsTool, + slack_message_reader: slackMessageReaderTool, + slack_message: slackMessageTool, + slack_open_view: slackOpenViewTool, + slack_publish_view: slackPublishViewTool, + slack_push_view: slackPushViewTool, + slack_remove_reaction: slackRemoveReactionTool, + slack_rename_conversation: slackRenameConversationTool, + slack_schedule_message: slackScheduleMessageTool, + slack_set_conversation_purpose: slackSetConversationPurposeTool, + slack_set_conversation_topic: slackSetConversationTopicTool, + slack_set_status: slackSetStatusTool, + slack_set_suggested_prompts: slackSetSuggestedPromptsTool, + slack_set_title: slackSetTitleTool, + slack_update_message: slackUpdateMessageTool, + slack_update_view: slackUpdateViewTool, } diff --git a/apps/sim/triggers/slack/capabilities.ts b/apps/sim/triggers/slack/capabilities.ts index 1774ba11a9b..9bae850b790 100644 --- a/apps/sim/triggers/slack/capabilities.ts +++ b/apps/sim/triggers/slack/capabilities.ts @@ -40,15 +40,6 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ scopes: ['im:history', 'im:read'], events: ['message.im'], }, - { - id: 'trigger_group_dm', - label: 'Group direct message', - description: 'Trigger on messages in multi-person DMs your bot is part of.', - defaultChecked: true, - group: 'trigger', - scopes: ['mpim:history', 'mpim:read'], - events: ['message.mpim'], - }, { id: 'trigger_public_channel', label: 'Public channel message', @@ -77,6 +68,60 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ scopes: ['reactions:read'], events: ['reaction_added', 'reaction_removed'], }, + { + id: 'trigger_file_shared', + label: 'File shared', + description: 'Trigger when a file is shared in a channel your bot can see.', + defaultChecked: false, + group: 'trigger', + scopes: ['files:read'], + events: ['file_shared'], + }, + { + id: 'trigger_member_channel', + label: 'Member joined / left channel', + description: 'Trigger when a member joins or leaves a channel your bot is in.', + defaultChecked: false, + group: 'trigger', + scopes: ['channels:read', 'groups:read'], + events: ['member_joined_channel', 'member_left_channel'], + }, + { + id: 'trigger_channel_lifecycle', + label: 'Channel created / archived / renamed', + description: 'Trigger when a channel is created, archived, or renamed.', + defaultChecked: false, + group: 'trigger', + scopes: ['channels:read', 'groups:read'], + events: ['channel_created', 'channel_archive', 'channel_rename'], + }, + { + id: 'trigger_pin', + label: 'Pin added / removed', + description: 'Trigger when a message is pinned or unpinned in a channel.', + defaultChecked: false, + group: 'trigger', + scopes: ['pins:read'], + events: ['pin_added', 'pin_removed'], + }, + { + id: 'trigger_team_join', + label: 'Member joined workspace', + description: 'Trigger when a new member joins the workspace.', + defaultChecked: false, + group: 'trigger', + scopes: ['users:read'], + events: ['team_join'], + }, + { + id: 'trigger_app_home', + label: 'App home opened', + description: "Trigger when a user opens your app's Home tab.", + defaultChecked: false, + group: 'trigger', + scopes: [], + events: ['app_home_opened'], + }, { id: 'action_send', label: 'Send messages', @@ -102,7 +147,7 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ 'Let the bot page through channel, thread, and DM history (conversations.history / conversations.replies).', defaultChecked: true, group: 'action', - scopes: ['channels:history', 'groups:history', 'im:history', 'mpim:history'], + scopes: ['channels:history', 'groups:history', 'im:history'], events: [], }, { diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 20e16989a63..43a1ac33b70 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -1,8 +1,28 @@ import { SlackIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' -import { SLACK_TRIGGER_EVENT_OPTIONS, SLACK_TRIGGER_OUTPUTS } from '@/triggers/slack/shared' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { + SLACK_ALL_EVENT_OPTIONS, + SLACK_SIM_EVENT_OPTIONS, + SLACK_SOURCE_OPTIONS, + SLACK_THREAD_OPTIONS, + SLACK_TRIGGER_OUTPUTS, + slackEventsSupportingFilter, +} from '@/triggers/slack/shared' import type { TriggerConfig } from '@/triggers/types' +// Filter sub-block gating is derived from the catalog's `filters` field so the +// UI conditions and the ingest route share one source of truth. +const SOURCE_FILTER_EVENTS = slackEventsSupportingFilter('source') +const CHANNEL_FILTER_EVENTS = slackEventsSupportingFilter('channels') +const THREAD_FILTER_EVENTS = slackEventsSupportingFilter('threads') +const EMOJI_FILTER_EVENTS = slackEventsSupportingFilter('emoji') +const NAME_FILTER_EVENTS = slackEventsSupportingFilter('name') +// Bot/own toggles gate UI visibility only (the route applies them unconditionally), +// so they are not catalog `filters`. +const BOT_FILTER_EVENTS = ['message', 'app_mention'] +const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reaction_removed'] + /** * Unified Slack trigger. App Type selects how events arrive: * - `sim` (default): the official Sim Slack app the user OAuth-connects — events @@ -21,15 +41,20 @@ export const slackOAuthTrigger: TriggerConfig = { subBlocks: [ { - id: 'events', - title: 'Operations', + id: 'eventType', + title: 'Event', type: 'dropdown', - multiSelect: true, - options: [...SLACK_TRIGGER_EVENT_OPTIONS], - placeholder: 'Select operations', - description: 'Which Slack events should trigger this workflow.', + options: [...SLACK_SIM_EVENT_OPTIONS], + placeholder: 'Select an event', + description: + 'The single Slack event this trigger fires on. Add another trigger block for another event.', required: true, mode: 'trigger', + dependsOn: ['appType'], + fetchOptions: async (blockId: string) => { + const appType = useSubBlockStore.getState().getValue(blockId, 'appType') + return appType === 'custom' ? [...SLACK_ALL_EVENT_OPTIONS] : [...SLACK_SIM_EVENT_OPTIONS] + }, }, { id: 'appType', @@ -55,9 +80,22 @@ export const slackOAuthTrigger: TriggerConfig = { mode: 'trigger', condition: { field: 'appType', value: 'sim' }, }, + { + id: 'source', + title: 'Source', + type: 'dropdown', + multiSelect: true, + options: [...SLACK_SOURCE_OPTIONS], + placeholder: 'Any source', + description: + 'Restrict to direct messages, public channels, or private channels. Leave empty to match any.', + required: false, + mode: 'trigger', + condition: { field: 'eventType', value: SOURCE_FILTER_EVENTS }, + }, { id: 'channelFilter', - title: 'Channels (optional)', + title: 'Channels', type: 'channel-selector', canonicalParamId: 'channelFilter', multiSelect: true, @@ -65,10 +103,11 @@ export const slackOAuthTrigger: TriggerConfig = { selectorKey: 'slack.channels', placeholder: 'Any channel the bot is in', description: - 'Restrict channel, mention, and reaction events to specific channels. Leave empty to trigger on any channel the bot has been added to. Does not apply to direct messages.', + 'Restrict to specific channels. Leave empty to trigger on any channel the bot has been added to.', dependsOn: ['triggerCredentials'], required: false, mode: 'trigger', + condition: { field: 'eventType', value: CHANNEL_FILTER_EVENTS }, }, { id: 'manualChannelFilter', @@ -79,6 +118,39 @@ export const slackOAuthTrigger: TriggerConfig = { description: 'Comma-separated channel IDs to restrict to. Set IDs directly here.', required: false, mode: 'trigger-advanced', + condition: { field: 'eventType', value: CHANNEL_FILTER_EVENTS }, + }, + { + id: 'threads', + title: 'Threads', + type: 'dropdown', + options: [...SLACK_THREAD_OPTIONS], + value: () => 'include', + description: + 'Include thread replies, exclude them (top-level only), or fire only on thread replies.', + required: false, + mode: 'trigger', + condition: { field: 'eventType', value: THREAD_FILTER_EVENTS }, + }, + { + id: 'emoji', + title: 'Emoji', + type: 'short-input', + placeholder: 'thumbsup, white_check_mark', + description: 'Comma-separated emoji names to restrict to. Leave empty to match any emoji.', + required: false, + mode: 'trigger', + condition: { field: 'eventType', value: EMOJI_FILTER_EVENTS }, + }, + { + id: 'nameContains', + title: 'Name contains', + type: 'short-input', + placeholder: 'incident-', + description: 'Only fire when the created channel name contains this text.', + required: false, + mode: 'trigger', + condition: { field: 'eventType', value: NAME_FILTER_EVENTS }, }, { id: 'webhookUrlDisplay', @@ -116,12 +188,24 @@ export const slackOAuthTrigger: TriggerConfig = { }, { id: 'filterBotMessages', - title: 'Filter bot messages', + title: 'Ignore bot messages', type: 'switch', defaultValue: true, - description: 'Ignore messages sent by bots (including this app) to prevent loops.', + description: "Ignore messages sent by other bots. This app's own output is always ignored.", required: false, mode: 'trigger', + condition: { field: 'eventType', value: BOT_FILTER_EVENTS }, + }, + { + id: 'includeOwnMessages', + title: 'Include own bot messages', + type: 'switch', + defaultValue: false, + description: + "Also fire on this app's own messages and reactions. Can cause loops — use with care.", + required: false, + mode: 'trigger-advanced', + condition: { field: 'eventType', value: OWN_MESSAGE_EVENTS }, }, { id: 'includeFiles', @@ -131,6 +215,7 @@ export const slackOAuthTrigger: TriggerConfig = { description: 'Download and include file attachments from messages. Requires files:read.', required: false, mode: 'trigger', + condition: { field: 'eventType', value: 'message' }, }, { id: 'setupWizard', diff --git a/apps/sim/triggers/slack/shared.ts b/apps/sim/triggers/slack/shared.ts index 9665f2a414c..50a8fe1bdf0 100644 --- a/apps/sim/triggers/slack/shared.ts +++ b/apps/sim/triggers/slack/shared.ts @@ -117,6 +117,11 @@ export const SLACK_TRIGGER_OUTPUTS: Record = { type: 'string', description: 'Slack app ID. Present for interactivity and slash commands', }, + app_id: { + type: 'string', + description: + "App ID of the app that produced the event (e.g. the bot that posted a message). Used to identify the app's own output", + }, message_ts: { type: 'string', description: @@ -151,27 +156,153 @@ export const SLACK_TRIGGER_OUTPUTS: Record = { } /** - * Selectable trigger events for the native OAuth (`slack_app`) trigger. Each id - * is a Slack bot event that the shared Sim app subscribes to; the ingest route - * filters inbound events against the user's selection. + * A contextual filter a Slack event can expose. Each entry's `filters` list + * drives both which filter sub-blocks the trigger UI shows and which checks the + * ingest route applies: + * - `source`: restrict a message event to DMs / public / private channels. + * - `channels`: restrict to specific channel IDs. + * - `threads`: include / exclude / only thread replies. + * - `emoji`: restrict a reaction event to specific emoji names. + * - `name`: substring match on a created channel's name. + */ +export type SlackEventFilter = 'source' | 'channels' | 'threads' | 'emoji' | 'name' + +export interface SlackEventCatalogEntry { + /** Selected value stored under the `eventType` sub-block. */ + id: string + label: string + /** True when the official shared Sim app already subscribes to the event. */ + simSubscribed: boolean + /** Contextual filters this event supports. */ + filters: readonly SlackEventFilter[] +} + +/** + * The full catalog of selectable events for the native OAuth (`slack_app`) + * trigger, and the single source of truth for event gating. One trigger block + * fires on exactly one `id`. `simSubscribed` gates which events are offered in + * Sim mode (the official app), while every event is offered in Custom mode + * (the bring-your-own app generates a manifest that subscribes to it, driven by + * SLACK_CAPABILITIES). `filters` drives both the trigger UI (which filter + * sub-blocks show) and the ingest route (which checks apply). + */ +export const SLACK_EVENT_CATALOG: readonly SlackEventCatalogEntry[] = [ + { + id: 'message', + label: 'Message', + simSubscribed: true, + filters: ['source', 'channels', 'threads'], + }, + { + id: 'app_mention', + label: 'App mentioned', + simSubscribed: true, + filters: ['channels', 'threads'], + }, + { + id: 'reaction_added', + label: 'Reaction added', + simSubscribed: true, + filters: ['emoji', 'channels'], + }, + { + id: 'reaction_removed', + label: 'Reaction removed', + simSubscribed: true, + filters: ['emoji', 'channels'], + }, + { + id: 'message_edited', + label: 'Message edited', + simSubscribed: true, + filters: ['source', 'channels'], + }, + { + id: 'message_deleted', + label: 'Message deleted', + simSubscribed: true, + filters: ['source', 'channels'], + }, + { id: 'file_shared', label: 'File shared', simSubscribed: false, filters: ['channels'] }, + { + id: 'member_joined_channel', + label: 'Member joined channel', + simSubscribed: false, + filters: ['channels'], + }, + { + id: 'member_left_channel', + label: 'Member left channel', + simSubscribed: false, + filters: ['channels'], + }, + { id: 'channel_created', label: 'Channel created', simSubscribed: false, filters: ['name'] }, + { id: 'channel_archive', label: 'Channel archived', simSubscribed: false, filters: ['channels'] }, + { id: 'channel_rename', label: 'Channel renamed', simSubscribed: false, filters: ['channels'] }, + { id: 'pin_added', label: 'Pin added', simSubscribed: false, filters: ['channels'] }, + { id: 'pin_removed', label: 'Pin removed', simSubscribed: false, filters: ['channels'] }, + { id: 'team_join', label: 'Member joined workspace', simSubscribed: false, filters: [] }, + { id: 'app_home_opened', label: 'App home opened', simSubscribed: false, filters: [] }, + { id: 'assistant_thread_started', label: 'Assistant opened', simSubscribed: true, filters: [] }, + { + id: 'assistant_thread_context_changed', + label: 'Assistant context changed', + simSubscribed: true, + filters: [], + }, +] as const + +/** Catalog entry lookup by `eventType` id. */ +export const slackEventById = new Map( + SLACK_EVENT_CATALOG.map((entry) => [entry.id, entry]) +) + +/** Event ids the official shared Sim app subscribes to (Sim-mode gating SOT). */ +export const SIM_SUBSCRIBED_EVENTS: readonly string[] = SLACK_EVENT_CATALOG.filter( + (entry) => entry.simSubscribed +).map((entry) => entry.id) + +/** Dropdown options for Sim mode — only officially-subscribed events. */ +export const SLACK_SIM_EVENT_OPTIONS = SLACK_EVENT_CATALOG.filter( + (entry) => entry.simSubscribed +).map((entry) => ({ label: entry.label, id: entry.id })) + +/** Dropdown options for Custom mode — every event (manifest generated on demand). */ +export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({ + label: entry.label, + id: entry.id, +})) + +/** + * Message-source filter options. Each id maps to a Slack `channel_type`. The + * filter is multiselect and empty means any source, so "public + private, no + * DMs" is `[channel, group]` — a case a single-select could not express. */ -export const SLACK_TRIGGER_EVENT_OPTIONS = [ - { label: 'App mention', id: 'app_mention' }, - { label: 'Direct message', id: 'message.im' }, - { label: 'Public channel message', id: 'message.channels' }, - { label: 'Private channel message', id: 'message.groups' }, - { label: 'Reaction added', id: 'reaction_added' }, - { label: 'Reaction removed', id: 'reaction_removed' }, +export const SLACK_SOURCE_OPTIONS = [ + { label: 'Direct message', id: 'im' }, + { label: 'Public channel', id: 'channel' }, + { label: 'Private channel', id: 'group' }, +] as const + +/** Three-way thread filter options. */ +export const SLACK_THREAD_OPTIONS = [ + { label: 'Messages and replies', id: 'include' }, + { label: 'Messages only (no replies)', id: 'exclude' }, + { label: 'Replies only', id: 'only' }, ] as const +/** True when the given event id exposes the given contextual filter. */ +export function slackEventSupportsFilter(eventType: string, filter: SlackEventFilter): boolean { + return slackEventById.get(eventType)?.filters.includes(filter) ?? false +} + /** - * Event ids that occur in a specific channel and therefore honor the channel - * filter. Direct-message events (`message.im`) are intentionally excluded. + * Event ids that expose the given filter — the catalog-derived list used for + * trigger sub-block `condition` gating, so the UI and the ingest route share one + * source of truth. */ -export const SLACK_CHANNEL_SCOPED_EVENTS = new Set([ - 'app_mention', - 'message.channels', - 'message.groups', - 'reaction_added', - 'reaction_removed', -]) +export function slackEventsSupportingFilter(filter: SlackEventFilter): string[] { + return SLACK_EVENT_CATALOG.filter((entry) => entry.filters.includes(filter)).map( + (entry) => entry.id + ) +} From 9de9952ef8e7d88eb418dad2f00120ba2f20a51a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 6 Jul 2026 17:48:29 -0700 Subject: [PATCH 09/22] fix(slack-trigger): apply event/channel/bot filters on custom-app path too --- apps/sim/app/api/webhooks/slack/route.test.ts | 204 +++------------ apps/sim/app/api/webhooks/slack/route.ts | 209 +-------------- apps/sim/lib/webhooks/providers/slack.test.ts | 239 +++++++++++++++++- apps/sim/lib/webhooks/providers/slack.ts | 224 ++++++++++++++++ 4 files changed, 505 insertions(+), 371 deletions(-) diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts index a40230f125b..6708c10dc4e 100644 --- a/apps/sim/app/api/webhooks/slack/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/route.test.ts @@ -9,12 +9,14 @@ const { mockCheckWebhookPreprocessing, mockQueueWebhookExecution, mockBlockExistsInDeployment, + mockShouldSkip, } = vi.hoisted(() => ({ mockParseWebhookBody: vi.fn(), mockFindWebhooksByRoutingKey: vi.fn(), mockCheckWebhookPreprocessing: vi.fn(), mockQueueWebhookExecution: vi.fn(), mockBlockExistsInDeployment: vi.fn(), + mockShouldSkip: vi.fn(), })) vi.mock('@/lib/core/admission/gate', () => ({ @@ -36,12 +38,7 @@ vi.mock('@/lib/webhooks/processor', () => ({ vi.mock('@/lib/webhooks/providers/slack', () => ({ handleSlackChallenge: () => null, verifySlackRequestSignature: () => null, - resolveSlackEventChannel: (event: Record | undefined) => { - if (!event) return undefined - if (typeof event.channel === 'string') return event.channel - const item = event.item as Record | undefined - return typeof item?.channel === 'string' ? item.channel : undefined - }, + shouldSkipSlackTriggerEvent: mockShouldSkip, })) vi.mock('@/lib/workflows/persistence/utils', () => ({ @@ -50,8 +47,6 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ import { POST } from '@/app/api/webhooks/slack/route' -const API_APP_ID = 'A_SELF' - function makeRequest() { return new Request('https://sim.test/api/webhooks/slack', { method: 'POST', @@ -59,187 +54,64 @@ function makeRequest() { }) as unknown as import('next/server').NextRequest } -function slackBody(event: Record, extra: Record = {}) { - return { team_id: 'T1', api_app_id: API_APP_ID, event, ...extra } +function webhook(id: string) { + return { webhook: { id, blockId: `blk-${id}`, providerConfig: {} }, workflow: { id: `wf-${id}` } } } -/** Drive the route with a single webhook whose providerConfig is `config`. */ -async function fireWith( - config: Record, - body: Record -): Promise { +async function run(body: Record) { mockParseWebhookBody.mockResolvedValue({ body, rawBody: JSON.stringify(body) }) - mockFindWebhooksByRoutingKey.mockResolvedValue([ - { - webhook: { id: 'wh1', blockId: 'blk1', providerConfig: config }, - workflow: { id: 'wf1' }, - }, - ]) mockCheckWebhookPreprocessing.mockResolvedValue({ actorUserId: 'u1', executionId: 'e1', correlation: {}, }) mockBlockExistsInDeployment.mockResolvedValue(true) - mockQueueWebhookExecution.mockClear() - await POST(makeRequest()) - return mockQueueWebhookExecution.mock.calls.length > 0 } -describe('Slack app webhook route filtering', () => { +const messageBody = { + team_id: 'T1', + api_app_id: 'A1', + event: { type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.1' }, +} + +describe('Slack app webhook route', () => { beforeEach(() => { vi.clearAllMocks() + mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')]) }) - it('fires a message event matching source=channel in a public channel', async () => { - const fired = await fireWith( - { eventType: 'message', source: ['channel'] }, - slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.1' }) - ) - expect(fired).toBe(true) - }) - - it('drops a DM when source is restricted to public channels', async () => { - const fired = await fireWith( - { eventType: 'message', source: ['channel'] }, - slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.1' }) - ) - expect(fired).toBe(false) - }) - - it('source=[public,private] fires on both channel types but drops DMs', async () => { - const source = ['channel', 'group'] - const publicMsg = await fireWith( - { eventType: 'message', source }, - slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.2' }) - ) - expect(publicMsg).toBe(true) - - const privateMsg = await fireWith( - { eventType: 'message', source }, - slackBody({ type: 'message', channel_type: 'group', channel: 'G1', ts: '1.3' }) - ) - expect(privateMsg).toBe(true) - - const dm = await fireWith( - { eventType: 'message', source }, - slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.4' }) - ) - expect(dm).toBe(false) + it('queues execution when the shared filter does not skip', async () => { + mockShouldSkip.mockReturnValue(false) + await run(messageBody) + expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(1) }) - it('empty source matches any channel type', async () => { - const dm = await fireWith( - { eventType: 'message', source: [] }, - slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.5' }) - ) - expect(dm).toBe(true) + it('does not queue when the shared filter skips the event', async () => { + mockShouldSkip.mockReturnValue(true) + await run(messageBody) + expect(mockQueueWebhookExecution).not.toHaveBeenCalled() }) - it('a channel filter never drops a DM allowed by Source', async () => { - const config = { eventType: 'message', source: ['im', 'channel'], channelFilter: ['C1'] } - // DM passes: the channel filter does not apply to DMs. - const dm = await fireWith( - config, - slackBody({ type: 'message', channel_type: 'im', channel: 'D1', ts: '1.6' }) - ) - expect(dm).toBe(true) - // Public message in the allowed channel fires. - const inChannel = await fireWith( - config, - slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.7' }) - ) - expect(inChannel).toBe(true) - // Public message in another channel is dropped by the channel filter. - const otherChannel = await fireWith( - config, - slackBody({ type: 'message', channel_type: 'channel', channel: 'C2', ts: '1.8' }) + it('routes via Slack Connect authorizations and dedups overlapping webhooks', async () => { + mockShouldSkip.mockReturnValue(false) + // Two candidate teams (outer + authorization) that resolve to overlapping webhooks. + mockFindWebhooksByRoutingKey.mockImplementation(async (teamId: string) => + teamId === 'T1' ? [webhook('wh1')] : [webhook('wh1'), webhook('wh2')] ) - expect(otherChannel).toBe(false) - }) - - it('app_mention Threads=Only fires only on threaded mentions', async () => { - const topLevel = await fireWith( - { eventType: 'app_mention', threads: 'only' }, - slackBody({ type: 'app_mention', channel: 'C1', ts: '2.0' }) - ) - expect(topLevel).toBe(false) - - const threaded = await fireWith( - { eventType: 'app_mention', threads: 'only' }, - slackBody({ type: 'app_mention', channel: 'C1', ts: '2.1', thread_ts: '2.0' }) - ) - expect(threaded).toBe(true) - }) - - it('maps message_changed to message_edited and not to message', async () => { - const editBody = slackBody({ - type: 'message', - subtype: 'message_changed', - channel_type: 'channel', - channel: 'C1', - ts: '3.1', + await run({ + ...messageBody, + authorizations: [{ team_id: 'T2' }], }) - expect(await fireWith({ eventType: 'message_edited' }, editBody)).toBe(true) - expect(await fireWith({ eventType: 'message' }, editBody)).toBe(false) + expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledTimes(2) + // wh1 (in both) is queued once, wh2 once — dedup by webhook id. + expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(2) }) - it("self-drops the app's own message unless includeOwnMessages is set", async () => { - const ownBody = slackBody({ - type: 'message', - channel_type: 'channel', - channel: 'C1', - ts: '4.1', - app_id: API_APP_ID, - bot_id: 'B1', - }) - expect(await fireWith({ eventType: 'message' }, ownBody)).toBe(false) - expect(await fireWith({ eventType: 'message', includeOwnMessages: true }, ownBody)).toBe(true) - }) - - it("self-drops the app's own reaction via stored bot_user_id", async () => { - const body = slackBody({ - type: 'reaction_added', - reaction: 'thumbsup', - user: 'U_BOT', - item: { channel: 'C1', ts: '5.0' }, - }) - expect(await fireWith({ eventType: 'reaction_added', bot_user_id: 'U_BOT' }, body)).toBe(false) - expect(await fireWith({ eventType: 'reaction_added', bot_user_id: 'U_OTHER' }, body)).toBe(true) - }) - - it('applies the emoji filter to reaction events', async () => { - const body = slackBody({ - type: 'reaction_added', - reaction: 'eyes', - user: 'U1', - item: { channel: 'C1', ts: '6.0' }, - }) - expect(await fireWith({ eventType: 'reaction_added', emoji: 'thumbsup' }, body)).toBe(false) - expect(await fireWith({ eventType: 'reaction_added', emoji: 'eyes, thumbsup' }, body)).toBe( - true - ) - }) - - it('honors the legacy events array for pre-redesign webhooks', async () => { - const fired = await fireWith( - { events: ['message.channels'] }, - slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '7.1' }) - ) - expect(fired).toBe(true) - }) - - it('ignores other bots but not our own drop path when filterBotMessages is on', async () => { - const otherBot = slackBody({ - type: 'message', - channel_type: 'channel', - channel: 'C1', - ts: '8.1', - bot_id: 'B_OTHER', - app_id: 'A_OTHER', - }) - expect(await fireWith({ eventType: 'message' }, otherBot)).toBe(false) - expect(await fireWith({ eventType: 'message', filterBotMessages: false }, otherBot)).toBe(true) + it('returns 200 with no team_id', async () => { + mockShouldSkip.mockReturnValue(false) + await run({ event: { type: 'message' } }) + expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() + expect(mockQueueWebhookExecution).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index d8edb536cfd..03b59508b73 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -12,11 +12,10 @@ import { } from '@/lib/webhooks/processor' import { handleSlackChallenge, - resolveSlackEventChannel, + shouldSkipSlackTriggerEvent, verifySlackRequestSignature, } from '@/lib/webhooks/providers/slack' import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' -import { type SlackEventFilter, slackEventSupportsFilter } from '@/triggers/slack/shared' const logger = createLogger('SlackAppWebhookAPI') @@ -24,117 +23,6 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 60 -/** Message subtypes that carry real content (vs system/join/topic messages). */ -const CONTENT_MESSAGE_SUBTYPES = new Set([ - 'file_share', - 'me_message', - 'thread_broadcast', - 'bot_message', -]) - -/** - * Maps an inbound Slack Events API payload to the trigger `eventType` id it - * satisfies (see SLACK_EVENT_CATALOG). Returns null for payloads we do not - * surface. For most events the Slack `event.type` is the id verbatim; `message` - * fans out to `message` / `message_edited` / `message_deleted` by subtype. - */ -function resolveSlackEventKey(body: Record): string | null { - const event = body.event as Record | undefined - if (!event) return null - const type = event.type as string | undefined - - switch (type) { - case 'app_mention': - case 'reaction_added': - case 'reaction_removed': - case 'file_shared': - case 'member_joined_channel': - case 'member_left_channel': - case 'channel_created': - case 'channel_archive': - case 'channel_rename': - case 'pin_added': - case 'pin_removed': - case 'team_join': - case 'app_home_opened': - case 'assistant_thread_started': - case 'assistant_thread_context_changed': - return type - case 'message': { - const subtype = event.subtype as string | undefined - if (subtype === 'message_changed') return 'message_edited' - if (subtype === 'message_deleted') return 'message_deleted' - // Edits/deletes are handled above; other non-content subtypes (joins, - // topic/name changes, etc.) are not surfaced. `bot_message` is content so - // the "Ignore bot messages" toggle can decide, rather than being dropped. - if (subtype && !CONTENT_MESSAGE_SUBTYPES.has(subtype)) return null - return 'message' - } - default: - return null - } -} - -/** True when the message originated from a bot (used to ignore other bots). */ -function isBotMessage(event: Record | undefined): boolean { - if (!event) return false - return Boolean(event.bot_id) || event.subtype === 'bot_message' -} - -/** - * True when the event was produced by this Slack app itself (its own message or - * bot output), identified by matching the producing `app_id` against the - * payload's `api_app_id`. Config-independent — used for the always-on self-drop. - */ -function isOwnAppEvent( - event: Record | undefined, - apiAppId: string | undefined -): boolean { - if (!event || !apiAppId) return false - const appId = - (event.app_id as string | undefined) ?? - ((event.bot_profile as Record | undefined)?.app_id as string | undefined) - return appId === apiAppId -} - -/** True when the event is a thread reply (Slack-canonical: thread_ts set and != ts). */ -function isThreadReply(event: Record | undefined): boolean { - if (!event) return false - const threadTs = event.thread_ts as string | undefined - const ts = event.ts as string | undefined - return typeof threadTs === 'string' && threadTs.length > 0 && threadTs !== ts -} - -function normalizeSelection(value: unknown): string[] { - if (Array.isArray(value)) return value.map(String) - if (typeof value === 'string' && value.length > 0) return value.split(',').map((v) => v.trim()) - return [] -} - -/** - * Back-compat matcher for pre-redesign webhooks that stored a multi-select - * `events` array of old ids (`message.im`, `message.channels`, ...). Maps the - * new `eventKey` back onto the legacy selection so existing deployments keep - * firing until they are re-deployed onto the single-event model. - */ -function matchesLegacyEvents( - rawEvents: unknown, - eventKey: string | null, - channelType: string | undefined -): boolean { - const events = normalizeSelection(rawEvents) - if (events.length === 0 || !eventKey) return false - for (const legacy of events) { - if (legacy === eventKey) return true - if (eventKey === 'message') { - if (legacy === 'message.im' && channelType === 'im') return true - if (legacy === 'message.channels' && channelType === 'channel') return true - if (legacy === 'message.groups' && channelType === 'group') return true - } - } - return false -} - /** * Single ingest endpoint for the official Sim Slack app. Every workspace's * events arrive here and are routed to listening workflows by Slack `team_id` @@ -216,17 +104,6 @@ async function handleSlackAppWebhook(request: NextRequest): Promise | undefined - const eventKey = resolveSlackEventKey(payload) - const apiAppId = typeof payload.api_app_id === 'string' ? payload.api_app_id : undefined - const eventChannel = resolveSlackEventChannel(rawEvent) - const channelType = rawEvent?.channel_type as string | undefined - const reaction = rawEvent?.reaction as string | undefined - const reactor = rawEvent?.user as string | undefined - const isReply = isThreadReply(rawEvent) - const isOwn = isOwnAppEvent(rawEvent, apiAppId) - const isBot = isBotMessage(rawEvent) - const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') const triggerTimestampMs = slackRequestTimestamp ? Number(slackRequestTimestamp) * 1000 @@ -234,87 +111,11 @@ async function handleSlackAppWebhook(request: NextRequest): Promise) || {} - const configuredEvent = - typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null - - // Match the single configured event. Pre-redesign webhooks fall back to the - // legacy multi-select `events` array. - if (configuredEvent) { - if (!eventKey || eventKey !== configuredEvent) continue - } else if (!matchesLegacyEvents(providerConfig.events, eventKey, channelType)) { - continue - } - - const supports = (filter: SlackEventFilter): boolean => - configuredEvent !== null && slackEventSupportsFilter(configuredEvent, filter) - - // Source filter — restrict a message event to any of DM / public / private - // (multiselect by `channel_type`). Empty means any source. - if (supports('source')) { - const sources = normalizeSelection(providerConfig.source) - if (sources.length > 0 && (!channelType || !sources.includes(channelType))) continue - } - - // Threads filter — include / exclude / only. - if (supports('threads')) { - const threads = - typeof providerConfig.threads === 'string' ? providerConfig.threads : 'include' - if (threads === 'exclude' && isReply) continue - if (threads === 'only' && !isReply) continue - } - // Emoji filter — restrict a reaction event to specific emoji names. - if (supports('emoji')) { - const emojis = normalizeSelection(providerConfig.emoji) - if (emojis.length > 0 && (!reaction || !emojis.includes(reaction))) continue - } - - // Name-contains filter — restrict channel_created to matching names. - if (supports('name')) { - const needle = - typeof providerConfig.nameContains === 'string' ? providerConfig.nameContains.trim() : '' - if (needle) { - const channel = rawEvent?.channel as Record | undefined - const name = typeof channel?.name === 'string' ? channel.name : '' - if (!name.includes(needle)) continue - } - } - - // Channel filter — picker (channelFilter) or manual IDs (manualChannelFilter), - // the basic/advanced sides of one canonical field. Prefer the picker so a - // stale manual value can't keep matching. DMs always skip it: a DM's channel - // is a DM id that can't be picked, so a DM allowed by Source must not be - // dropped by a channel filter meant for real channels. - const channelScoped = - channelType !== 'im' && (configuredEvent ? supports('channels') : Boolean(eventChannel)) - if (channelScoped) { - const pickerChannels = normalizeSelection(providerConfig.channelFilter) - const selectedChannels = - pickerChannels.length > 0 - ? pickerChannels - : normalizeSelection(providerConfig.manualChannelFilter) - if ( - selectedChannels.length > 0 && - (!eventChannel || !selectedChannels.includes(eventChannel)) - ) { - continue - } - } - - // Self-drop (invariant): never fire on this app's own output unless the - // advanced opt-in is set. Reactions identify self by the stored bot user id; - // messages by app_id. Other bots are dropped by the "Ignore bot messages" - // toggle, but never our own output. - const includeOwn = providerConfig.includeOwnMessages === true - let ownEvent = isOwn - if (eventKey === 'reaction_added' || eventKey === 'reaction_removed') { - const botUserId = - typeof providerConfig.bot_user_id === 'string' ? providerConfig.bot_user_id : undefined - ownEvent = Boolean(botUserId) && reactor === botUserId - } - if (ownEvent) { - if (!includeOwn) continue - } else if (isBot && providerConfig.filterBotMessages !== false) { + // Apply the shared trigger filter (event, source, threads, emoji, name, + // channels, self-drop, bot). The custom-app path applies the same via + // slackHandler.shouldSkipEvent. + if (shouldSkipSlackTriggerEvent(payload, providerConfig)) { continue } diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 1c261ba3ec3..19db4f2f2e7 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { slackHandler } from '@/lib/webhooks/providers/slack' +import { shouldSkipSlackTriggerEvent, slackHandler } from '@/lib/webhooks/providers/slack' const ctx = (body: unknown) => ({ webhook: {}, @@ -208,3 +208,240 @@ describe('slackHandler extractIdempotencyId', () => { expect(slackHandler.extractIdempotencyId!({})).toBeNull() }) }) + +const API_APP_ID = 'A_SELF' + +function slackBody(event: Record, extra: Record = {}) { + return { team_id: 'T1', api_app_id: API_APP_ID, event, ...extra } +} + +/** True when the event fires (i.e. is not skipped) for the given config. */ +function fires(config: Record, event: Record): boolean { + return !shouldSkipSlackTriggerEvent(slackBody(event), config) +} + +describe('shouldSkipSlackTriggerEvent', () => { + it('fires a message event matching source=channel in a public channel', () => { + expect( + fires( + { eventType: 'message', source: ['channel'] }, + { + type: 'message', + channel_type: 'channel', + channel: 'C1', + ts: '1.1', + } + ) + ).toBe(true) + }) + + it('drops a DM when source is restricted to public channels', () => { + expect( + fires( + { eventType: 'message', source: ['channel'] }, + { + type: 'message', + channel_type: 'im', + channel: 'D1', + ts: '1.1', + } + ) + ).toBe(false) + }) + + it('source=[public,private] fires on both channel types but drops DMs', () => { + const source = ['channel', 'group'] + expect( + fires( + { eventType: 'message', source }, + { + type: 'message', + channel_type: 'channel', + channel: 'C1', + ts: '1.2', + } + ) + ).toBe(true) + expect( + fires( + { eventType: 'message', source }, + { + type: 'message', + channel_type: 'group', + channel: 'G1', + ts: '1.3', + } + ) + ).toBe(true) + expect( + fires( + { eventType: 'message', source }, + { + type: 'message', + channel_type: 'im', + channel: 'D1', + ts: '1.4', + } + ) + ).toBe(false) + }) + + it('empty source matches any channel type', () => { + expect( + fires( + { eventType: 'message', source: [] }, + { + type: 'message', + channel_type: 'im', + channel: 'D1', + ts: '1.5', + } + ) + ).toBe(true) + }) + + it('a channel filter never drops a DM allowed by Source', () => { + const config = { eventType: 'message', source: ['im', 'channel'], channelFilter: ['C1'] } + expect(fires(config, { type: 'message', channel_type: 'im', channel: 'D1', ts: '1.6' })).toBe( + true + ) + expect( + fires(config, { type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.7' }) + ).toBe(true) + expect( + fires(config, { type: 'message', channel_type: 'channel', channel: 'C2', ts: '1.8' }) + ).toBe(false) + }) + + it('app_mention Threads=Only fires only on threaded mentions', () => { + expect( + fires( + { eventType: 'app_mention', threads: 'only' }, + { + type: 'app_mention', + channel: 'C1', + ts: '2.0', + } + ) + ).toBe(false) + expect( + fires( + { eventType: 'app_mention', threads: 'only' }, + { + type: 'app_mention', + channel: 'C1', + ts: '2.1', + thread_ts: '2.0', + } + ) + ).toBe(true) + }) + + it('maps message_changed to message_edited and not to message', () => { + const edit = { + type: 'message', + subtype: 'message_changed', + channel_type: 'channel', + channel: 'C1', + ts: '3.1', + } + expect(fires({ eventType: 'message_edited' }, edit)).toBe(true) + expect(fires({ eventType: 'message' }, edit)).toBe(false) + }) + + it("self-drops the app's own message unless includeOwnMessages is set", () => { + const own = { + type: 'message', + channel_type: 'channel', + channel: 'C1', + ts: '4.1', + app_id: API_APP_ID, + bot_id: 'B1', + } + expect(fires({ eventType: 'message' }, own)).toBe(false) + expect(fires({ eventType: 'message', includeOwnMessages: true }, own)).toBe(true) + }) + + it("self-drops the app's own reaction via stored bot_user_id", () => { + const event = { + type: 'reaction_added', + reaction: 'thumbsup', + user: 'U_BOT', + item: { channel: 'C1', ts: '5.0' }, + } + expect(fires({ eventType: 'reaction_added', bot_user_id: 'U_BOT' }, event)).toBe(false) + expect(fires({ eventType: 'reaction_added', bot_user_id: 'U_OTHER' }, event)).toBe(true) + }) + + it('applies the emoji filter to reaction events', () => { + const event = { + type: 'reaction_added', + reaction: 'eyes', + user: 'U1', + item: { channel: 'C1', ts: '6.0' }, + } + expect(fires({ eventType: 'reaction_added', emoji: 'thumbsup' }, event)).toBe(false) + expect(fires({ eventType: 'reaction_added', emoji: 'eyes, thumbsup' }, event)).toBe(true) + }) + + it('honors the legacy events array for pre-redesign webhooks', () => { + expect( + fires( + { events: ['message.channels'] }, + { + type: 'message', + channel_type: 'channel', + channel: 'C1', + ts: '7.1', + } + ) + ).toBe(true) + }) + + it('ignores other bots unless filterBotMessages is off', () => { + const otherBot = { + type: 'message', + channel_type: 'channel', + channel: 'C1', + ts: '8.1', + bot_id: 'B_OTHER', + app_id: 'A_OTHER', + } + expect(fires({ eventType: 'message' }, otherBot)).toBe(false) + expect(fires({ eventType: 'message', filterBotMessages: false }, otherBot)).toBe(true) + }) +}) + +describe('slackHandler.shouldSkipEvent (custom-app path)', () => { + const message = slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '9.1' }) + const skipCtx = (providerConfig: Record, body: unknown) => ({ + webhook: {}, + body, + requestId: 'r', + providerConfig, + }) + + it('applies the trigger filter for a slack_oauth webhook', () => { + // Configured for reactions, but a message arrives -> skip. + expect( + slackHandler.shouldSkipEvent!( + skipCtx({ triggerId: 'slack_oauth', eventType: 'reaction_added' }, message) + ) + ).toBe(true) + // Configured for messages -> fire. + expect( + slackHandler.shouldSkipEvent!( + skipCtx({ triggerId: 'slack_oauth', eventType: 'message' }, message) + ) + ).toBe(false) + }) + + it('never skips the legacy slack_webhook trigger (unfiltered)', () => { + expect( + slackHandler.shouldSkipEvent!( + skipCtx({ triggerId: 'slack_webhook', eventType: 'reaction_added' }, message) + ) + ).toBe(false) + expect(slackHandler.shouldSkipEvent!(skipCtx({}, message))).toBe(false) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 16e7e894b6d..01b52f0e722 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -12,11 +12,13 @@ import { } from '@/lib/core/security/input-validation.server' import type { AuthContext, + EventFilterContext, FormatInputContext, FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { type SlackEventFilter, slackEventSupportsFilter } from '@/triggers/slack/shared' const logger = createLogger('WebhookProvider:Slack') @@ -534,6 +536,221 @@ export function verifySlackRequestSignature( return null } +/** Message subtypes that carry real content (vs system/join/topic messages). */ +const CONTENT_MESSAGE_SUBTYPES = new Set([ + 'file_share', + 'me_message', + 'thread_broadcast', + 'bot_message', +]) + +/** + * Maps an inbound Slack Events API payload to the trigger `eventType` id it + * satisfies (see SLACK_EVENT_CATALOG). Returns null for payloads we do not + * surface. For most events the Slack `event.type` is the id verbatim; `message` + * fans out to `message` / `message_edited` / `message_deleted` by subtype. + */ +export function resolveSlackEventKey(body: Record): string | null { + const event = body.event as Record | undefined + if (!event) return null + const type = event.type as string | undefined + + switch (type) { + case 'app_mention': + case 'reaction_added': + case 'reaction_removed': + case 'file_shared': + case 'member_joined_channel': + case 'member_left_channel': + case 'channel_created': + case 'channel_archive': + case 'channel_rename': + case 'pin_added': + case 'pin_removed': + case 'team_join': + case 'app_home_opened': + case 'assistant_thread_started': + case 'assistant_thread_context_changed': + return type + case 'message': { + const subtype = event.subtype as string | undefined + if (subtype === 'message_changed') return 'message_edited' + if (subtype === 'message_deleted') return 'message_deleted' + // Edits/deletes are handled above; other non-content subtypes (joins, + // topic/name changes, etc.) are not surfaced. `bot_message` is content so + // the "Ignore bot messages" toggle can decide, rather than being dropped. + if (subtype && !CONTENT_MESSAGE_SUBTYPES.has(subtype)) return null + return 'message' + } + default: + return null + } +} + +/** True when the message originated from a bot (used to ignore other bots). */ +function isBotEvent(event: Record | undefined): boolean { + if (!event) return false + return Boolean(event.bot_id) || event.subtype === 'bot_message' +} + +/** + * True when the event was produced by this Slack app itself, identified by + * matching the producing `app_id` against the payload's `api_app_id`. + */ +function isOwnAppEvent( + event: Record | undefined, + apiAppId: string | undefined +): boolean { + if (!event || !apiAppId) return false + const appId = + (event.app_id as string | undefined) ?? + ((event.bot_profile as Record | undefined)?.app_id as string | undefined) + return appId === apiAppId +} + +/** True when the event is a thread reply (Slack-canonical: thread_ts set and != ts). */ +function isThreadReply(event: Record | undefined): boolean { + if (!event) return false + const threadTs = event.thread_ts as string | undefined + const ts = event.ts as string | undefined + return typeof threadTs === 'string' && threadTs.length > 0 && threadTs !== ts +} + +function normalizeSelection(value: unknown): string[] { + if (Array.isArray(value)) return value.map(String) + if (typeof value === 'string' && value.length > 0) return value.split(',').map((v) => v.trim()) + return [] +} + +/** + * Back-compat matcher for pre-redesign webhooks that stored a multi-select + * `events` array of old ids (`message.im`, `message.channels`, ...). Maps the + * new `eventKey` back onto the legacy selection so existing deployments keep + * firing until they are re-deployed onto the single-event model. + */ +function matchesLegacyEvents( + rawEvents: unknown, + eventKey: string | null, + channelType: string | undefined +): boolean { + const events = normalizeSelection(rawEvents) + if (events.length === 0 || !eventKey) return false + for (const legacy of events) { + if (legacy === eventKey) return true + if (eventKey === 'message') { + if (legacy === 'message.im' && channelType === 'im') return true + if (legacy === 'message.channels' && channelType === 'channel') return true + if (legacy === 'message.groups' && channelType === 'group') return true + } + } + return false +} + +/** + * Decides whether an inbound Slack event should be dropped for a `slack_oauth` + * trigger webhook, applying the configured event, source, threads, emoji, name, + * channel, self-drop, and bot filters. Shared by the native app ingest route + * (`/api/webhooks/slack`) and the custom-app path route (via + * `slackHandler.shouldSkipEvent`) so both backends filter identically. Returns + * true to skip. + */ +export function shouldSkipSlackTriggerEvent( + body: Record, + providerConfig: Record +): boolean { + const rawEvent = body.event as Record | undefined + const eventKey = resolveSlackEventKey(body) + const channelType = rawEvent?.channel_type as string | undefined + const configuredEvent = + typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null + + // Match the single configured event. Pre-redesign webhooks fall back to the + // legacy multi-select `events` array. + if (configuredEvent) { + if (!eventKey || eventKey !== configuredEvent) return true + } else if (!matchesLegacyEvents(providerConfig.events, eventKey, channelType)) { + return true + } + + const supports = (filter: SlackEventFilter): boolean => + configuredEvent !== null && slackEventSupportsFilter(configuredEvent, filter) + + // Source — restrict a message event to any of DM / public / private + // (multiselect by `channel_type`). Empty means any source. + if (supports('source')) { + const sources = normalizeSelection(providerConfig.source) + if (sources.length > 0 && (!channelType || !sources.includes(channelType))) return true + } + + // Threads — include / exclude / only. + if (supports('threads')) { + const threads = typeof providerConfig.threads === 'string' ? providerConfig.threads : 'include' + const reply = isThreadReply(rawEvent) + if (threads === 'exclude' && reply) return true + if (threads === 'only' && !reply) return true + } + + // Emoji — restrict a reaction event to specific emoji names. + if (supports('emoji')) { + const emojis = normalizeSelection(providerConfig.emoji) + const reaction = rawEvent?.reaction as string | undefined + if (emojis.length > 0 && (!reaction || !emojis.includes(reaction))) return true + } + + // Name-contains — restrict channel_created to matching names. + if (supports('name')) { + const needle = + typeof providerConfig.nameContains === 'string' ? providerConfig.nameContains.trim() : '' + if (needle) { + const channel = rawEvent?.channel as Record | undefined + const name = typeof channel?.name === 'string' ? channel.name : '' + if (!name.includes(needle)) return true + } + } + + // Channels — picker or manual IDs, the basic/advanced sides of one canonical + // field. DMs always skip it: a DM's channel can't be picked, so a DM allowed + // by Source must not be dropped by a channel filter meant for real channels. + const eventChannel = resolveSlackEventChannel(rawEvent) + const channelScoped = + channelType !== 'im' && (configuredEvent ? supports('channels') : Boolean(eventChannel)) + if (channelScoped) { + const pickerChannels = normalizeSelection(providerConfig.channelFilter) + const selectedChannels = + pickerChannels.length > 0 + ? pickerChannels + : normalizeSelection(providerConfig.manualChannelFilter) + if ( + selectedChannels.length > 0 && + (!eventChannel || !selectedChannels.includes(eventChannel)) + ) { + return true + } + } + + // Self-drop (invariant): never fire on this app's own output unless the + // advanced opt-in is set. Reactions identify self by the stored bot user id, + // messages by app_id. Other bots are dropped by the "Ignore bot messages" + // toggle, but never our own output. + const includeOwn = providerConfig.includeOwnMessages === true + let ownEvent = isOwnAppEvent( + rawEvent, + typeof body.api_app_id === 'string' ? body.api_app_id : undefined + ) + if (eventKey === 'reaction_added' || eventKey === 'reaction_removed') { + const botUserId = + typeof providerConfig.bot_user_id === 'string' ? providerConfig.bot_user_id : undefined + ownEvent = Boolean(botUserId) && (rawEvent?.user as string | undefined) === botUserId + } + if (ownEvent) { + if (!includeOwn) return true + } else if (isBotEvent(rawEvent) && providerConfig.filterBotMessages !== false) { + return true + } + + return false +} + export const slackHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const signingSecret = providerConfig.signingSecret as string | undefined @@ -547,6 +764,13 @@ export const slackHandler: WebhookProviderHandler = { return handleSlackChallenge(body) }, + shouldSkipEvent({ body, providerConfig }: EventFilterContext) { + // Only the unified `slack_oauth` trigger carries event/filter config on this + // (custom-app) path; the legacy `slack_webhook` trigger is unfiltered. + if (providerConfig.triggerId !== 'slack_oauth') return false + return shouldSkipSlackTriggerEvent(body as Record, providerConfig) + }, + extractIdempotencyId(body: unknown) { const obj = body as Record if (obj.event_id) { From 5ac0932ecf5000b61d2c4b047fc225f8c9347cc3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 6 Jul 2026 20:07:03 -0700 Subject: [PATCH 10/22] fix(slack-trigger): don't drop edit/delete events when channel_type is absent --- apps/sim/lib/webhooks/providers/slack.test.ts | 12 ++++++++++++ apps/sim/lib/webhooks/providers/slack.ts | 7 +++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 19db4f2f2e7..6d1df25c299 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -349,6 +349,18 @@ describe('shouldSkipSlackTriggerEvent', () => { expect(fires({ eventType: 'message' }, edit)).toBe(false) }) + it('does not drop an edit event that omits channel_type when a Source is selected', () => { + // message_changed payloads often omit channel_type; a Source selection must + // not silently swallow them. + const edit = { + type: 'message', + subtype: 'message_changed', + channel: 'C1', + ts: '3.2', + } + expect(fires({ eventType: 'message_edited', source: ['channel'] }, edit)).toBe(true) + }) + it("self-drops the app's own message unless includeOwnMessages is set", () => { const own = { type: 'message', diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 01b52f0e722..a0d423b78ba 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -676,10 +676,13 @@ export function shouldSkipSlackTriggerEvent( configuredEvent !== null && slackEventSupportsFilter(configuredEvent, filter) // Source — restrict a message event to any of DM / public / private - // (multiselect by `channel_type`). Empty means any source. + // (multiselect by `channel_type`). Empty means any source. Only filter when + // the channel_type is actually known: `message_changed` / `message_deleted` + // payloads often omit it, and dropping those on an unknown type would silently + // swallow every edit/delete. if (supports('source')) { const sources = normalizeSelection(providerConfig.source) - if (sources.length > 0 && (!channelType || !sources.includes(channelType))) return true + if (sources.length > 0 && channelType && !sources.includes(channelType)) return true } // Threads — include / exclude / only. From 9dfe5d5be997cf1ae5ea556f18443e40b9c70ada Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 13:05:36 -0700 Subject: [PATCH 11/22] feat(slack): reusable custom bot credentials, slack_v2 block, interactivity triggers - Custom bot as a workspace service-account credential (set up once, shared ingest URL /api/webhooks/slack/custom/{credentialId}, reused across triggers and actions) - slack_v2 action block: credential-based Custom Bot auth alongside Sim OAuth; v1 hidden from toolbar - Interaction triggers (block_actions / view_submission) with optional action/callback id filter; settings.interactivity in generated manifests - Setup wizard: name + description, full permissions by default with ChipDropdown customization; reconnect mode rotates secrets in place - Centralized service-account token resolution (unknown provider fails loudly) - Shared Slack webhook fan-out dispatcher for native + custom ingest routes --- apps/sim/app/api/auth/oauth/token/route.ts | 27 +- apps/sim/app/api/auth/oauth/utils.test.ts | 69 +++ apps/sim/app/api/auth/oauth/utils.ts | 113 ++++- apps/sim/app/api/credentials/[id]/route.ts | 4 + apps/sim/app/api/credentials/route.ts | 98 ++-- .../sim/app/api/tools/slack/channels/route.ts | 8 +- .../slack/custom/[credentialId]/route.test.ts | 144 ++++++ .../slack/custom/[credentialId]/route.ts | 88 ++++ apps/sim/app/api/webhooks/slack/route.test.ts | 26 + apps/sim/app/api/webhooks/slack/route.ts | 67 +-- .../[block]/integration-block-detail.tsx | 4 +- .../connect-service-account-modal.tsx | 93 +++- .../connect-slack-bot-modal.tsx | 460 ++++++++++++++++++ .../connected-credential-detail.tsx | 27 +- .../credential-selector.tsx | 50 +- .../editor/components/sub-block/sub-block.tsx | 4 +- apps/sim/blocks/blocks/slack.ts | 99 +++- apps/sim/blocks/registry-maps.minimal.ts | 3 +- apps/sim/blocks/registry-maps.ts | 3 +- apps/sim/blocks/types.ts | 6 + apps/sim/hooks/queries/credentials.ts | 4 + apps/sim/lib/api/contracts/credentials.ts | 43 +- .../lib/credentials/orchestration/index.ts | 42 ++ .../service-account-secret.test.ts | 125 +++++ .../lib/credentials/service-account-secret.ts | 139 ++++++ apps/sim/lib/integrations/oauth-service.ts | 9 +- apps/sim/lib/oauth/oauth.ts | 1 + apps/sim/lib/oauth/types.ts | 19 + apps/sim/lib/oauth/utils.ts | 8 +- apps/sim/lib/webhooks/deploy.ts | 29 +- apps/sim/lib/webhooks/processor.ts | 7 +- apps/sim/lib/webhooks/providers/slack.test.ts | 76 ++- apps/sim/lib/webhooks/providers/slack.ts | 58 ++- apps/sim/lib/webhooks/slack-dispatch.ts | 81 +++ apps/sim/triggers/slack/capabilities.test.ts | 55 +++ apps/sim/triggers/slack/capabilities.ts | 77 ++- apps/sim/triggers/slack/oauth.ts | 85 ++-- apps/sim/triggers/slack/shared.ts | 16 +- .../emcn/src/components/wizard/wizard.tsx | 42 +- scripts/check-api-validation-contracts.ts | 1 + 40 files changed, 2018 insertions(+), 292 deletions(-) create mode 100644 apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts create mode 100644 apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx create mode 100644 apps/sim/lib/credentials/service-account-secret.test.ts create mode 100644 apps/sim/lib/credentials/service-account-secret.ts create mode 100644 apps/sim/lib/webhooks/slack-dispatch.ts create mode 100644 apps/sim/triggers/slack/capabilities.test.ts diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts index d7d1b9cd9f3..b7ac9593149 100644 --- a/apps/sim/app/api/auth/oauth/token/route.ts +++ b/apps/sim/app/api/auth/oauth/token/route.ts @@ -10,14 +10,12 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' import { - getAtlassianServiceAccountSecret, getCredential, getOAuthToken, - getServiceAccountToken, refreshTokenIfNeeded, resolveOAuthAccountId, + resolveServiceAccountToken, } from '@/app/api/auth/oauth/utils' export const dynamic = 'force-dynamic' @@ -121,23 +119,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { - const secret = await getAtlassianServiceAccountSecret(resolved.credentialId) - return NextResponse.json( - { - accessToken: secret.apiToken, - cloudId: secret.cloudId, - domain: secret.domain, - }, - { status: 200 } - ) - } - const accessToken = await getServiceAccountToken( + const result = await resolveServiceAccountToken( resolved.credentialId, + resolved.providerId, scopes ?? [], impersonateEmail ) - return NextResponse.json({ accessToken }, { status: 200 }) + return NextResponse.json( + { + accessToken: result.accessToken, + cloudId: result.cloudId, + domain: result.domain, + }, + { status: 200 } + ) } catch (error) { logger.error(`[${requestId}] Service account token error:`, error) return NextResponse.json({ error: 'Failed to get service account token' }, { status: 401 }) diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 78fdecf2975..77457cf3328 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -14,13 +14,25 @@ vi.mock('@/lib/oauth/oauth', () => ({ vi.mock('@/lib/core/config/redis', () => redisConfigMock) +const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() })) +vi.mock('@/lib/core/security/encryption', () => ({ + decryptSecret: mockDecryptSecret, + encryptSecret: vi.fn(async (value: string) => ({ encrypted: value, iv: 'iv' })), +})) + import { db } from '@sim/db' import { __resetCoalesceLocallyForTests } from '@/lib/concurrency/singleflight' import { refreshOAuthToken } from '@/lib/oauth' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' import { getCredential, refreshAccessTokenIfNeeded, refreshTokenIfNeeded, + resolveServiceAccountToken, } from '@/app/api/auth/oauth/utils' const mockDb = db as any @@ -262,4 +274,61 @@ describe('OAuth Utils', () => { expect(token).toBeNull() }) }) + + describe('resolveServiceAccountToken', () => { + it('throws loudly for an unknown provider (never silently attempts Google)', async () => { + await expect(resolveServiceAccountToken('cred-1', 'mystery-provider')).rejects.toThrow( + /Unsupported service-account provider/ + ) + }) + + it('returns the decrypted bot token for a custom Slack bot', async () => { + mockSelectChain([ + { + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + encryptedServiceAccountKey: 'enc', + }, + ]) + mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ signingSecret: 's', botToken: 'xoxb-tok', teamId: 'T1' }), + }) + const result = await resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID) + expect(result.accessToken).toBe('xoxb-tok') + }) + + it('throws when the Slack bot credential is missing', async () => { + mockSelectChain([]) + await expect( + resolveServiceAccountToken('cred-1', SLACK_CUSTOM_BOT_PROVIDER_ID) + ).rejects.toThrow(/Slack bot credential not found/) + }) + + it('returns apiToken + cloudId + domain for Atlassian', async () => { + mockSelectChain([{ encryptedServiceAccountKey: 'enc' }]) + mockDecryptSecret.mockResolvedValueOnce({ + decrypted: JSON.stringify({ + type: 'atlassian_service_account', + apiToken: 'atk', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + }), + }) + const result = await resolveServiceAccountToken( + 'cred-1', + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID + ) + expect(result).toMatchObject({ + accessToken: 'atk', + cloudId: 'cloud-1', + domain: 'acme.atlassian.net', + }) + }) + + it('requires scopes for a Google service account', async () => { + await expect( + resolveServiceAccountToken('cred-1', GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, []) + ).rejects.toThrow(/Scopes are required/) + }) + }) }) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 041a6a2ce53..3906b94feec 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -21,6 +21,8 @@ import { import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' const logger = createLogger('OAuthUtilsAPI') @@ -222,6 +224,60 @@ export async function getServiceAccountToken( return tokenData.access_token } +export interface SlackBotCredentialSecrets { + signingSecret: string + botToken: string + teamId: string + botUserId?: string + teamName?: string +} + +/** + * Decrypt a reusable custom Slack bot credential — a `service_account` credential + * with `providerId='slack-custom-bot'` whose encrypted blob holds the bring-your-own + * app's signing secret + bot token + derived team_id/bot_user_id. Returns null if + * the id is not such a credential (or its blob is incomplete). + * + * @remarks Server-internal. The native custom ingest route authenticates each + * request via the app's signing secret (not a user session), so this reader does + * no per-user authorization; callers with a user context authorize separately. + */ +export async function getSlackBotCredential( + credentialId: string +): Promise { + const [row] = await db + .select({ + type: credential.type, + providerId: credential.providerId, + encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + + if ( + !row || + row.type !== 'service_account' || + row.providerId !== SLACK_CUSTOM_BOT_PROVIDER_ID || + !row.encryptedServiceAccountKey + ) { + return null + } + + const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey) + const blob = JSON.parse(decrypted) as Partial + if (!blob.signingSecret || !blob.botToken || !blob.teamId) { + return null + } + return { + signingSecret: blob.signingSecret, + botToken: blob.botToken, + teamId: blob.teamId, + botUserId: blob.botUserId, + teamName: blob.teamName, + } +} + interface AtlassianServiceAccountSecret { type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE apiToken: string @@ -264,9 +320,45 @@ export async function getAtlassianServiceAccountSecret( * blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`. * No exchange or refresh is needed; we just decrypt and return the raw token. */ -async function getAtlassianServiceAccountToken(credentialId: string): Promise { - const secret = await getAtlassianServiceAccountSecret(credentialId) - return secret.apiToken +export interface ServiceAccountTokenResult { + accessToken: string + /** Atlassian only — the resolved Jira/Confluence cloud id. */ + cloudId?: string + /** Atlassian only — the site domain. */ + domain?: string +} + +/** + * Single dispatch point for turning a `service_account` credential into an + * access token, keyed on `providerId`. Both `refreshAccessTokenIfNeeded` and the + * `POST /api/auth/oauth/token` route go through here, so a new service-account + * provider is one edit and an unknown provider fails loudly instead of silently + * attempting a Google JWT. + */ +export async function resolveServiceAccountToken( + credentialId: string, + providerId: string | null | undefined, + scopes?: string[], + impersonateEmail?: string +): Promise { + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + const secret = await getAtlassianServiceAccountSecret(credentialId) + return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } + } + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + const botCredential = await getSlackBotCredential(credentialId) + if (!botCredential) { + throw new Error('Slack bot credential not found') + } + return { accessToken: botCredential.botToken } + } + if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + if (!scopes?.length) { + throw new Error('Scopes are required for service account credentials') + } + return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) } + } + throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`) } /** @@ -511,15 +603,14 @@ export async function refreshAccessTokenIfNeeded( } if (resolved.credentialType === 'service_account' && resolved.credentialId) { - if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { - logger.info(`[${requestId}] Using Atlassian service account token for credential`) - return getAtlassianServiceAccountToken(resolved.credentialId) - } - if (!scopes?.length) { - throw new Error('Scopes are required for service account credentials') - } logger.info(`[${requestId}] Using service account token for credential`) - return getServiceAccountToken(resolved.credentialId, scopes, impersonateEmail) + const { accessToken } = await resolveServiceAccountToken( + resolved.credentialId, + resolved.providerId, + scopes, + impersonateEmail + ) + return accessToken } // Use the already-resolved account ID to avoid a redundant resolveOAuthAccountId query diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 3dc507ed5ae..bd067d20a7e 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -82,6 +82,10 @@ export const PUT = withRouteHandler( displayName: body.displayName, description: body.description, serviceAccountJson: body.serviceAccountJson, + signingSecret: body.signingSecret, + botToken: body.botToken, + apiToken: body.apiToken, + domain: body.domain, request, }) if (!result.success) { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 318b905987b..39ce6f4a20e 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -10,11 +10,9 @@ import { createWorkspaceCredentialContract, credentialsListGetQuerySchema, normalizeCredentialEnvKey, - serviceAccountJsonSchema, } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { @@ -22,18 +20,15 @@ import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES, } from '@/lib/credentials/access' -import { - AtlassianValidationError, - normalizeAtlassianDomain, - validateAtlassianServiceAccount, -} from '@/lib/credentials/atlassian-service-account' +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getWorkspaceMembership } from '@/lib/credentials/environment' import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' -import { getServiceConfigByProviderId } from '@/lib/oauth' import { - ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, - ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, -} from '@/lib/oauth/types' + ServiceAccountSecretError, + verifyAndBuildServiceAccountSecret, +} from '@/lib/credentials/service-account-secret' +import { getServiceConfigByProviderId } from '@/lib/oauth' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { captureServerEvent } from '@/lib/posthog/server' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -314,6 +309,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { serviceAccountJson, apiToken, domain, + id: clientCredentialId, + signingSecret, + botToken, } = parsed.data.body const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) @@ -364,68 +362,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId } } else if (type === 'service_account') { - if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { - if (!apiToken || !domain) { - return NextResponse.json( - { error: 'apiToken and domain are required for Atlassian service account credentials' }, - { status: 400 } - ) - } - - const normalizedDomain = normalizeAtlassianDomain(domain) - const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain) - - resolvedProviderId = ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID - resolvedAccountId = null - resolvedEnvOwnerUserId = null - - if (!resolvedDisplayName) { - resolvedDisplayName = validation.displayName - } - - const blob = JSON.stringify({ - type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + try { + const secret = await verifyAndBuildServiceAccountSecret(providerId ?? '', { + signingSecret, + botToken, apiToken, - domain: normalizedDomain, - cloudId: validation.cloudId, - atlassianAccountId: validation.accountId, + domain, + serviceAccountJson, }) - const { encrypted } = await encryptSecret(blob) - resolvedEncryptedServiceAccountKey = encrypted - extraAuditMetadata.atlassianDomain = normalizedDomain - extraAuditMetadata.atlassianCloudId = validation.cloudId - } else { - if (!serviceAccountJson) { - return NextResponse.json( - { error: 'serviceAccountJson is required for service account credentials' }, - { status: 400 } - ) - } - - const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson) - if (!jsonParseResult.success) { - return NextResponse.json( - { - error: getValidationErrorMessage( - jsonParseResult.error, - 'Invalid service account JSON' - ), - }, - { status: 400 } - ) - } - - const parsedKey = jsonParseResult.data - resolvedProviderId = 'google-service-account' + resolvedProviderId = secret.providerId resolvedAccountId = null resolvedEnvOwnerUserId = null - if (!resolvedDisplayName) { - resolvedDisplayName = parsedKey.client_email + resolvedDisplayName = secret.displayName } - - const { encrypted } = await encryptSecret(serviceAccountJson) - resolvedEncryptedServiceAccountKey = encrypted + resolvedEncryptedServiceAccountKey = secret.encryptedServiceAccountKey + Object.assign(extraAuditMetadata, secret.auditMetadata) + } catch (error) { + if (error instanceof ServiceAccountSecretError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + throw error } } else if (type === 'env_personal') { resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id @@ -506,7 +463,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const now = new Date() - const credentialId = generateId() + // Honor a client-supplied id only for custom Slack bots — the setup modal + // shows the ingest URL `/api/webhooks/slack/custom/{id}` before secrets exist. + const credentialId = + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && clientCredentialId + ? clientCredentialId + : generateId() const { ownerId: workspaceOwnerId, memberUserIds: workspaceMemberUserIds } = await getWorkspaceMembership(workspaceId) diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts index 4eaeae69cd9..feb5a7b5153 100644 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ b/apps/sim/app/api/tools/slack/channels/route.ts @@ -91,11 +91,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } accessToken = resolvedToken - logger.info('Using OAuth token for Slack API') // resolvedCredentialId is an account.id only for OAuth credentials // (the service_account path returns a credential.id). if (authz.credentialType === 'oauth' && authz.resolvedCredentialId) { + logger.info('Using OAuth token for Slack API') const [accountRow] = await db .select({ accountId: account.accountId }) .from(account) @@ -104,6 +104,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (accountRow) { scopedUserId = parseScopedSlackUserId(accountRow.accountId) } + } else { + // A custom-bot service_account credential resolves to a bot token with + // no scoped user; treat it like a direct bot token so the private -> + // public channel fallback applies. + isBotToken = true + logger.info('Using custom bot token for Slack API') } } diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts new file mode 100644 index 00000000000..18be21165ec --- /dev/null +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockParseWebhookBody, + mockFindWebhooksByRoutingKey, + mockCheckWebhookPreprocessing, + mockQueueWebhookExecution, + mockBlockExistsInDeployment, + mockGetSlackBotCredential, + mockHandleChallenge, + mockVerifySignature, + mockShouldSkip, +} = vi.hoisted(() => ({ + mockParseWebhookBody: vi.fn(), + mockFindWebhooksByRoutingKey: vi.fn(), + mockCheckWebhookPreprocessing: vi.fn(), + mockQueueWebhookExecution: vi.fn(), + mockBlockExistsInDeployment: vi.fn(), + mockGetSlackBotCredential: vi.fn(), + mockHandleChallenge: vi.fn(), + mockVerifySignature: vi.fn(), + mockShouldSkip: vi.fn(), +})) + +vi.mock('@/lib/core/admission/gate', () => ({ + tryAdmit: () => ({ release: vi.fn() }), + admissionRejectedResponse: () => new Response(null, { status: 503 }), +})) + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + getSlackBotCredential: mockGetSlackBotCredential, +})) + +vi.mock('@/lib/webhooks/processor', () => ({ + parseWebhookBody: mockParseWebhookBody, + findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey, + checkWebhookPreprocessing: mockCheckWebhookPreprocessing, + queueWebhookExecution: mockQueueWebhookExecution, +})) + +vi.mock('@/lib/webhooks/providers/slack', () => ({ + handleSlackChallenge: mockHandleChallenge, + verifySlackRequestSignature: mockVerifySignature, + shouldSkipSlackTriggerEvent: mockShouldSkip, + resolveSlackEventKey: () => null, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + blockExistsInDeployment: mockBlockExistsInDeployment, +})) + +import { POST } from '@/app/api/webhooks/slack/custom/[credentialId]/route' + +const CREDENTIAL_ID = 'cred-123' + +function makeRequest() { + return new Request('https://sim.test/api/webhooks/slack/custom/cred-123', { + method: 'POST', + headers: { 'x-slack-request-timestamp': '1700000000' }, + }) as unknown as import('next/server').NextRequest +} + +const context = { params: Promise.resolve({ credentialId: CREDENTIAL_ID }) } + +const messageBody = { + team_id: 'T1', + api_app_id: 'A1', + event: { type: 'message', channel_type: 'channel', channel: 'C1', ts: '1.1' }, +} + +function webhook(id: string) { + return { webhook: { id, blockId: `blk-${id}`, providerConfig: {} }, workflow: { id: `wf-${id}` } } +} + +describe('Slack custom-bot webhook route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHandleChallenge.mockReturnValue(null) + mockVerifySignature.mockReturnValue(null) + mockShouldSkip.mockReturnValue(false) + mockParseWebhookBody.mockResolvedValue({ + body: messageBody, + rawBody: JSON.stringify(messageBody), + }) + mockGetSlackBotCredential.mockResolvedValue({ + signingSecret: 'sec', + botToken: 'xoxb-x', + teamId: 'T1', + }) + mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')]) + mockCheckWebhookPreprocessing.mockResolvedValue({ + actorUserId: 'u1', + executionId: 'e1', + correlation: {}, + }) + mockBlockExistsInDeployment.mockResolvedValue(true) + }) + + it('echoes the url_verification challenge without loading the credential', async () => { + mockHandleChallenge.mockReturnValue(new Response('ok', { status: 200 })) + await POST(makeRequest(), context) + expect(mockGetSlackBotCredential).not.toHaveBeenCalled() + expect(mockVerifySignature).not.toHaveBeenCalled() + }) + + it('404s an unknown credential', async () => { + mockGetSlackBotCredential.mockResolvedValue(null) + const res = await POST(makeRequest(), context) + expect(res.status).toBe(404) + expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + }) + + it('verifies with the credential signing secret and rejects a bad signature', async () => { + mockVerifySignature.mockReturnValue(new Response(null, { status: 401 })) + const res = await POST(makeRequest(), context) + expect(mockVerifySignature).toHaveBeenCalledWith( + 'sec', + expect.anything(), + expect.any(String), + expect.any(String) + ) + expect(res.status).toBe(401) + expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + }) + + it('fans out by credential id (provider slack) and queues when not skipped', async () => { + await POST(makeRequest(), context) + expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith( + CREDENTIAL_ID, + expect.any(String), + 'slack' + ) + expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(1) + }) + + it('does not queue when the shared filter skips the event', async () => { + mockShouldSkip.mockReturnValue(true) + await POST(makeRequest(), context) + expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts new file mode 100644 index 00000000000..2267179417d --- /dev/null +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor' +import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' +import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch' +import { getSlackBotCredential } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('SlackCustomBotWebhookAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 60 + +/** + * Ingest endpoint for a reusable custom Slack bot. The bot's single Event + * Subscriptions Request URL embeds its credential id, so every trigger that + * references the same bot credential shares one URL — routed here by + * `routingKey = credentialId` (mirrors the native `/api/webhooks/slack` + * `team_id` fan-out). Requests are HMAC-verified with the credential's OWN + * signing secret (not the shared env secret). + */ +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ credentialId: string }> }) => { + const ticket = tryAdmit() + if (!ticket) { + return admissionRejectedResponse() + } + try { + return await handleSlackCustomBotWebhook(request, context) + } finally { + ticket.release() + } + } +) + +async function handleSlackCustomBotWebhook( + request: NextRequest, + context: { params: Promise<{ credentialId: string }> } +): Promise { + const receivedAt = Date.now() + const requestId = generateRequestId() + const { credentialId } = await context.params + + const parseResult = await parseWebhookBody(request, requestId) + if (parseResult instanceof NextResponse) { + return parseResult + } + const { body, rawBody } = parseResult + + // Echo Slack's url_verification challenge unconditionally — this is how Slack + // verifies the Request URL when the app is created from the manifest, before + // the bot is installed / the credential is finalized. + const challenge = handleSlackChallenge(body) + if (challenge) { + return challenge + } + + const botCredential = await getSlackBotCredential(credentialId) + if (!botCredential) { + logger.warn(`[${requestId}] Unknown Slack bot credential ${credentialId}`) + return new NextResponse(null, { status: 404 }) + } + + const authError = verifySlackRequestSignature( + botCredential.signingSecret, + request, + rawBody, + requestId + ) + if (authError) { + return authError + } + + const webhooks = await findWebhooksByRoutingKey(credentialId, requestId, 'slack') + if (webhooks.length === 0) { + logger.info( + `[${requestId}] No active trigger for bot credential ${credentialId}; nothing to run` + ) + return new NextResponse(null, { status: 200 }) + } + + await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt }) + + return new NextResponse(null, { status: 200 }) +} diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts index 6708c10dc4e..6a9eec97965 100644 --- a/apps/sim/app/api/webhooks/slack/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/route.test.ts @@ -39,6 +39,7 @@ vi.mock('@/lib/webhooks/providers/slack', () => ({ handleSlackChallenge: () => null, verifySlackRequestSignature: () => null, shouldSkipSlackTriggerEvent: mockShouldSkip, + resolveSlackEventKey: () => null, })) vi.mock('@/lib/workflows/persistence/utils', () => ({ @@ -114,4 +115,29 @@ describe('Slack app webhook route', () => { expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() expect(mockQueueWebhookExecution).not.toHaveBeenCalled() }) + + it('routes an interaction payload by payload.team.id', async () => { + mockShouldSkip.mockReturnValue(false) + await run({ + type: 'block_actions', + api_app_id: 'A1', + team: { id: 'T1' }, + user: { id: 'U1' }, + actions: [{ action_id: 'approve_btn' }], + }) + expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith('T1', expect.anything()) + expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(1) + }) + + it('fails closed on an interaction missing payload.team.id (never routes on user.team_id)', async () => { + mockShouldSkip.mockReturnValue(false) + await run({ + type: 'block_actions', + api_app_id: 'A1', + user: { id: 'U1', team_id: 'T_OTHER' }, + actions: [{ action_id: 'approve_btn' }], + }) + expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() + expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 03b59508b73..6562b5f12a3 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -4,18 +4,9 @@ import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - checkWebhookPreprocessing, - findWebhooksByRoutingKey, - parseWebhookBody, - queueWebhookExecution, -} from '@/lib/webhooks/processor' -import { - handleSlackChallenge, - shouldSkipSlackTriggerEvent, - verifySlackRequestSignature, -} from '@/lib/webhooks/providers/slack' -import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' +import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor' +import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' +import { dispatchSlackWebhooks } from '@/lib/webhooks/slack-dispatch' const logger = createLogger('SlackAppWebhookAPI') @@ -87,6 +78,17 @@ async function handleSlackAppWebhook(request: NextRequest): Promise | undefined)?.id + if (typeof interactionTeamId === 'string' && interactionTeamId.length > 0) { + teamIds.add(interactionTeamId) + } + } if (teamIds.size === 0) { logger.warn(`[${requestId}] Slack event missing team_id`) return new NextResponse(null, { status: 200 }) @@ -104,46 +106,7 @@ async function handleSlackAppWebhook(request: NextRequest): Promise) || {} - - // Apply the shared trigger filter (event, source, threads, emoji, name, - // channels, self-drop, bot). The custom-app path applies the same via - // slackHandler.shouldSkipEvent. - if (shouldSkipSlackTriggerEvent(payload, providerConfig)) { - continue - } - - if (foundWebhook.blockId) { - const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) - if (!blockExists) { - logger.info( - `[${requestId}] Trigger block ${foundWebhook.blockId} not in deployment for ${foundWorkflow.id}` - ) - continue - } - } - - const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) - if (preprocessResult.error) { - logger.warn(`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}`) - continue - } - - await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { - requestId, - actorUserId: preprocessResult.actorUserId, - executionId: preprocessResult.executionId, - correlation: preprocessResult.correlation, - receivedAt, - triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined, - }) - } + await dispatchSlackWebhooks(webhooks, { body, request, requestId, receivedAt }) return new NextResponse(null, { status: 200 }) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 108e28f85b4..5c3bc46e5ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -12,6 +12,7 @@ import { resolveOAuthServiceForIntegration, } from '@/lib/integrations' import { getServiceConfigByProviderId } from '@/lib/oauth' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' @@ -73,6 +74,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration }, [credentials, oauthService]) const [serviceAccountOpen, setServiceAccountOpen] = useState(false) const hasServiceAccount = Boolean(oauthService?.serviceAccountProviderId) + const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID const hasHandledConnectQueryRef = useRef(false) useEffect(() => { @@ -105,7 +107,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration }, { value: CONNECT_MODE.serviceAccount, - label: 'Add service account', + label: isSlackBot ? 'Set up a custom bot' : 'Add service account', icon: oauthService.serviceIcon, }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index d28b84bdb35..f50ac47225e 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -14,8 +14,15 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isApiClientError } from '@/lib/api/client/errors' import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials' -import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' -import { useCreateWorkspaceCredential } from '@/hooks/queries/credentials' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' +import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' +import { + useCreateWorkspaceCredential, + useUpdateWorkspaceCredential, +} from '@/hooks/queries/credentials' const logger = createLogger('ConnectServiceAccountModal') @@ -24,6 +31,7 @@ const GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID = 'google-service-account' as const export type ServiceAccountProviderId = | typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID | typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID + | typeof SLACK_CUSTOM_BOT_PROVIDER_ID /** Sim setup guides for each provider, docked bottom-left of each modal. */ const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' @@ -78,6 +86,16 @@ interface ConnectServiceAccountModalProps { serviceAccountProviderId: ServiceAccountProviderId serviceName: string serviceIcon: ComponentType<{ className?: string }> + /** + * When set, the modal reconnects (rotates secrets on) this existing credential + * in place instead of creating a new one. The id is preserved, so shares and + * (for Slack) the ingest URL stay valid. + */ + credentialId?: string + /** Existing display name, used to seed reconnect-capable modals. */ + credentialDisplayName?: string + /** Existing description, used to seed reconnect-capable modals. */ + credentialDescription?: string } /** @@ -99,7 +117,22 @@ export function ConnectServiceAccountModal({ serviceAccountProviderId, serviceName, serviceIcon, + credentialId, + credentialDisplayName, + credentialDescription, }: ConnectServiceAccountModalProps) { + if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + return ( + + ) + } if (serviceAccountProviderId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { return ( ) } @@ -118,6 +152,7 @@ export function ConnectServiceAccountModal({ workspaceId={workspaceId} serviceName={serviceName} serviceIcon={serviceIcon} + credentialId={credentialId} /> ) } @@ -128,6 +163,8 @@ interface ProviderModalProps { workspaceId: string serviceName: string serviceIcon: ComponentType<{ className?: string }> + /** When set, reconnect (rotate secrets on) this credential in place. */ + credentialId?: string } /** @@ -141,6 +178,7 @@ function GoogleServiceAccountModal({ workspaceId, serviceName, serviceIcon: ServiceIcon, + credentialId, }: ProviderModalProps) { const [jsonInput, setJsonInput] = useState('') const [uploadedFileName, setUploadedFileName] = useState(null) @@ -149,6 +187,7 @@ function GoogleServiceAccountModal({ const [error, setError] = useState(null) const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() useEffect(() => { if (open) return @@ -209,13 +248,17 @@ function GoogleServiceAccountModal({ return } try { - await createCredential.mutateAsync({ - workspaceId, - type: 'service_account', - displayName: displayName.trim() || undefined, - description: description.trim() || undefined, - serviceAccountJson: trimmed, - }) + if (credentialId) { + await updateCredential.mutateAsync({ credentialId, serviceAccountJson: trimmed }) + } else { + await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + serviceAccountJson: trimmed, + }) + } onOpenChange(false) } catch (err: unknown) { const message = getErrorMessage(err, 'Failed to add service account') @@ -224,7 +267,7 @@ function GoogleServiceAccountModal({ } } - const isPending = createCredential.isPending + const isPending = createCredential.isPending || updateCredential.isPending const isDisabled = !jsonInput.trim() || isPending return ( @@ -315,6 +358,7 @@ function AtlassianServiceAccountModal({ workspaceId, serviceName, serviceIcon: ServiceIcon, + credentialId, }: ProviderModalProps) { const [apiToken, setApiToken] = useState('') const [domain, setDomain] = useState('') @@ -323,6 +367,7 @@ function AtlassianServiceAccountModal({ const [error, setError] = useState(null) const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() useEffect(() => { if (open) return @@ -338,22 +383,30 @@ function AtlassianServiceAccountModal({ const showDomainHint = normalizedDomain.length > 0 && !ATLASSIAN_DOMAIN_HINT_REGEX.test(normalizedDomain) - const isPending = createCredential.isPending + const isPending = createCredential.isPending || updateCredential.isPending const isDisabled = !trimmedToken || !normalizedDomain || isPending const handleSubmit = async () => { setError(null) if (isDisabled) return try { - await createCredential.mutateAsync({ - workspaceId, - type: 'service_account', - providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, - apiToken: trimmedToken, - domain: normalizedDomain, - displayName: displayName.trim() || undefined, - description: description.trim() || undefined, - }) + if (credentialId) { + await updateCredential.mutateAsync({ + credentialId, + apiToken: trimmedToken, + domain: normalizedDomain, + }) + } else { + await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + apiToken: trimmedToken, + domain: normalizedDomain, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) + } onOpenChange(false) } catch (err: unknown) { setError(messageForAtlassianError(err)) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx new file mode 100644 index 00000000000..0a6933988d5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -0,0 +1,460 @@ +'use client' + +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Button, + ChipDropdown, + type ChipDropdownOption, + ChipInput, + Code, + CopyCodeButton, + Label, + SecretInput, + Wizard, +} from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { Loader2 } from 'lucide-react' +import { SlackIcon } from '@/components/icons' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' +import { + useCreateWorkspaceCredential, + useUpdateWorkspaceCredential, +} from '@/hooks/queries/credentials' +import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' + +const logger = createLogger('ConnectSlackBotModal') + +const DEFAULT_APP_NAME = 'Sim Bot' +const DONE_STEP = 4 + +/** Every capability is granted by default; trimming is an opt-in dropdown. */ +const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id)) + +const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({ + value: c.id, + label: c.label, +})) + +interface ConnectSlackBotModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + /** + * When set, the modal reconnects (rotates secrets on) this existing credential + * instead of creating a new one — the id is reused so the Slack ingest URL + * `/api/webhooks/slack/custom/{id}` stays valid, and saving updates the + * credential in place. + */ + credentialId?: string + /** Existing display name, seeded into the bot-name field on reconnect. */ + initialDisplayName?: string + /** Existing description, seeded into the description field on reconnect. */ + initialDescription?: string + /** Called with the credential id after a successful create or reconnect. */ + onCreated?: (credentialId: string) => void +} + +/** + * One-time setup for a reusable custom Slack bot credential — the same guided + * wizard as the legacy in-block setup, but it persists a workspace credential + * instead of writing sub-block values. The credential id is pre-generated so the + * ingest URL `/api/webhooks/slack/custom/{id}` (and the manifest that embeds it) + * can be shown up front; the credential is created on the final step once the + * signing secret + bot token are pasted. + */ +export function ConnectSlackBotModal({ + open, + onOpenChange, + workspaceId, + credentialId: reconnectCredentialId, + initialDisplayName, + initialDescription, + onCreated, +}: ConnectSlackBotModalProps) { + const isReconnect = Boolean(reconnectCredentialId) + const [step, setStep] = useState(0) + const [credentialId, setCredentialId] = useState(() => reconnectCredentialId ?? generateId()) + const [appName, setAppName] = useState(initialDisplayName ?? '') + const [appDescription, setAppDescription] = useState(initialDescription ?? '') + const [selected, setSelected] = useState>(() => new Set(ALL_CAPABILITIES)) + const [signingSecret, setSigningSecret] = useState('') + const [botToken, setBotToken] = useState('') + const [createError, setCreateError] = useState(null) + const [created, setCreated] = useState(false) + + const createCredential = useCreateWorkspaceCredential() + const updateCredential = useUpdateWorkspaceCredential() + + useEffect(() => { + if (open) return + setStep(0) + setAppName(initialDisplayName ?? '') + setAppDescription(initialDescription ?? '') + setSelected(new Set(ALL_CAPABILITIES)) + setSigningSecret('') + setBotToken('') + setCreateError(null) + // Mint a fresh ingest id only after a bot was actually saved, and never when + // reconnecting (that id belongs to an existing credential + Slack app). + // Otherwise keep it stable so a user who already pasted this Request URL into + // their Slack app can reopen and finish creating the credential under the + // same id — a regenerated id would leave Slack posting to a URL no credential + // resolves. + if (created) { + if (!isReconnect) setCredentialId(generateId()) + setCreated(false) + } + }, [open, created, isReconnect, initialDisplayName, initialDescription]) + + // NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be + // able to reach this URL, so it has to be the app's public base (e.g. the + // tunnel host in dev), not whatever host the browser happens to be on. + const requestUrl = useMemo( + () => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`, + [credentialId] + ) + + const manifestJson = useMemo(() => { + const manifest = buildSlackManifest(selected, { + appName: appName.trim() || DEFAULT_APP_NAME, + webhookUrl: requestUrl, + description: appDescription, + }) + return JSON.stringify(manifest, null, 2) + }, [selected, appName, appDescription, requestUrl]) + + const capabilityIds = useMemo(() => [...selected], [selected]) + const setCapabilityIds = useCallback((next: string[]) => setSelected(new Set(next)), []) + + const isPending = createCredential.isPending || updateCredential.isPending + + const runCreate = useCallback(async () => { + setCreateError(null) + try { + if (isReconnect) { + // Rotate secrets on the existing credential in place — same id, so the + // Slack app's Request URL and any shares stay intact. + await updateCredential.mutateAsync({ + credentialId, + signingSecret: signingSecret.trim(), + botToken: botToken.trim(), + displayName: appName.trim() || undefined, + description: appDescription.trim() || undefined, + }) + } else { + await createCredential.mutateAsync({ + workspaceId, + type: 'service_account', + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + id: credentialId, + signingSecret: signingSecret.trim(), + botToken: botToken.trim(), + displayName: appName.trim() || undefined, + description: appDescription.trim() || undefined, + }) + } + setCreated(true) + onCreated?.(credentialId) + } catch (err: unknown) { + setCreateError(getErrorMessage(err, 'Could not connect the Slack bot.')) + logger.error('Failed to add custom Slack bot credential', err) + } + }, [ + isReconnect, + updateCredential, + createCredential, + workspaceId, + credentialId, + signingSecret, + botToken, + appName, + appDescription, + onCreated, + ]) + + // Create the credential once when the final step is first reached (reachable + // only after both secrets are entered). A ref guards against re-firing on + // failure — retry is manual via the "Try again" button. + const attemptedRef = useRef(false) + useEffect(() => { + if (step !== DONE_STEP) { + attemptedRef.current = false + return + } + if (attemptedRef.current) return + attemptedRef.current = true + void runCreate() + }, [step, runCreate]) + + return ( + + + + + + + + 0}> + + + 0}> + + + + + + + ) +} + +interface SubStepListProps { + children: ReactNode +} +function SubStepList({ children }: SubStepListProps) { + return
    {children}
+} + +interface SubStepProps { + n: number + children: ReactNode +} +function SubStep({ n, children }: SubStepProps) { + return ( +
  • + + {n} + +
    + {children} +
    +
  • + ) +} + +interface StepConfigureProps { + appName: string + onAppNameChange: (next: string) => void + appDescription: string + onAppDescriptionChange: (next: string) => void + capabilityIds: string[] + onCapabilityIdsChange: (next: string[]) => void +} +function StepConfigure({ + appName, + onAppNameChange, + appDescription, + onAppDescriptionChange, + capabilityIds, + onCapabilityIdsChange, +}: StepConfigureProps) { + const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length + + return ( +
    +
    + + onAppNameChange(e.target.value)} + placeholder={DEFAULT_APP_NAME} + /> +
    +
    + + onAppDescriptionChange(e.target.value)} + placeholder="Optional — shown on the bot's Slack profile" + maxLength={140} + /> +
    +
    + + + {allSelected && ( +

    + Full access — the bot can read and send messages, react, upload files, and chat as an AI + assistant. +

    + )} +
    +
    + ) +} + +interface StepCreateProps { + manifestJson: string +} +function StepCreate({ manifestJson }: StepCreateProps) { + return ( +
    + + +
    Copy your manifest:
    +
    +
    + manifest.json + +
    + +
    +
    + + Open the{' '} + + Slack Apps page + + . + + + Click Create New AppFrom a manifest and pick your + workspace. + + + Paste your manifest, then click NextCreate. + +
    +
    + ) +} + +interface SecretStepProps { + value: string + onChange: (next: string) => void +} +function StepSecret({ value, onChange }: SecretStepProps) { + return ( +
    + + + In your new Slack app, open Basic Information. + + + Find Signing Secret and click Show, then copy it. + + Paste it into the field below. + + +
    + ) +} + +function StepToken({ value, onChange }: SecretStepProps) { + return ( +
    + + + In Slack, open Install AppInstall to Workspace and + authorize. + + + Copy the Bot User OAuth Token (starts with xoxb-). + + Paste it into the field below, then click Next. + + +
    + ) +} + +interface SecretFieldProps { + label: string + value: string + onChange: (next: string) => void + placeholder?: string +} +function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) { + return ( +
    + + +
    + ) +} + +interface StepDoneProps { + pending: boolean + created: boolean + error: string | null + onRetry: () => void +} +function StepDone({ pending, created, error, onRetry }: StepDoneProps) { + if (pending) { + return ( +
    + +

    Verifying your bot and connecting…

    +
    + ) + } + if (error) { + return ( +
    +

    {error}

    + +
    + ) + } + if (created) { + return ( +
    +
    +

    Bot connected

    +

    + It's now selectable in Slack triggers and actions across this workspace. Click Done to + finish. +

    +
    +
    + ) + } + return null +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 0082e066f16..11735375f5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -27,6 +27,10 @@ import { UnsavedChangesModal, useCredentialDetailForm, } from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + ConnectServiceAccountModal, + type ServiceAccountProviderId, +} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { useCreateCredentialDraft, @@ -77,6 +81,7 @@ export function ConnectedCredentialDetail({ const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false) const [isShareModalOpen, setIsShareModalOpen] = useState(false) + const [reconnectOpen, setReconnectOpen] = useState(false) const form = useCredentialDetailForm({ credential, isAdmin, backHref: integrationsHref }) @@ -193,9 +198,13 @@ export function ConnectedCredentialDetail({ const actions = credential && isAdmin ? ( <> - {serviceConfig?.authType !== 'service_account' && ( + {(credential.type === 'oauth' || credential.type === 'service_account') && ( setReconnectOpen(true) + : handleReconnectOAuth + } disabled={connectOAuthService.isPending} leftIcon={serviceConfig?.icon} > @@ -319,6 +328,20 @@ export function ConnectedCredentialDetail({ onOpenChange={form.setShowUnsavedAlert} onDiscard={form.confirmDiscard} /> + + {credential.type === 'service_account' && credential.providerId && ( + } + credentialId={credential.id} + credentialDisplayName={credential.displayName} + credentialDescription={credential.description ?? undefined} + /> + )} ) } 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 72fa3638a55..c8c0ddd9829 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 @@ -14,6 +14,7 @@ import { } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' @@ -48,6 +49,7 @@ export function CredentialSelector({ const workspaceId = (params?.workspaceId as string) || '' const [showConnectModal, setShowConnectModal] = useState(false) const [showOAuthModal, setShowOAuthModal] = useState(false) + const [showSlackBotModal, setShowSlackBotModal] = useState(false) const [editingValue, setEditingValue] = useState('') const [isEditing, setIsEditing] = useState(false) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -96,13 +98,18 @@ export function CredentialSelector({ const credentialsLoading = isAllCredentials ? allCredentialsLoading : oauthCredentialsLoading - const credentials = useMemo( - () => - isTriggerMode - ? rawCredentials.filter((cred) => cred.type !== 'service_account') - : rawCredentials, - [rawCredentials, isTriggerMode] - ) + const credentialKind = subBlock.credentialKind + + const credentials = useMemo(() => { + // A custom-bot picker lists only the reusable Slack bot credentials + // (service-account type), including in trigger mode. + if (credentialKind === 'custom-bot') { + return rawCredentials.filter((cred) => cred.type === 'service_account') + } + return isTriggerMode + ? rawCredentials.filter((cred) => cred.type !== 'service_account') + : rawCredentials + }, [rawCredentials, isTriggerMode, credentialKind]) const selectedCredential = useMemo( () => credentials.find((cred) => cred.id === selectedId), @@ -178,8 +185,12 @@ export function CredentialSelector({ ) const handleAddCredential = useCallback(() => { + if (credentialKind === 'custom-bot') { + setShowSlackBotModal(true) + return + } setShowConnectModal(true) - }, []) + }, [credentialKind]) const getProviderIcon = useCallback((providerName: OAuthProvider) => { const { baseProvider } = parseProvider(providerName) @@ -220,9 +231,13 @@ export function CredentialSelector({ options.push({ label: - credentials.length > 0 - ? `Connect another ${getProviderName(provider)} account` - : `Connect ${getProviderName(provider)} account`, + credentialKind === 'custom-bot' + ? credentials.length > 0 + ? 'Connect another custom bot' + : 'Set up a custom bot' + : credentials.length > 0 + ? `Connect another ${getProviderName(provider)} account` + : `Connect ${getProviderName(provider)} account`, value: '__connect_account__', iconElement: , }) @@ -232,6 +247,7 @@ export function CredentialSelector({ isAllCredentials, allWorkspaceCredentials, credentials, + credentialKind, provider, getProviderIcon, getProviderName, @@ -379,6 +395,18 @@ export function CredentialSelector({ serviceId={serviceId} /> )} + + {showSlackBotModal && ( + { + setStoreValue(newCredentialId) + refetchCredentials() + }} + /> + )} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index d215ddbfe33..7ed1f9806f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -59,8 +59,8 @@ const SLACK_OVERRIDES: SelectorOverrides = { const authMethod = deps.authMethod as string const oauthCredential = authMethod === 'bot_token' - ? String(deps.botToken ?? '') - : String(deps.credential ?? deps.triggerCredentials ?? '') + ? String(deps.customBotCredential ?? deps.botToken ?? '') + : String(deps.credential ?? deps.customBotCredential ?? deps.triggerCredentials ?? '') return { ...context, oauthCredential } }, } diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 8c8f0db65f8..0548e50a35f 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1,7 +1,7 @@ import { BookOpen, ClipboardList, File, Table, Users } from '@sim/emcn/icons' import { GoogleTranslateIcon, GreptileIcon, LinearIcon, SlackIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' -import type { BlockConfig, BlockMeta } from '@/blocks/types' +import type { BlockConfig, BlockMeta, SubBlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import type { SlackResponse } from '@/tools/slack/types' @@ -21,6 +21,7 @@ export const SlackBlock: BlockConfig = { bgColor: '#611f69', icon: SlackIcon, triggerAllowed: true, + hideFromToolbar: true, // Legacy: superseded by slack_v2. Kept for existing workflows. subBlocks: [ { id: 'operation', @@ -149,7 +150,7 @@ export const SlackBlock: BlockConfig = { selectorKey: 'slack.channels', placeholder: 'Select Slack channel', mode: 'basic', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, condition: (values?: Record) => { const op = values?.operation as string if (op === 'ephemeral') { @@ -192,7 +193,7 @@ export const SlackBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'channel', placeholder: 'Enter Slack channel ID (e.g., C1234567890)', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, mode: 'advanced', condition: (values?: Record) => { const op = values?.operation as string @@ -239,7 +240,7 @@ export const SlackBlock: BlockConfig = { selectorKey: 'slack.users', placeholder: 'Select Slack user', mode: 'basic', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, condition: { field: 'destinationType', value: 'dm', @@ -252,7 +253,7 @@ export const SlackBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'dmUserId', placeholder: 'Enter Slack user ID (e.g., U1234567890)', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, mode: 'advanced', condition: { field: 'destinationType', @@ -269,7 +270,7 @@ export const SlackBlock: BlockConfig = { selectorKey: 'slack.users', placeholder: 'Select Slack user', mode: 'basic', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, condition: { field: 'operation', value: 'ephemeral', @@ -282,7 +283,7 @@ export const SlackBlock: BlockConfig = { type: 'short-input', canonicalParamId: 'ephemeralUser', placeholder: 'Enter Slack user ID (e.g., U1234567890)', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, mode: 'advanced', condition: { field: 'operation', @@ -532,7 +533,7 @@ Do not include any explanations, markdown formatting, or other text outside the selectorKey: 'slack.users', placeholder: 'Select Slack user', mode: 'basic', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, condition: { field: 'operation', value: 'get_user', @@ -545,7 +546,7 @@ Do not include any explanations, markdown formatting, or other text outside the type: 'short-input', canonicalParamId: 'userId', placeholder: 'Enter Slack user ID (e.g., U1234567890)', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, mode: 'advanced', condition: { field: 'operation', @@ -904,7 +905,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, selectorKey: 'slack.users', placeholder: 'Select Slack user', mode: 'basic', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, condition: { field: 'operation', value: 'get_user_presence', @@ -917,7 +918,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, type: 'short-input', canonicalParamId: 'presenceUserId', placeholder: 'Enter Slack user ID (e.g., U1234567890)', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, mode: 'advanced', condition: { field: 'operation', @@ -1265,7 +1266,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, selectorKey: 'slack.users', placeholder: 'Select user to publish Home tab to', mode: 'basic', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, condition: { field: 'operation', value: 'publish_view', @@ -1278,7 +1279,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`, type: 'short-input', canonicalParamId: 'publishUserId', placeholder: 'Enter Slack user ID (e.g., U0BPQUNTA)', - dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }, + dependsOn: { all: ['authMethod'], any: ['credential', 'botToken', 'customBotCredential'] }, mode: 'advanced', condition: { field: 'operation', @@ -1593,6 +1594,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te oauthCredential, authMethod, botToken, + botCredential, operation, destinationType, channel, @@ -1693,9 +1695,15 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te baseParams.channel = effectiveChannel } - // Handle authentication based on method + // Handle authentication based on method. Custom Bot resolves to a token + // server-side: v2 selects a reusable bot credential; v1 pastes a raw + // token (kept for back-compat). if (authMethod === 'bot_token') { - baseParams.accessToken = botToken + if (botCredential) { + baseParams.credential = botCredential + } else if (botToken) { + baseParams.accessToken = botToken + } } else { // Default to OAuth baseParams.credential = oauthCredential @@ -2067,6 +2075,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te destinationType: { type: 'string', description: 'Destination type (channel or dm)' }, oauthCredential: { type: 'string', description: 'Slack access token' }, botToken: { type: 'string', description: 'Bot token' }, + botCredential: { type: 'string', description: 'Custom Slack bot credential id' }, channel: { type: 'string', description: 'Channel identifier (canonical param)' }, dmUserId: { type: 'string', description: 'User ID for DM recipient (canonical param)' }, text: { type: 'string', description: 'Message text' }, @@ -2454,10 +2463,10 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te team_id: { type: 'string', description: 'Slack workspace/team ID' }, event_id: { type: 'string', description: 'Unique event identifier for the trigger' }, }, - // New: Trigger capabilities + // Trigger capabilities moved to slack_v2 so the trigger surfaces once. triggers: { - enabled: true, - available: ['slack_oauth'], + enabled: false, + available: [], }, } @@ -2613,3 +2622,57 @@ export const SlackBlockMeta = { }, ], } as const satisfies BlockMeta + +/** + * Custom Bot picker used by slack_v2 in place of v1's raw bot-token field — a + * canonical basic/advanced pair (dropdown + manual credential-ID paste), + * mirroring the OAuth `credential`/`manualCredential` pair. + */ +const SLACK_CUSTOM_BOT_SUBBLOCKS: SubBlockConfig[] = [ + { + id: 'customBotCredential', + title: 'Slack Bot', + type: 'oauth-input', + canonicalParamId: 'botCredential', + mode: 'basic', + serviceId: 'slack', + credentialKind: 'custom-bot', + requiredScopes: getScopesForService('slack'), + placeholder: 'Select a connected bot', + dependsOn: ['authMethod'], + condition: { field: 'authMethod', value: 'bot_token' }, + required: true, + }, + { + id: 'manualCustomBotCredential', + title: 'Bot Credential ID', + type: 'short-input', + canonicalParamId: 'botCredential', + mode: 'advanced', + placeholder: 'Enter bot credential ID', + dependsOn: ['authMethod'], + condition: { field: 'authMethod', value: 'bot_token' }, + required: true, + }, +] + +/** + * slack_v2 — the go-forward Slack action block. Identical operations, tools, and + * outputs to v1 (shared by reference), but the "Custom Bot" auth method selects + * a reusable bot credential set up once, instead of pasting a raw token. Also + * hosts the native Slack trigger so it surfaces once. + */ +export const SlackV2Block: BlockConfig = { + ...SlackBlock, + type: 'slack_v2', + hideFromToolbar: false, + subBlocks: SlackBlock.subBlocks.flatMap((sb) => { + if (sb.id === 'botToken') return [] + if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS] + return [sb] + }), + triggers: { + enabled: true, + available: ['slack_oauth'], + }, +} diff --git a/apps/sim/blocks/registry-maps.minimal.ts b/apps/sim/blocks/registry-maps.minimal.ts index 9195eb5367b..9c43a7b948b 100644 --- a/apps/sim/blocks/registry-maps.minimal.ts +++ b/apps/sim/blocks/registry-maps.minimal.ts @@ -23,7 +23,7 @@ import { RssBlock } from '@/blocks/blocks/rss' import { ScheduleBlock } from '@/blocks/blocks/schedule' import { SearchBlock } from '@/blocks/blocks/search' import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event' -import { SlackBlock } from '@/blocks/blocks/slack' +import { SlackBlock, SlackV2Block } from '@/blocks/blocks/slack' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' import { TableBlock } from '@/blocks/blocks/table' import { TranslateBlock } from '@/blocks/blocks/translate' @@ -74,6 +74,7 @@ export const BLOCK_REGISTRY: Record = { search: SearchBlock, sim_workspace_event: SimWorkspaceEventBlock, slack: SlackBlock, + slack_v2: SlackV2Block, start_trigger: StartTriggerBlock, table: TableBlock, translate: TranslateBlock, diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index fd1d8de138a..20842ecf1c2 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -271,7 +271,7 @@ import { ShopifyBlock, ShopifyBlockMeta } from '@/blocks/blocks/shopify' import { SimWorkspaceEventBlock } from '@/blocks/blocks/sim_workspace_event' import { SimilarwebBlock, SimilarwebBlockMeta } from '@/blocks/blocks/similarweb' import { SixtyfourBlock, SixtyfourBlockMeta } from '@/blocks/blocks/sixtyfour' -import { SlackBlock, SlackBlockMeta } from '@/blocks/blocks/slack' +import { SlackBlock, SlackBlockMeta, SlackV2Block } from '@/blocks/blocks/slack' import { SmtpBlock, SmtpBlockMeta } from '@/blocks/blocks/smtp' import { SportmonksBlock, SportmonksBlockMeta } from '@/blocks/blocks/sportmonks' import { SpotifyBlock, SpotifyBlockMeta } from '@/blocks/blocks/spotify' @@ -579,6 +579,7 @@ export const BLOCK_REGISTRY: Record = { similarweb: SimilarwebBlock, sixtyfour: SixtyfourBlock, slack: SlackBlock, + slack_v2: SlackV2Block, smtp: SmtpBlock, sportmonks: SportmonksBlock, spotify: SpotifyBlock, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index b57d41075ec..eda5bb5f720 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -366,6 +366,12 @@ export interface SubBlockConfig { // OAuth specific properties - serviceId is the canonical identifier for OAuth services serviceId?: string requiredScopes?: string[] + /** + * Narrows an `oauth-input` selector to a specific credential kind. `'custom-bot'` + * lists only reusable custom Slack bot credentials (service-account type) and its + * connect row opens the custom-bot setup modal instead of the OAuth flow. + */ + credentialKind?: 'custom-bot' // Selector properties — declarative mapping to a SelectorKey selectorKey?: SelectorKey selectorAllowSearch?: boolean diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index d567a4ec406..db1cd25e6ca 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -136,6 +136,10 @@ export function useUpdateWorkspaceCredential() { displayName: payload.displayName, description: payload.description, serviceAccountJson: payload.serviceAccountJson, + signingSecret: payload.signingSecret, + botToken: payload.botToken, + apiToken: payload.apiToken, + domain: payload.domain, }, }) }, diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index cec3b73c7e8..ecd15b68afb 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -1,6 +1,10 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' -import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthProvider } from '@/lib/oauth/types' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + type OAuthProvider, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/ @@ -121,6 +125,14 @@ export const createCredentialBodySchema = z serviceAccountJson: z.string().optional(), apiToken: z.string().trim().min(1).optional(), domain: z.string().trim().min(1).optional(), + /** + * Client-supplied credential id, honored only for `slack-custom-bot` creates: + * the setup modal shows the ingest URL `/api/webhooks/slack/custom/{id}` + * before secrets exist, so the id must be known up front. + */ + id: z.string().uuid('id must be a valid UUID').optional(), + signingSecret: z.string().trim().min(1).optional(), + botToken: z.string().trim().min(1).optional(), }) .superRefine((data, ctx) => { if (data.type === 'oauth') { @@ -166,6 +178,23 @@ export const createCredentialBodySchema = z } return } + if (data.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + if (!data.signingSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'signingSecret is required for a custom Slack bot credential', + path: ['signingSecret'], + }) + } + if (!data.botToken) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'botToken is required for a custom Slack bot credential', + path: ['botToken'], + }) + } + return + } if (!data.serviceAccountJson) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -200,13 +229,23 @@ export const updateCredentialByIdBodySchema = z displayName: z.string().trim().min(1).max(255).optional(), description: z.string().trim().max(500).nullish(), serviceAccountJson: z.string().min(1).optional(), + /** Slack custom-bot secret rotation (reconnect). */ + signingSecret: z.string().trim().min(1).optional(), + botToken: z.string().trim().min(1).optional(), + /** Atlassian service-account secret rotation (reconnect). */ + apiToken: z.string().trim().min(1).optional(), + domain: z.string().trim().min(1).optional(), }) .strict() .refine( (data) => data.displayName !== undefined || data.description !== undefined || - data.serviceAccountJson !== undefined, + data.serviceAccountJson !== undefined || + data.signingSecret !== undefined || + data.botToken !== undefined || + data.apiToken !== undefined || + data.domain !== undefined, { message: 'At least one field must be provided', path: ['displayName'], diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 20655dad168..8e7342bfdb0 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -7,11 +7,16 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { encryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' import { deleteWorkspaceEnvCredentials, syncPersonalEnvCredentialsForUser, } from '@/lib/credentials/environment' +import { + ServiceAccountSecretError, + verifyAndBuildServiceAccountSecret, +} from '@/lib/credentials/service-account-secret' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') @@ -37,6 +42,12 @@ export interface PerformUpdateCredentialParams extends CredentialActorParams { displayName?: string description?: string | null serviceAccountJson?: string + /** Slack custom-bot secret rotation (reconnect). */ + signingSecret?: string + botToken?: string + /** Atlassian service-account secret rotation (reconnect). */ + apiToken?: string + domain?: string } export interface PerformCredentialResult { @@ -103,6 +114,37 @@ export async function performUpdateCredential( updates.encryptedServiceAccountKey = encrypted } + // Reconnect: rotate a Slack/Atlassian service-account secret in place. The + // secret is re-verified against the provider and re-encrypted; the display + // name is preserved (the user may have renamed it). + const hasRotationSecret = + params.signingSecret !== undefined || + params.botToken !== undefined || + params.apiToken !== undefined || + params.domain !== undefined + if (hasRotationSecret && access.credential.type === 'service_account') { + try { + const secret = await verifyAndBuildServiceAccountSecret( + access.credential.providerId ?? '', + { + signingSecret: params.signingSecret, + botToken: params.botToken, + apiToken: params.apiToken, + domain: params.domain, + } + ) + updates.encryptedServiceAccountKey = secret.encryptedServiceAccountKey + } catch (error) { + if (error instanceof ServiceAccountSecretError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + if (error instanceof AtlassianValidationError) { + return { success: false, error: error.code, errorCode: 'validation' } + } + throw error + } + } + if (Object.keys(updates).length === 0) { if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' } diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts new file mode 100644 index 00000000000..4fe7eb52cb3 --- /dev/null +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEncryptSecret, mockFetchSlackTeamId, mockValidateAtlassian, mockNormalizeDomain } = + vi.hoisted(() => ({ + // Identity encryption so tests can read back the JSON blob. + mockEncryptSecret: vi.fn(async (value: string) => ({ encrypted: value })), + mockFetchSlackTeamId: vi.fn(), + mockValidateAtlassian: vi.fn(), + mockNormalizeDomain: vi.fn((raw: string) => raw.trim().toLowerCase()), + })) + +vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret })) +vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId })) +vi.mock('@/lib/credentials/atlassian-service-account', () => ({ + validateAtlassianServiceAccount: mockValidateAtlassian, + normalizeAtlassianDomain: mockNormalizeDomain, +})) +vi.mock('@/lib/api/contracts/credentials', () => ({ + serviceAccountJsonSchema: { + safeParse: (value: string) => { + try { + const parsed = JSON.parse(value) + return { success: true, data: parsed } + } catch { + return { success: false, error: { issues: [{ message: 'bad json' }] } } + } + }, + }, +})) +vi.mock('@/lib/api/server', () => ({ + getValidationErrorMessage: (_error: unknown, fallback: string) => fallback, +})) + +import { + ServiceAccountSecretError, + verifyAndBuildServiceAccountSecret, +} from '@/lib/credentials/service-account-secret' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' + +describe('verifyAndBuildServiceAccountSecret', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + mockNormalizeDomain.mockImplementation((raw: string) => raw.trim().toLowerCase()) + }) + + it('verifies a Slack bot token and encrypts the derived blob', async () => { + mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T1', userId: 'U_BOT', teamName: 'Acme' }) + const result = await verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, { + signingSecret: 'sec', + botToken: 'xoxb-1', + }) + expect(result.providerId).toBe(SLACK_CUSTOM_BOT_PROVIDER_ID) + expect(result.displayName).toBe('Acme') + expect(result.auditMetadata.slackTeamId).toBe('T1') + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + signingSecret: 'sec', + botToken: 'xoxb-1', + teamId: 'T1', + botUserId: 'U_BOT', + teamName: 'Acme', + }) + }) + + it('throws when Slack required fields are missing', async () => { + await expect( + verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, { signingSecret: 'sec' }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(mockFetchSlackTeamId).not.toHaveBeenCalled() + }) + + it('wraps a failed Slack token verification as a ServiceAccountSecretError', async () => { + mockFetchSlackTeamId.mockRejectedValue(new Error('invalid_auth')) + await expect( + verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, { + signingSecret: 'sec', + botToken: 'xoxb-bad', + }) + ).rejects.toThrow(/Could not verify the Slack bot token/) + }) + + it('verifies an Atlassian token and encrypts the blob', async () => { + mockValidateAtlassian.mockResolvedValue({ + accountId: 'acc-1', + displayName: 'Jira Bot', + cloudId: 'cloud-1', + }) + const result = await verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { + apiToken: 'tok', + domain: 'Acme.atlassian.net', + }) + expect(result.providerId).toBe(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) + expect(result.displayName).toBe('Jira Bot') + expect(result.auditMetadata.atlassianCloudId).toBe('cloud-1') + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + apiToken: 'tok', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + }) + }) + + it('throws when Atlassian required fields are missing', async () => { + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { apiToken: 'tok' }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + }) + + it('validates and encrypts a Google service-account JSON key', async () => { + const json = JSON.stringify({ type: 'service_account', client_email: 'svc@proj.iam' }) + const result = await verifyAndBuildServiceAccountSecret('google-service-account', { + serviceAccountJson: json, + }) + expect(result.providerId).toBe('google-service-account') + expect(result.displayName).toBe('svc@proj.iam') + expect(result.encryptedServiceAccountKey).toBe(json) + }) +}) diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts new file mode 100644 index 00000000000..1f59d0e0007 --- /dev/null +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -0,0 +1,139 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials' +import { getValidationErrorMessage } from '@/lib/api/server' +import { encryptSecret } from '@/lib/core/security/encryption' +import { + normalizeAtlassianDomain, + validateAtlassianServiceAccount, +} from '@/lib/credentials/atlassian-service-account' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + SLACK_CUSTOM_BOT_PROVIDER_ID, + SLACK_CUSTOM_BOT_SECRET_TYPE, +} from '@/lib/oauth/types' +import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack' + +/** Provider-specific secret inputs a service-account credential can carry. */ +export interface ServiceAccountSecretFields { + signingSecret?: string + botToken?: string + apiToken?: string + domain?: string + serviceAccountJson?: string +} + +export interface ServiceAccountSecretResult { + /** Canonical provider id for the resolved secret. */ + providerId: string + encryptedServiceAccountKey: string + displayName: string + auditMetadata: Record +} + +/** Thrown when a service-account secret is missing or fails provider verification. */ +export class ServiceAccountSecretError extends Error { + constructor(message: string) { + super(message) + this.name = 'ServiceAccountSecretError' + } +} + +/** + * Verifies a service-account secret against its provider, derives the display + * name, and returns the encrypted blob ready to persist. Shared by credential + * create (POST) and in-place reconnect (PUT) so both paths verify + encrypt + * identically. Throws {@link ServiceAccountSecretError} on missing fields or a + * failed provider verification (callers map it to a 400). + */ +export async function verifyAndBuildServiceAccountSecret( + providerId: string, + fields: ServiceAccountSecretFields +): Promise { + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + const { apiToken, domain } = fields + if (!apiToken || !domain) { + throw new ServiceAccountSecretError( + 'apiToken and domain are required for Atlassian service account credentials' + ) + } + const normalizedDomain = normalizeAtlassianDomain(domain) + const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain) + const blob = JSON.stringify({ + type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + apiToken, + domain: normalizedDomain, + cloudId: validation.cloudId, + atlassianAccountId: validation.accountId, + }) + const { encrypted } = await encryptSecret(blob) + return { + providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: { + atlassianDomain: normalizedDomain, + atlassianCloudId: validation.cloudId, + }, + } + } + + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + const { signingSecret, botToken } = fields + if (!signingSecret || !botToken) { + throw new ServiceAccountSecretError( + 'signingSecret and botToken are required for a custom Slack bot credential' + ) + } + // Verify the token and derive the workspace/team identity (never trusted + // from the client) via auth.test. + let teamId: string + let botUserId: string | undefined + let teamName: string | undefined + try { + const auth = await fetchSlackTeamId(botToken) + teamId = auth.teamId + botUserId = auth.userId + teamName = auth.teamName + } catch (error) { + throw new ServiceAccountSecretError( + `Could not verify the Slack bot token: ${getErrorMessage(error)}` + ) + } + const blob = JSON.stringify({ + type: SLACK_CUSTOM_BOT_SECRET_TYPE, + signingSecret, + botToken, + teamId, + botUserId, + teamName, + }) + const { encrypted } = await encryptSecret(blob) + return { + providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, + encryptedServiceAccountKey: encrypted, + displayName: teamName || 'Slack bot', + auditMetadata: { slackTeamId: teamId }, + } + } + + const { serviceAccountJson } = fields + if (!serviceAccountJson) { + throw new ServiceAccountSecretError( + 'serviceAccountJson is required for service account credentials' + ) + } + const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson) + if (!jsonParseResult.success) { + throw new ServiceAccountSecretError( + getValidationErrorMessage(jsonParseResult.error, 'Invalid service account JSON') + ) + } + const { encrypted } = await encryptSecret(serviceAccountJson) + return { + providerId: 'google-service-account', + encryptedServiceAccountKey: encrypted, + displayName: jsonParseResult.data.client_email, + auditMetadata: {}, + } +} diff --git a/apps/sim/lib/integrations/oauth-service.ts b/apps/sim/lib/integrations/oauth-service.ts index a88cef7326e..dc61fd34759 100644 --- a/apps/sim/lib/integrations/oauth-service.ts +++ b/apps/sim/lib/integrations/oauth-service.ts @@ -2,6 +2,7 @@ import type { ComponentType } from 'react' import integrationsJson from '@/lib/integrations/integrations.json' import type { Integration } from '@/lib/integrations/types' import { getServiceConfigByServiceId } from '@/lib/oauth' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' const INTEGRATIONS_DATA: readonly Integration[] = @@ -34,7 +35,13 @@ export interface OAuthServiceMatch { function asServiceAccountProviderId( value: string | undefined ): ServiceAccountProviderId | undefined { - if (value === 'google-service-account' || value === 'atlassian-service-account') return value + if ( + value === 'google-service-account' || + value === 'atlassian-service-account' || + value === SLACK_CUSTOM_BOT_PROVIDER_ID + ) { + return value + } return undefined } diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 79f0409892d..e7e393de67a 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -700,6 +700,7 @@ export const OAUTH_PROVIDERS: Record = { name: 'Slack', description: 'Use Slack messaging, files, reactions, views, and canvases.', providerId: 'slack', + serviceAccountProviderId: 'slack-custom-bot', icon: SlackIcon, baseProviderIcon: SlackIcon, scopes: [ diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index bd1de917bf2..beaafbd4b8a 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -7,11 +7,30 @@ import type { ReactNode } from 'react' */ export const ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID = 'atlassian-service-account' as const +/** + * Stable identifier for a Google service account credential (JSON key → JWT + * token exchange). Used as the `providerId` on credential rows and the + * `serviceAccountProviderId` on Google service configs. + */ +export const GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID = 'google-service-account' as const + /** * Discriminator stored inside the encrypted Atlassian service account secret blob. */ export const ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE = 'atlassian_service_account' as const +/** + * Stable identifier for a reusable custom Slack bot credential — a + * `service_account`-type credential whose encrypted blob holds the bring-your-own + * app's signing secret + bot token + derived team_id/bot_user_id. Used as the + * `providerId` on credential rows and the `serviceAccountProviderId` on the Slack + * service config, so it folds into the same Slack credential dropdown. + */ +export const SLACK_CUSTOM_BOT_PROVIDER_ID = 'slack-custom-bot' as const + +/** Discriminator stored inside the encrypted Slack custom bot secret blob. */ +export const SLACK_CUSTOM_BOT_SECRET_TYPE = 'slack_custom_bot' as const + export type OAuthProvider = | 'google' | 'google-email' diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 17ff65e51fb..ad6de5b0bb4 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -515,7 +515,13 @@ export function getServiceConfigByServiceId(serviceId: string): OAuthServiceConf export function getServiceConfigByProviderId(providerId: string): OAuthServiceConfig | null { for (const provider of Object.values(OAUTH_PROVIDERS)) { for (const [key, service] of Object.entries(provider.services)) { - if (service.providerId === providerId || key === providerId) { + // Also resolve a service-account provider id (e.g. `slack-custom-bot`) back + // to its owning service so its credentials group under that integration. + if ( + service.providerId === providerId || + key === providerId || + service.serviceAccountProviderId === providerId + ) { return service } } diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index cfd7e8e553f..6ea7b2513fa 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -19,7 +19,7 @@ import { isCanonicalPair, resolveActiveCanonicalValue, } from '@/lib/workflows/subblocks/visibility' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { getSlackBotCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -478,7 +478,34 @@ export async function saveTriggerWebhooksForDeploy({ effectiveProvider = 'slack_app' effectivePath = null } else { + // Custom: a reusable bring-your-own bot credential. Route by the + // credential id (one shared ingest URL per bot) instead of a per-workflow + // path, so multiple triggers on the same bot share one Request URL. + const botCredentialId = + typeof providerConfig.botCredential === 'string' + ? providerConfig.botCredential + : undefined + if (!botCredentialId) { + return { + success: false, + error: { message: 'Select a Slack bot credential for the trigger.', status: 400 }, + } + } + const botCredential = await getSlackBotCredential(botCredentialId) + if (!botCredential) { + return { + success: false, + error: { + message: 'The selected Slack bot credential is missing or invalid. Reconnect it.', + status: 400, + }, + } + } effectiveProvider = 'slack' + effectivePath = null + routingKey = botCredentialId + providerConfig.credentialId = botCredentialId + if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId } } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index bcb7d38c6ce..555e979e891 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -383,7 +383,8 @@ export async function findAllWebhooksForPath( */ export async function findWebhooksByRoutingKey( routingKey: string, - requestId: string + requestId: string, + provider = 'slack_app' ): Promise> { if (!routingKey) { return [] @@ -406,7 +407,7 @@ export async function findWebhooksByRoutingKey( .where( and( eq(webhook.routingKey, routingKey), - eq(webhook.provider, 'slack_app'), + eq(webhook.provider, provider), eq(webhook.isActive, true), isNull(webhook.archivedAt), isNull(workflow.archivedAt), @@ -418,7 +419,7 @@ export async function findWebhooksByRoutingKey( ) if (results.length === 0) { - logger.warn(`[${requestId}] No active slack_app webhooks for routing key`) + logger.warn(`[${requestId}] No active ${provider} webhooks for routing key`) } return results diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 6d1df25c299..de800a7314e 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { shouldSkipSlackTriggerEvent, slackHandler } from '@/lib/webhooks/providers/slack' +import { + resolveSlackEventKey, + shouldSkipSlackTriggerEvent, + slackHandler, +} from '@/lib/webhooks/providers/slack' const ctx = (body: unknown) => ({ webhook: {}, @@ -424,6 +428,76 @@ describe('shouldSkipSlackTriggerEvent', () => { }) }) +describe('resolveSlackEventKey - interactions', () => { + it('maps a top-level block_actions / view_submission payload (no event envelope)', () => { + expect(resolveSlackEventKey({ type: 'block_actions', actions: [] })).toBe('block_actions') + expect(resolveSlackEventKey({ type: 'view_submission', view: {} })).toBe('view_submission') + }) + + it('does not surface unsupported interaction types or Events API without an event', () => { + expect(resolveSlackEventKey({ type: 'shortcut' })).toBeNull() + expect(resolveSlackEventKey({ type: 'view_closed' })).toBeNull() + expect(resolveSlackEventKey({})).toBeNull() + }) +}) + +/** True when an interaction (top-level payload, no event envelope) fires. */ +function interactionFires(config: Record, body: Record): boolean { + return !shouldSkipSlackTriggerEvent( + { team: { id: 'T1' }, api_app_id: API_APP_ID, ...body }, + config + ) +} + +describe('shouldSkipSlackTriggerEvent - interactions', () => { + const blockActions = { + type: 'block_actions', + user: { id: 'U1' }, + actions: [{ action_id: 'approve_btn', value: 'v' }], + } + const viewSubmission = { + type: 'view_submission', + user: { id: 'U1' }, + view: { callback_id: 'create_ticket' }, + } + + it('fires a block_actions event when eventType matches and no filter is set', () => { + expect(interactionFires({ eventType: 'block_actions' }, blockActions)).toBe(true) + }) + + it('drops an interaction when the configured eventType is a different event', () => { + expect(interactionFires({ eventType: 'message' }, blockActions)).toBe(false) + expect(interactionFires({ eventType: 'view_submission' }, blockActions)).toBe(false) + }) + + it('scopes block_actions to matching action_ids', () => { + expect( + interactionFires({ eventType: 'block_actions', interactionFilter: 'deny_btn' }, blockActions) + ).toBe(false) + expect( + interactionFires( + { eventType: 'block_actions', interactionFilter: 'approve_btn, deny_btn' }, + blockActions + ) + ).toBe(true) + }) + + it('scopes view_submission to matching callback_ids', () => { + expect( + interactionFires( + { eventType: 'view_submission', interactionFilter: 'other_modal' }, + viewSubmission + ) + ).toBe(false) + expect( + interactionFires( + { eventType: 'view_submission', interactionFilter: 'create_ticket' }, + viewSubmission + ) + ).toBe(true) + }) +}) + describe('slackHandler.shouldSkipEvent (custom-app path)', () => { const message = slackBody({ type: 'message', channel_type: 'channel', channel: 'C1', ts: '9.1' }) const skipCtx = (providerConfig: Record, body: unknown) => ({ diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index a0d423b78ba..6311de07854 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -17,7 +17,11 @@ import type { FormatInputResult, WebhookProviderHandler, } from '@/lib/webhooks/providers/types' -import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' +import { + getSlackBotCredential, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import { type SlackEventFilter, slackEventSupportsFilter } from '@/triggers/slack/shared' const logger = createLogger('WebhookProvider:Slack') @@ -43,6 +47,13 @@ const SLACK_INTERACTIVE_TYPES = new Set([ 'block_suggestion', ]) +/** + * Interaction payload types surfaced as selectable trigger events. A subset of + * SLACK_INTERACTIVE_TYPES — these are the only interactions that map to an + * eventType a trigger can subscribe to (see SLACK_EVENT_CATALOG). + */ +const SLACK_INTERACTION_EVENT_KEYS = new Set(['block_actions', 'view_submission']) + interface SlackDownloadedFile { name: string data: string @@ -419,9 +430,11 @@ const SLACK_TIMESTAMP_MAX_SKEW = 300 * both are Slack-attested, never taken from user input. Throws on failure so * deploy fails fast rather than registering an unroutable trigger. */ -export async function fetchSlackTeamId( - botToken: string -): Promise<{ teamId: string; userId: string | undefined }> { +export async function fetchSlackTeamId(botToken: string): Promise<{ + teamId: string + userId: string | undefined + teamName: string | undefined +}> { const response = await fetch('https://slack.com/api/auth.test', { headers: { Authorization: `Bearer ${botToken}` }, }) @@ -429,12 +442,13 @@ export async function fetchSlackTeamId( ok?: boolean team_id?: string user_id?: string + team?: string error?: string } if (!data.ok || !data.team_id) { throw new Error(`Slack auth.test failed: ${data.error || 'unknown error'}`) } - return { teamId: data.team_id, userId: data.user_id } + return { teamId: data.team_id, userId: data.user_id, teamName: data.team } } /** @@ -552,7 +566,16 @@ const CONTENT_MESSAGE_SUBTYPES = new Set([ */ export function resolveSlackEventKey(body: Record): string | null { const event = body.event as Record | undefined - if (!event) return null + if (!event) { + // Interactivity payloads (button clicks, modal submits) have no `event` + // envelope — the family is the top-level `type`. Surface only the ones a + // trigger can subscribe to. + const interactionType = body.type as string | undefined + if (interactionType && SLACK_INTERACTION_EVENT_KEYS.has(interactionType)) { + return interactionType + } + return null + } const type = event.type as string | undefined switch (type) { @@ -711,6 +734,24 @@ export function shouldSkipSlackTriggerEvent( } } + // Interaction — restrict a block_actions / view_submission event to specific + // action_id / callback_id values. Interaction fields live on the top-level + // body, not `rawEvent` (which is undefined for interactions). Empty = any. + if (supports('interaction')) { + const ids = normalizeSelection(providerConfig.interactionFilter) + if (ids.length > 0) { + const actions = Array.isArray(body.actions) + ? (body.actions as Array>) + : [] + const view = body.view as Record | undefined + const interactionId = + eventKey === 'view_submission' + ? ((view?.callback_id as string | undefined) ?? (body.callback_id as string | undefined)) + : (actions[0]?.action_id as string | undefined) + if (!interactionId || !ids.includes(interactionId)) return true + } + } + // Channels — picker or manual IDs, the basic/advanced sides of one canonical // field. DMs always skip it: a DM's channel can't be picked, so a DM allowed // by Source must not be dropped by a channel filter meant for real channels. @@ -806,6 +847,11 @@ export const slackHandler: WebhookProviderHandler = { const b = body as Record const providerConfig = (webhook.providerConfig as Record) || {} let botToken = providerConfig.botToken as string | undefined + // Reusable custom Slack bot credential: use its stored bot token directly. + if (!botToken && typeof providerConfig.credentialId === 'string') { + const botCredential = await getSlackBotCredential(providerConfig.credentialId) + if (botCredential) botToken = botCredential.botToken + } // Native (slack_app) triggers carry an OAuth credential rather than a pasted // bot token; resolve it via the credential's OWNER (not the execution actor // in workflow.userId, who may not own the credential) so reaction-message diff --git a/apps/sim/lib/webhooks/slack-dispatch.ts b/apps/sim/lib/webhooks/slack-dispatch.ts new file mode 100644 index 00000000000..a865b661b9a --- /dev/null +++ b/apps/sim/lib/webhooks/slack-dispatch.ts @@ -0,0 +1,81 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { + checkWebhookPreprocessing, + type findWebhooksByRoutingKey, + queueWebhookExecution, +} from '@/lib/webhooks/processor' +import { resolveSlackEventKey, shouldSkipSlackTriggerEvent } from '@/lib/webhooks/providers/slack' +import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' + +const logger = createLogger('SlackWebhookDispatch') + +interface DispatchSlackWebhooksOptions { + body: unknown + request: NextRequest + requestId: string + receivedAt: number +} + +/** + * Shared fan-out tail for the Slack ingest routes (native team-id route and the + * custom-bot credential route): run each candidate webhook through the trigger + * filter, verify its block is still deployed, preprocess, and enqueue. Keeping + * this in one place stops the skip/preprocess/queue sequence drifting between + * the two ingest paths. + */ +export async function dispatchSlackWebhooks( + webhooks: Awaited>, + { body, request, requestId, receivedAt }: DispatchSlackWebhooksOptions +): Promise { + const payload = body as Record + const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp') + const parsedTimestampMs = slackRequestTimestamp ? Number(slackRequestTimestamp) * 1000 : undefined + const triggerTimestampMs = Number.isFinite(parsedTimestampMs) ? parsedTimestampMs : undefined + + for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooks) { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + + // Shared trigger filter (event, source, threads, emoji, name, channels, + // interaction, self-drop, bot). + if (shouldSkipSlackTriggerEvent(payload, providerConfig)) { + const rawEvent = payload.event as Record | undefined + logger.info(`[${requestId}] Event skipped by trigger filter for webhook ${foundWebhook.id}`, { + eventKey: resolveSlackEventKey(payload), + configuredEvent: providerConfig.eventType, + channelType: rawEvent?.channel_type, + subtype: rawEvent?.subtype, + isThreadReply: + typeof rawEvent?.thread_ts === 'string' && rawEvent.thread_ts !== rawEvent.ts, + threadsSetting: providerConfig.threads, + botId: rawEvent?.bot_id, + }) + continue + } + + if (foundWebhook.blockId) { + const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) + if (!blockExists) { + logger.info( + `[${requestId}] Trigger block ${foundWebhook.blockId} not in deployment for ${foundWorkflow.id}` + ) + continue + } + } + + const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) + if (preprocessResult.error) { + logger.warn(`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}`) + continue + } + + await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { + requestId, + actorUserId: preprocessResult.actorUserId, + executionId: preprocessResult.executionId, + correlation: preprocessResult.correlation, + receivedAt, + triggerTimestampMs, + }) + } +} diff --git a/apps/sim/triggers/slack/capabilities.test.ts b/apps/sim/triggers/slack/capabilities.test.ts new file mode 100644 index 00000000000..be56ce1cd07 --- /dev/null +++ b/apps/sim/triggers/slack/capabilities.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildSlackManifest } from '@/triggers/slack/capabilities' + +const opts = { appName: 'Test Bot', webhookUrl: 'https://sim.test/api/webhooks/slack' } + +function settingsOf(manifest: Record) { + return manifest.settings as Record +} + +describe('buildSlackManifest - interactivity', () => { + it('emits settings.interactivity when the interactivity capability is active', () => { + const manifest = buildSlackManifest(new Set(['action_interactivity']), opts) + expect(settingsOf(manifest).interactivity).toEqual({ + is_enabled: true, + request_url: opts.webhookUrl, + }) + }) + + it('omits settings.interactivity when the capability is absent', () => { + const manifest = buildSlackManifest(new Set(['action_send']), opts) + expect(settingsOf(manifest).interactivity).toBeUndefined() + }) + + it('enables interactivity independently of event subscriptions', () => { + // No bot_events (interactivity-only bot) still gets the interactivity block. + const manifest = buildSlackManifest(new Set(['action_interactivity']), opts) + const settings = settingsOf(manifest) + expect(settings.event_subscriptions).toBeUndefined() + expect(settings.interactivity).toBeDefined() + }) +}) + +describe('buildSlackManifest - description', () => { + it('emits display_information.description and reuses it as the assistant description', () => { + const manifest = buildSlackManifest(new Set(['action_assistant']), { + ...opts, + description: 'Answers support questions.', + }) + expect(manifest.display_information).toEqual({ + name: 'Test Bot', + description: 'Answers support questions.', + }) + const features = manifest.features as Record> + expect(features.assistant_view.assistant_description).toBe('Answers support questions.') + }) + + it('omits the description key and falls back for the assistant when absent', () => { + const manifest = buildSlackManifest(new Set(['action_assistant']), opts) + expect(manifest.display_information).toEqual({ name: 'Test Bot' }) + const features = manifest.features as Record> + expect(features.assistant_view.assistant_description).toBe( + 'Test Bot — an AI assistant powered by Sim.' + ) + }) +}) diff --git a/apps/sim/triggers/slack/capabilities.ts b/apps/sim/triggers/slack/capabilities.ts index 9bae850b790..26bae846059 100644 --- a/apps/sim/triggers/slack/capabilities.ts +++ b/apps/sim/triggers/slack/capabilities.ts @@ -19,6 +19,20 @@ export interface SlackCapability { group: SlackCapabilityGroup scopes: readonly string[] events: readonly string[] + /** + * Marks the AI Assistant capability. When enabled the manifest additionally + * declares the app as an Agents & AI app (`features.assistant_view`) and + * enables the App Home messages tab — required for assistant threads, the + * "thinking" status (`assistant.threads.setStatus`), and DM-style chat to work. + */ + assistant?: boolean + /** + * Marks the interactivity capability. When enabled the manifest declares + * `settings.interactivity` (pointing at the same ingest URL) — required for + * Slack to deliver `block_actions` (button/select clicks) and `view_submission` + * (modal submits) so triggers can fire on them. Not a scope or a bot_event. + */ + interactivity?: boolean } export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ @@ -152,13 +166,14 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ }, { id: 'action_assistant', - label: 'Drive assistant threads', + label: 'AI assistant', description: - 'Let the bot set the assistant status, title, and suggested prompts on assistant threads (assistant.threads.*).', + 'Register the bot as an AI assistant: users open an assistant thread, the bot shows a "thinking" status, and can set the thread title and suggested prompts (assistant.threads.*).', defaultChecked: true, group: 'action', - scopes: ['assistant:write'], - events: [], + scopes: ['assistant:write', 'im:history'], + events: ['assistant_thread_started', 'assistant_thread_context_changed', 'message.im'], + assistant: true, }, { id: 'action_read_files', @@ -178,6 +193,17 @@ export const SLACK_CAPABILITIES: readonly SlackCapability[] = [ scopes: ['users:read', 'users:read.email'], events: [], }, + { + id: 'action_interactivity', + label: 'Buttons & modals', + description: + 'Let workflows trigger on interactions — button/select clicks and modal submits. Enables the app’s Interactivity Request URL.', + defaultChecked: true, + group: 'action', + scopes: [], + events: [], + interactivity: true, + }, ] as const const WEBHOOK_URL_PLACEHOLDER = '' @@ -185,6 +211,8 @@ const WEBHOOK_URL_PLACEHOLDER = '' export interface BuildManifestOptions { appName: string webhookUrl: string | null + /** Shown on the bot's Slack profile and as the assistant description. */ + description?: string } /** @@ -199,18 +227,39 @@ export interface BuildManifestOptions { */ export function buildSlackManifest( enabled: ReadonlySet, - { appName, webhookUrl }: BuildManifestOptions + { appName, webhookUrl, description }: BuildManifestOptions ): Record { const active = SLACK_CAPABILITIES.filter((c) => enabled.has(c.id)) const scopes = [...new Set(active.flatMap((c) => c.scopes))].sort() const events = [...new Set(active.flatMap((c) => c.events))].sort() const displayName = appName.trim() || 'Sim Workflow Bot' + const trimmedDescription = description?.trim() || '' + const isAssistant = active.some((c) => c.assistant) + const isInteractive = active.some((c) => c.interactivity) + + const features: Record = { + bot_user: { display_name: displayName, always_online: true }, + } + if (isAssistant) { + // Declares the app as an Agents & AI app; without this Slack won't surface + // the assistant thread UI or fire assistant_thread_* events. The messages + // tab must be enabled so users can chat the assistant. + features.assistant_view = { + assistant_description: + trimmedDescription || `${displayName} — an AI assistant powered by Sim.`, + } + features.app_home = { + home_tab_enabled: false, + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + } + } const manifest: Record = { - display_information: { name: displayName }, - features: { - bot_user: { display_name: displayName, always_online: true }, - }, + display_information: trimmedDescription + ? { name: displayName, description: trimmedDescription } + : { name: displayName }, + features, oauth_config: { scopes: { bot: scopes }, }, @@ -229,5 +278,15 @@ export function buildSlackManifest( } } + // Interactivity is independent of event subscriptions — a bot can have + // buttons/modals with no bot_events. Points at the same ingest URL. + if (isInteractive) { + const settings = manifest.settings as Record + settings.interactivity = { + is_enabled: true, + request_url: webhookUrl ?? WEBHOOK_URL_PLACEHOLDER, + } + } + return manifest } diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 43a1ac33b70..bae96f7fe8b 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -18,6 +18,7 @@ const CHANNEL_FILTER_EVENTS = slackEventsSupportingFilter('channels') const THREAD_FILTER_EVENTS = slackEventsSupportingFilter('threads') const EMOJI_FILTER_EVENTS = slackEventsSupportingFilter('emoji') const NAME_FILTER_EVENTS = slackEventsSupportingFilter('name') +const INTERACTION_FILTER_EVENTS = slackEventsSupportingFilter('interaction') // Bot/own toggles gate UI visibility only (the route applies them unconditionally), // so they are not catalog `filters`. const BOT_FILTER_EVENTS = ['message', 'app_mention'] @@ -28,8 +29,10 @@ const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reactio * - `sim` (default): the official Sim Slack app the user OAuth-connects — events * route to this workflow by Slack `team_id` (stored as the webhook `routingKey`, * derived at deploy time). No signing secret, bot token, or app setup. - * - `custom`: a bring-your-own Slack app — events arrive on a per-workflow webhook - * URL and are verified with the app's own signing secret. + * - `custom`: a bring-your-own Slack app, selected as a reusable bot credential + * (set up once). Events route by that credential (`webhook.routingKey = + * credentialId`) to one shared ingest URL, so many triggers on the same bot + * share a single Request URL, verified with the bot's own signing secret. */ export const slackOAuthTrigger: TriggerConfig = { id: 'slack_oauth', @@ -60,11 +63,16 @@ export const slackOAuthTrigger: TriggerConfig = { id: 'appType', title: 'App Type', type: 'dropdown', + // Ship 1 exposes custom bots only; the native "Sim" app mode returns in a + // later ship (un-hide + default back to 'sim'). Hidden — not removed — so + // the seeded 'custom' value keeps every `appType==='custom'` condition, the + // event-catalog fetch, and the deploy routing branch resolving correctly. + hidden: true, options: [ { label: 'Sim', id: 'sim' }, { label: 'Custom', id: 'custom' }, ], - value: () => 'sim', + value: () => 'custom', description: 'Use the official Sim Slack app, or your own custom Slack app.', mode: 'trigger', }, @@ -80,6 +88,32 @@ export const slackOAuthTrigger: TriggerConfig = { mode: 'trigger', condition: { field: 'appType', value: 'sim' }, }, + { + id: 'customBotCredential', + title: 'Slack Bot', + type: 'oauth-input', + canonicalParamId: 'botCredential', + serviceId: 'slack', + credentialKind: 'custom-bot', + requiredScopes: getScopesForService('slack'), + placeholder: 'Select a connected bot', + description: + 'Choose a custom Slack bot you set up once and reuse across triggers and actions.', + required: { field: 'appType', value: 'custom' }, + mode: 'trigger', + condition: { field: 'appType', value: 'custom' }, + }, + { + id: 'manualBotCredential', + title: 'Bot Credential ID', + type: 'short-input', + canonicalParamId: 'botCredential', + placeholder: 'Enter bot credential ID', + description: 'Set the custom bot credential ID directly.', + required: { field: 'appType', value: 'custom' }, + mode: 'trigger-advanced', + condition: { field: 'appType', value: 'custom' }, + }, { id: 'source', title: 'Source', @@ -104,7 +138,7 @@ export const slackOAuthTrigger: TriggerConfig = { placeholder: 'Any channel the bot is in', description: 'Restrict to specific channels. Leave empty to trigger on any channel the bot has been added to.', - dependsOn: ['triggerCredentials'], + dependsOn: { any: ['triggerCredentials', 'customBotCredential'] }, required: false, mode: 'trigger', condition: { field: 'eventType', value: CHANNEL_FILTER_EVENTS }, @@ -153,38 +187,15 @@ export const slackOAuthTrigger: TriggerConfig = { condition: { field: 'eventType', value: NAME_FILTER_EVENTS }, }, { - id: 'webhookUrlDisplay', - title: 'Webhook URL', + id: 'interactionFilter', + title: 'Action / Callback ID', type: 'short-input', - readOnly: true, - showCopyButton: true, - useWebhookUrl: true, - placeholder: 'Webhook URL will be generated', - mode: 'trigger', - condition: { field: 'appType', value: 'custom' }, - }, - { - id: 'signingSecret', - title: 'Signing Secret', - type: 'short-input', - placeholder: 'Enter your Slack app signing secret', - description: 'The signing secret from your Slack app to validate request authenticity.', - password: true, - required: { field: 'appType', value: 'custom' }, - mode: 'trigger', - condition: { field: 'appType', value: 'custom' }, - }, - { - id: 'botToken', - title: 'Bot Token', - type: 'short-input', - placeholder: 'xoxb-...', + placeholder: 'approve_btn, deny_btn', description: - 'The bot token from your Slack app. Required for downloading files attached to messages.', - password: true, + 'Comma-separated action_ids (buttons/selects) or callback_ids (modals) to restrict to. Leave empty to fire on any interaction.', required: false, mode: 'trigger', - condition: { field: 'appType', value: 'custom' }, + condition: { field: 'eventType', value: INTERACTION_FILTER_EVENTS }, }, { id: 'filterBotMessages', @@ -217,16 +228,6 @@ export const slackOAuthTrigger: TriggerConfig = { mode: 'trigger', condition: { field: 'eventType', value: 'message' }, }, - { - id: 'setupWizard', - title: 'Slack App Setup', - type: 'modal', - modalId: 'slack-setup-wizard', - description: 'Walk through manifest creation, app install, and pasting credentials.', - hideFromPreview: true, - mode: 'trigger', - condition: { field: 'appType', value: 'custom' }, - }, ], outputs: SLACK_TRIGGER_OUTPUTS, diff --git a/apps/sim/triggers/slack/shared.ts b/apps/sim/triggers/slack/shared.ts index 50a8fe1bdf0..0efca9c72ac 100644 --- a/apps/sim/triggers/slack/shared.ts +++ b/apps/sim/triggers/slack/shared.ts @@ -164,8 +164,10 @@ export const SLACK_TRIGGER_OUTPUTS: Record = { * - `threads`: include / exclude / only thread replies. * - `emoji`: restrict a reaction event to specific emoji names. * - `name`: substring match on a created channel's name. + * - `interaction`: restrict an interactivity event to specific `action_id`s + * (block_actions) or `callback_id`s (view_submission). */ -export type SlackEventFilter = 'source' | 'channels' | 'threads' | 'emoji' | 'name' +export type SlackEventFilter = 'source' | 'channels' | 'threads' | 'emoji' | 'name' | 'interaction' export interface SlackEventCatalogEntry { /** Selected value stored under the `eventType` sub-block. */ @@ -250,6 +252,18 @@ export const SLACK_EVENT_CATALOG: readonly SlackEventCatalogEntry[] = [ simSubscribed: true, filters: [], }, + { + id: 'block_actions', + label: 'Button / select clicked', + simSubscribed: true, + filters: ['interaction'], + }, + { + id: 'view_submission', + label: 'Modal submitted', + simSubscribed: true, + filters: ['interaction'], + }, ] as const /** Catalog entry lookup by `eventType` id. */ diff --git a/packages/emcn/src/components/wizard/wizard.tsx b/packages/emcn/src/components/wizard/wizard.tsx index ddece93ac7d..ff533c64e3b 100644 --- a/packages/emcn/src/components/wizard/wizard.tsx +++ b/packages/emcn/src/components/wizard/wizard.tsx @@ -1,7 +1,6 @@ 'use client' import * as React from 'react' -import { cn } from '../../lib/cn' import { Button } from '../button/button' import { Modal, @@ -17,8 +16,8 @@ import { * A multi-step modal wizard primitive. * * @remarks - * Wraps the emcn Modal with a step progress bar, a shared Back / Next / Done - * footer, and declarative `Wizard.Step` children. Step state is controlled + * Wraps the emcn Modal with a shared Back / Next / Done footer and declarative + * `Wizard.Step` children. Step state is controlled * from the outside so the consumer can hydrate from persisted state, reset * on close, or jump around imperatively. * @@ -113,6 +112,13 @@ interface WizardProps { * If omitted, a generic sr-only description is rendered automatically. */ description?: string + /** + * Optional persistent header title shown (with `icon`) instead of the active + * step's title, for a stable branded header across steps. + */ + title?: string + /** Optional leading icon rendered in the header. */ + icon?: React.ComponentType<{ className?: string }> } const WizardRoot: React.FC = ({ @@ -128,6 +134,8 @@ const WizardRoot: React.FC = ({ nextLabel = 'Next', doneLabel = 'Done', description, + title, + icon: Icon, }) => { const steps = React.Children.toArray(children).filter(isStepElement) const total = steps.length @@ -154,24 +162,16 @@ const WizardRoot: React.FC = ({ return ( - {activeStep?.props.title} - -
    -
    - {steps.map((_step, i) => ( -
    - ))} -
    - - {clamped + 1}/{total} - -
    + + {title ? ( + + {Icon ? : null} + {title} + + ) : ( + activeStep?.props.title + )} + diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 9c434fee8df..95a74305670 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -73,6 +73,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ // event envelope (varies per event type) read via parseWebhookBody; there is // no client contract to bind — authenticity is enforced by signature. 'apps/sim/app/api/webhooks/slack/route.ts', + 'apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.ts', 'apps/sim/app/api/resume/poll/route.ts', // MCP routes that take only auth context (no client-supplied params/query/body). 'apps/sim/app/api/mcp/discover/route.ts', From 1c5be320cd8231e0955ce77734b3e798a713e6b7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 13:15:59 -0700 Subject: [PATCH 12/22] chore(api-validation): bump route baseline to 924 after staging merge --- scripts/check-api-validation-contracts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 54bcdf78026..79e5075fd1d 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 922, - zodRoutes: 922, + totalRoutes: 924, + zodRoutes: 924, nonZodRoutes: 0, } as const From 8d40d3f356917105f62d8e59b0567524593f70c9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 13:31:26 -0700 Subject: [PATCH 13/22] feat(slack): preview-gate slack_v2 and the custom-bot credential surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slack_v2 (block + hosted slack_oauth trigger) ships preview: true — hidden from all discovery until revealed via block-visibility AppConfig or PREVIEW_BLOCKS. v1 stays toolbar-visible with the legacy slack_webhook trigger until v2 GAs. The integrations-page custom-bot setup surface rides the same flag via isHiddenUnder(slack_v2); placed instances, existing credentials, and ingest/execution paths are never gated. --- .../[block]/integration-block-detail.tsx | 23 +++++++++++++------ apps/sim/blocks/blocks.test.ts | 9 +++++++- apps/sim/blocks/blocks/slack.ts | 15 +++++++++--- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 5c3bc46e5ff..6ca5964d8c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -21,6 +21,8 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration' +import { getBlock } from '@/blocks' +import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { getTileIconColorClass } from '@/blocks/icon-color' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { @@ -28,6 +30,7 @@ import { getTemplatesForBlock, type ScopedBlockTemplate, } from '@/blocks/registry' +import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' @@ -73,8 +76,17 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration ) }, [credentials, oauthService]) const [serviceAccountOpen, setServiceAccountOpen] = useState(false) - const hasServiceAccount = Boolean(oauthService?.serviceAccountProviderId) const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID + const blockOverlayVersion = useCustomBlockOverlayVersion() + // Custom Slack bots ride the slack_v2 preview flag: the setup surface stays + // hidden until that block is revealed for this viewer. + const slackBotPreviewHidden = useMemo(() => { + if (!isSlackBot) return false + const v2 = getBlock('slack_v2') + return !v2 || isHiddenUnder(overlayVisibility(), v2) + }, [isSlackBot, blockOverlayVersion]) + const hasServiceAccount = + Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden const hasHandledConnectQueryRef = useRef(false) useEffect(() => { @@ -85,10 +97,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration if (connectMode === CONNECT_MODE.oauth && oauthService) { setOAuthOpen(true) handled = true - } else if ( - connectMode === CONNECT_MODE.serviceAccount && - oauthService?.serviceAccountProviderId - ) { + } else if (connectMode === CONNECT_MODE.serviceAccount && hasServiceAccount) { setServiceAccountOpen(true) handled = true } @@ -96,7 +105,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration hasHandledConnectQueryRef.current = true void setConnectMode(null, { history: 'replace', scroll: false }) - }, [connectMode, oauthService, setConnectMode]) + }, [connectMode, oauthService, hasServiceAccount, setConnectMode]) const connectOptions = oauthService ? [ @@ -166,7 +175,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration serviceIcon={oauthService.serviceIcon} /> )} - {oauthService?.serviceAccountProviderId && ( + {hasServiceAccount && oauthService?.serviceAccountProviderId && ( { describe('Block Consistency', () => { it('should have consistent registry keys matching block types', () => { for (const block of getAllBlocks()) { - expect(getBlock(block.type)).toBe(block) + const canonical = getBlock(block.type) + if (canonical?.preview) { + // Preview blocks surface in getAllBlocks() as projection clones + // (hidden or "(Preview)"-suffixed); getBlock stays the pure config. + expect(canonical.type).toBe(block.type) + continue + } + expect(canonical).toBe(block) } }) diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 0548e50a35f..6129fb2ce7b 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -21,7 +21,9 @@ export const SlackBlock: BlockConfig = { bgColor: '#611f69', icon: SlackIcon, triggerAllowed: true, - hideFromToolbar: true, // Legacy: superseded by slack_v2. Kept for existing workflows. + // Superseded by slack_v2, but stays discoverable until v2 GAs — hiding both + // would leave no Slack block in the toolbar while v2 is preview-gated. At v2 + // GA this becomes `hideFromToolbar: true` (superseded-version paradigm). subBlocks: [ { id: 'operation', @@ -2464,9 +2466,11 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te event_id: { type: 'string', description: 'Unique event identifier for the trigger' }, }, // Trigger capabilities moved to slack_v2 so the trigger surfaces once. + // Legacy webhook trigger stays available while slack_v2 (which hosts the + // redesigned slack_oauth trigger) is preview-gated; drops at v2 GA. triggers: { - enabled: false, - available: [], + enabled: true, + available: ['slack_webhook'], }, } @@ -2666,6 +2670,11 @@ export const SlackV2Block: BlockConfig = { ...SlackBlock, type: 'slack_v2', hideFromToolbar: false, + // Preview-gated: hidden from every discovery surface until revealed via the + // block-visibility AppConfig (hosted) or PREVIEW_BLOCKS=slack_v2 (dev / + // self-host). At GA: drop this flag, add SlackV2BlockMeta + docs, and set + // hideFromToolbar on v1. + preview: true, subBlocks: SlackBlock.subBlocks.flatMap((sb) => { if (sb.id === 'botToken') return [] if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS] From d4ae64cda0b9c64174ea731357df6626fb33be3f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 13:44:53 -0700 Subject: [PATCH 14/22] fix(slack): v1 keeps slack_webhook trigger subblocks; handle object-form event channels - v1 spread had been swapped to slack_oauth's trigger subblocks (shared with v2), leaving its slack_webhook deploy path without signing-secret config (Bugbot high). v1 now carries the legacy trigger set again; v2 swaps them for slack_oauth's. - resolveSlackEventChannel reads channel.id for channel_created/channel_rename payloads, so channel filters no longer drop every rename event. --- apps/sim/blocks/blocks/slack.ts | 24 +++++++++++++------ apps/sim/lib/webhooks/providers/slack.test.ts | 10 ++++++++ apps/sim/lib/webhooks/providers/slack.ts | 4 ++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 6129fb2ce7b..0db701b5066 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -1453,7 +1453,7 @@ Return ONLY the integer Unix timestamp - no explanations, no quotes, no extra te }, required: true, }, - ...getTrigger('slack_oauth').subBlocks, + ...getTrigger('slack_webhook').subBlocks, ], tools: { access: [ @@ -2660,11 +2660,15 @@ const SLACK_CUSTOM_BOT_SUBBLOCKS: SubBlockConfig[] = [ }, ] +const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set( + getTrigger('slack_webhook').subBlocks.map((sb) => sb.id) +) + /** * slack_v2 — the go-forward Slack action block. Identical operations, tools, and * outputs to v1 (shared by reference), but the "Custom Bot" auth method selects * a reusable bot credential set up once, instead of pasting a raw token. Also - * hosts the native Slack trigger so it surfaces once. + * hosts the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook). */ export const SlackV2Block: BlockConfig = { ...SlackBlock, @@ -2675,11 +2679,17 @@ export const SlackV2Block: BlockConfig = { // self-host). At GA: drop this flag, add SlackV2BlockMeta + docs, and set // hideFromToolbar on v1. preview: true, - subBlocks: SlackBlock.subBlocks.flatMap((sb) => { - if (sb.id === 'botToken') return [] - if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS] - return [sb] - }), + subBlocks: [ + ...SlackBlock.subBlocks.flatMap((sb) => { + // Drop the legacy paste-secret trigger config (v1 hosts slack_webhook) + // and v1's raw bot-token auth field — the trigger set includes an + // id-colliding 'botToken', so the set check covers both. + if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return [] + if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS] + return [sb] + }), + ...getTrigger('slack_oauth').subBlocks, + ], triggers: { enabled: true, available: ['slack_oauth'], diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 9f1c9bf9a7d..118659f449a 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -437,6 +437,16 @@ describe('shouldSkipSlackTriggerEvent', () => { expect(fires({ eventType: 'reaction_added', bot_user_id: 'U_OTHER' }, event)).toBe(true) }) + it('matches the channel filter for object-form channels (channel_rename)', () => { + // channel_created / channel_rename deliver `channel` as an object, not a string. + const rename = { + type: 'channel_rename', + channel: { id: 'C1', name: 'renamed-channel', created: 1 }, + } + expect(fires({ eventType: 'channel_rename', channelFilter: ['C1'] }, rename)).toBe(true) + expect(fires({ eventType: 'channel_rename', channelFilter: ['C2'] }, rename)).toBe(false) + }) + it('applies the emoji filter to reaction events', () => { const event = { type: 'reaction_added', diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts index 74872183e35..fcd88767072 100644 --- a/apps/sim/lib/webhooks/providers/slack.ts +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -499,6 +499,10 @@ export function resolveSlackEventChannel( ): string | undefined { if (!event) return undefined if (typeof event.channel === 'string') return event.channel + // channel_created / channel_rename deliver `channel` as an object. + if (isRecordLike(event.channel) && typeof event.channel.id === 'string') { + return event.channel.id + } const item = event.item as Record | undefined if (typeof item?.channel === 'string') return item.channel return typeof event.channel_id === 'string' ? event.channel_id : undefined From d0ed86d17b0cf695359e10f150727efdfc8e13b1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 13:52:00 -0700 Subject: [PATCH 15/22] fix(slack): default absent appType to custom at deploy; deactivate custom-bot webhooks on credential delete - appType is hidden and seeded 'custom' by value(), which only covers editor-created blocks; defaultValue now persists it via buildProviderConfig and the deploy fallback flips to custom (the only exposed mode this ship) - deleting a slack-custom-bot credential now also deactivates provider='slack' webhooks routed by that credential id, not just native slack_app rows --- apps/sim/lib/credentials/deletion.ts | 20 +++++++++++++------- apps/sim/lib/webhooks/deploy.ts | 5 ++++- apps/sim/triggers/slack/oauth.ts | 4 ++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 2678675bd9d..1ea84e8d1c5 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, or, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils' @@ -95,10 +95,11 @@ export async function clearCredentialRefs( } /** - * Deactivates native Slack (`slack_app`) trigger webhooks bound to this - * credential so inbound events stop routing once the account is disconnected. - * The credential id lives in `providerConfig`, not a foreign key, so it is not - * covered by CASCADE. + * Deactivates Slack trigger webhooks bound to this credential so inbound + * events stop routing once the account is disconnected: native (`slack_app`) + * rows reference it via `providerConfig.credentialId`, custom-bot (`slack`) + * rows via `routingKey` = the bot credential id. Neither is a foreign key, so + * neither is covered by CASCADE. */ async function deactivateSlackAppWebhooks(credentialId: string): Promise { await db @@ -106,9 +107,14 @@ async function deactivateSlackAppWebhooks(credentialId: string): Promise { .set({ isActive: false, updatedAt: new Date() }) .where( and( - eq(schema.webhook.provider, 'slack_app'), eq(schema.webhook.isActive, true), - sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}` + or( + and( + eq(schema.webhook.provider, 'slack_app'), + sql`${schema.webhook.providerConfig}->>'credentialId' = ${credentialId}` + ), + and(eq(schema.webhook.provider, 'slack'), eq(schema.webhook.routingKey, credentialId)) + ) ) ) } diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 6ea7b2513fa..e43b6edd6a3 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -430,7 +430,10 @@ export async function saveTriggerWebhooksForDeploy({ let effectivePath: string | null = triggerPath let routingKey: string | null = null if (triggerId === 'slack_oauth') { - const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'sim' + // Absent appType means custom: it's the only mode this ship exposes (the + // hidden selector seeds/persists 'custom'), and defaulting to sim would + // send credential-less configs down the OAuth/team-id branch. + const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'custom' if (appType === 'sim') { const eventType = typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index bae96f7fe8b..1591889831b 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -73,6 +73,10 @@ export const slackOAuthTrigger: TriggerConfig = { { label: 'Custom', id: 'custom' }, ], value: () => 'custom', + // `value()` only seeds editor-created blocks; `defaultValue` is what + // buildProviderConfig persists when the stored value is absent + // (imported / programmatically-created workflows). + defaultValue: 'custom', description: 'Use the official Sim Slack app, or your own custom Slack app.', mode: 'trigger', }, From ec06db5fc3a89ddc88c206087d4a015632bc5153 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 13:57:49 -0700 Subject: [PATCH 16/22] fix(slack): resolve credential owner for deploy-time team_id lookup A teammate deploying a trigger wired to a shared Slack credential isn't the credential owner; refreshAccessTokenIfNeeded only loads tokens for the owning user. Resolve the account owner first, mirroring the runtime formatInput path. --- apps/sim/lib/webhooks/deploy.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index e43b6edd6a3..5d9145e3796 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { webhook, workflowDeploymentVersion } from '@sim/db/schema' +import { account, webhook, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -19,7 +19,11 @@ import { isCanonicalPair, resolveActiveCanonicalValue, } from '@/lib/workflows/subblocks/visibility' -import { getSlackBotCredential, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { + getSlackBotCredential, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -453,7 +457,20 @@ export async function saveTriggerWebhooksForDeploy({ error: { message: 'Select a Slack account for the trigger.', status: 400 }, } } - const botToken = await refreshAccessTokenIfNeeded(credentialId, userId, requestId) + // Resolve the credential OWNER's token (not the deploying actor's) — + // in a shared workspace a teammate can deploy a trigger wired to + // someone else's Slack credential. Mirrors the runtime formatInput path. + let tokenOwnerUserId = userId + const resolvedAccount = await resolveOAuthAccountId(credentialId) + if (resolvedAccount?.accountId) { + const [owner] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolvedAccount.accountId)) + .limit(1) + if (owner?.userId) tokenOwnerUserId = owner.userId + } + const botToken = await refreshAccessTokenIfNeeded(credentialId, tokenOwnerUserId, requestId) if (!botToken) { return { success: false, From 67f54df3018fb14dcaea64a4a8058a41f1de8dcf Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 15:36:02 -0700 Subject: [PATCH 17/22] chore(slack): reconcile staging merge - nullable webhook.path coalesced at correlation/payload/tiktok boundaries - slack dispatch delegates to staging's dispatchResolvedWebhookTarget (shared preprocess/deployment/filter/enqueue lifecycle), keeping the skip-reason diagnostics; route tests reworked around that seam - api-validation route baseline 924 -> 926 --- .../slack/custom/[credentialId]/route.test.ts | 52 ++++++-------- apps/sim/app/api/webhooks/slack/route.test.ts | 69 +++++++------------ apps/sim/background/tiktok-webhook-ingress.ts | 2 +- apps/sim/lib/webhooks/processor.ts | 5 +- apps/sim/lib/webhooks/slack-dispatch.ts | 51 ++++---------- scripts/check-api-validation-contracts.ts | 4 +- 6 files changed, 67 insertions(+), 116 deletions(-) diff --git a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts index 18be21165ec..ae51cee4d19 100644 --- a/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/custom/[credentialId]/route.test.ts @@ -6,23 +6,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, - mockCheckWebhookPreprocessing, - mockQueueWebhookExecution, - mockBlockExistsInDeployment, + mockDispatchResolvedWebhookTarget, mockGetSlackBotCredential, mockHandleChallenge, mockVerifySignature, - mockShouldSkip, } = vi.hoisted(() => ({ mockParseWebhookBody: vi.fn(), mockFindWebhooksByRoutingKey: vi.fn(), - mockCheckWebhookPreprocessing: vi.fn(), - mockQueueWebhookExecution: vi.fn(), - mockBlockExistsInDeployment: vi.fn(), + mockDispatchResolvedWebhookTarget: vi.fn(), mockGetSlackBotCredential: vi.fn(), mockHandleChallenge: vi.fn(), mockVerifySignature: vi.fn(), - mockShouldSkip: vi.fn(), })) vi.mock('@/lib/core/admission/gate', () => ({ @@ -37,21 +31,15 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({ vi.mock('@/lib/webhooks/processor', () => ({ parseWebhookBody: mockParseWebhookBody, findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey, - checkWebhookPreprocessing: mockCheckWebhookPreprocessing, - queueWebhookExecution: mockQueueWebhookExecution, + dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget, })) vi.mock('@/lib/webhooks/providers/slack', () => ({ handleSlackChallenge: mockHandleChallenge, verifySlackRequestSignature: mockVerifySignature, - shouldSkipSlackTriggerEvent: mockShouldSkip, resolveSlackEventKey: () => null, })) -vi.mock('@/lib/workflows/persistence/utils', () => ({ - blockExistsInDeployment: mockBlockExistsInDeployment, -})) - import { POST } from '@/app/api/webhooks/slack/custom/[credentialId]/route' const CREDENTIAL_ID = 'cred-123' @@ -80,7 +68,6 @@ describe('Slack custom-bot webhook route', () => { vi.clearAllMocks() mockHandleChallenge.mockReturnValue(null) mockVerifySignature.mockReturnValue(null) - mockShouldSkip.mockReturnValue(false) mockParseWebhookBody.mockResolvedValue({ body: messageBody, rawBody: JSON.stringify(messageBody), @@ -91,12 +78,11 @@ describe('Slack custom-bot webhook route', () => { teamId: 'T1', }) mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')]) - mockCheckWebhookPreprocessing.mockResolvedValue({ - actorUserId: 'u1', - executionId: 'e1', - correlation: {}, + mockDispatchResolvedWebhookTarget.mockResolvedValue({ + outcome: 'queued', + response: new Response(null, { status: 200 }), + reason: 'queued', }) - mockBlockExistsInDeployment.mockResolvedValue(true) }) it('echoes the url_verification challenge without loading the credential', async () => { @@ -110,7 +96,7 @@ describe('Slack custom-bot webhook route', () => { mockGetSlackBotCredential.mockResolvedValue(null) const res = await POST(makeRequest(), context) expect(res.status).toBe(404) - expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled() }) it('verifies with the credential signing secret and rejects a bad signature', async () => { @@ -123,22 +109,28 @@ describe('Slack custom-bot webhook route', () => { expect.any(String) ) expect(res.status).toBe(401) - expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled() }) - it('fans out by credential id (provider slack) and queues when not skipped', async () => { - await POST(makeRequest(), context) + it('fans out by credential id (provider slack) and dispatches each webhook', async () => { + const res = await POST(makeRequest(), context) expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith( CREDENTIAL_ID, expect.any(String), 'slack' ) - expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(1) + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) + expect(res.status).toBe(200) }) - it('does not queue when the shared filter skips the event', async () => { - mockShouldSkip.mockReturnValue(true) - await POST(makeRequest(), context) - expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + it('still returns 200 when the dispatcher filters the event', async () => { + mockDispatchResolvedWebhookTarget.mockResolvedValue({ + outcome: 'ignored', + response: new Response(null, { status: 200 }), + reason: 'filtered', + }) + const res = await POST(makeRequest(), context) + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) + expect(res.status).toBe(200) }) }) diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts index 6a9eec97965..ac20c7927cf 100644 --- a/apps/sim/app/api/webhooks/slack/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/route.test.ts @@ -3,21 +3,12 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockParseWebhookBody, - mockFindWebhooksByRoutingKey, - mockCheckWebhookPreprocessing, - mockQueueWebhookExecution, - mockBlockExistsInDeployment, - mockShouldSkip, -} = vi.hoisted(() => ({ - mockParseWebhookBody: vi.fn(), - mockFindWebhooksByRoutingKey: vi.fn(), - mockCheckWebhookPreprocessing: vi.fn(), - mockQueueWebhookExecution: vi.fn(), - mockBlockExistsInDeployment: vi.fn(), - mockShouldSkip: vi.fn(), -})) +const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, mockDispatchResolvedWebhookTarget } = + vi.hoisted(() => ({ + mockParseWebhookBody: vi.fn(), + mockFindWebhooksByRoutingKey: vi.fn(), + mockDispatchResolvedWebhookTarget: vi.fn(), + })) vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: () => ({ release: vi.fn() }), @@ -31,21 +22,15 @@ vi.mock('@/lib/core/config/env', () => ({ vi.mock('@/lib/webhooks/processor', () => ({ parseWebhookBody: mockParseWebhookBody, findWebhooksByRoutingKey: mockFindWebhooksByRoutingKey, - checkWebhookPreprocessing: mockCheckWebhookPreprocessing, - queueWebhookExecution: mockQueueWebhookExecution, + dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget, })) vi.mock('@/lib/webhooks/providers/slack', () => ({ handleSlackChallenge: () => null, verifySlackRequestSignature: () => null, - shouldSkipSlackTriggerEvent: mockShouldSkip, resolveSlackEventKey: () => null, })) -vi.mock('@/lib/workflows/persistence/utils', () => ({ - blockExistsInDeployment: mockBlockExistsInDeployment, -})) - import { POST } from '@/app/api/webhooks/slack/route' function makeRequest() { @@ -61,12 +46,6 @@ function webhook(id: string) { async function run(body: Record) { mockParseWebhookBody.mockResolvedValue({ body, rawBody: JSON.stringify(body) }) - mockCheckWebhookPreprocessing.mockResolvedValue({ - actorUserId: 'u1', - executionId: 'e1', - correlation: {}, - }) - mockBlockExistsInDeployment.mockResolvedValue(true) await POST(makeRequest()) } @@ -80,22 +59,29 @@ describe('Slack app webhook route', () => { beforeEach(() => { vi.clearAllMocks() mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')]) + mockDispatchResolvedWebhookTarget.mockResolvedValue({ + outcome: 'queued', + response: new Response(null, { status: 200 }), + reason: 'queued', + }) }) - it('queues execution when the shared filter does not skip', async () => { - mockShouldSkip.mockReturnValue(false) + it('dispatches each webhook resolved for the event team', async () => { await run(messageBody) - expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(1) + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) }) - it('does not queue when the shared filter skips the event', async () => { - mockShouldSkip.mockReturnValue(true) + it('continues cleanly when the dispatcher filters the event', async () => { + mockDispatchResolvedWebhookTarget.mockResolvedValue({ + outcome: 'ignored', + response: new Response(null, { status: 200 }), + reason: 'filtered', + }) await run(messageBody) - expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) }) it('routes via Slack Connect authorizations and dedups overlapping webhooks', async () => { - mockShouldSkip.mockReturnValue(false) // Two candidate teams (outer + authorization) that resolve to overlapping webhooks. mockFindWebhooksByRoutingKey.mockImplementation(async (teamId: string) => teamId === 'T1' ? [webhook('wh1')] : [webhook('wh1'), webhook('wh2')] @@ -105,19 +91,17 @@ describe('Slack app webhook route', () => { authorizations: [{ team_id: 'T2' }], }) expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledTimes(2) - // wh1 (in both) is queued once, wh2 once — dedup by webhook id. - expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(2) + // wh1 (in both) is dispatched once, wh2 once — dedup by webhook id. + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2) }) it('returns 200 with no team_id', async () => { - mockShouldSkip.mockReturnValue(false) await run({ event: { type: 'message' } }) expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() - expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled() }) it('routes an interaction payload by payload.team.id', async () => { - mockShouldSkip.mockReturnValue(false) await run({ type: 'block_actions', api_app_id: 'A1', @@ -126,11 +110,10 @@ describe('Slack app webhook route', () => { actions: [{ action_id: 'approve_btn' }], }) expect(mockFindWebhooksByRoutingKey).toHaveBeenCalledWith('T1', expect.anything()) - expect(mockQueueWebhookExecution).toHaveBeenCalledTimes(1) + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) }) it('fails closed on an interaction missing payload.team.id (never routes on user.team_id)', async () => { - mockShouldSkip.mockReturnValue(false) await run({ type: 'block_actions', api_app_id: 'A1', @@ -138,6 +121,6 @@ describe('Slack app webhook route', () => { actions: [{ action_id: 'approve_btn' }], }) expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() - expect(mockQueueWebhookExecution).not.toHaveBeenCalled() + expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/background/tiktok-webhook-ingress.ts b/apps/sim/background/tiktok-webhook-ingress.ts index a55cbb3783e..d5b4bfb9480 100644 --- a/apps/sim/background/tiktok-webhook-ingress.ts +++ b/apps/sim/background/tiktok-webhook-ingress.ts @@ -60,7 +60,7 @@ export async function executeTikTokWebhookIngress( request, { requestId: payload.requestId, - path: webhook.path, + path: webhook.path ?? undefined, receivedAt: payload.receivedAt, triggerTimestampMs: payload.envelope.create_time * 1000, } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index e0f3fc9fc6d..cb289b9e9a3 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -676,7 +676,8 @@ async function queueWebhookExecutionWithResult( source: 'webhook' as const, workflowId: foundWorkflow.id, webhookId: foundWebhook.id, - path: options.path || foundWebhook.path, + // Routing-key webhooks (e.g. Slack) have no path. + path: options.path || foundWebhook.path || undefined, provider: foundWebhook.provider, triggerType: 'webhook', } satisfies AsyncExecutionCorrelation) @@ -692,7 +693,7 @@ async function queueWebhookExecutionWithResult( provider: foundWebhook.provider, body, headers, - path: options.path || foundWebhook.path, + path: options.path || foundWebhook.path || '', blockId: foundWebhook.blockId ?? undefined, workspaceId, ...(credentialId ? { credentialId } : {}), diff --git a/apps/sim/lib/webhooks/slack-dispatch.ts b/apps/sim/lib/webhooks/slack-dispatch.ts index a865b661b9a..8d1b15e704f 100644 --- a/apps/sim/lib/webhooks/slack-dispatch.ts +++ b/apps/sim/lib/webhooks/slack-dispatch.ts @@ -1,12 +1,10 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { - checkWebhookPreprocessing, + dispatchResolvedWebhookTarget, type findWebhooksByRoutingKey, - queueWebhookExecution, } from '@/lib/webhooks/processor' -import { resolveSlackEventKey, shouldSkipSlackTriggerEvent } from '@/lib/webhooks/providers/slack' -import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils' +import { resolveSlackEventKey } from '@/lib/webhooks/providers/slack' const logger = createLogger('SlackWebhookDispatch') @@ -19,10 +17,10 @@ interface DispatchSlackWebhooksOptions { /** * Shared fan-out tail for the Slack ingest routes (native team-id route and the - * custom-bot credential route): run each candidate webhook through the trigger - * filter, verify its block is still deployed, preprocess, and enqueue. Keeping - * this in one place stops the skip/preprocess/queue sequence drifting between - * the two ingest paths. + * custom-bot credential route): run each candidate webhook through the common + * post-auth lifecycle (preprocess, deployment check, trigger filter, enqueue) + * via {@link dispatchResolvedWebhookTarget}, logging skip diagnostics for + * filtered events. */ export async function dispatchSlackWebhooks( webhooks: Awaited>, @@ -34,12 +32,15 @@ export async function dispatchSlackWebhooks( const triggerTimestampMs = Number.isFinite(parsedTimestampMs) ? parsedTimestampMs : undefined for (const { webhook: foundWebhook, workflow: foundWorkflow } of webhooks) { - const providerConfig = (foundWebhook.providerConfig as Record) || {} + const result = await dispatchResolvedWebhookTarget(foundWebhook, foundWorkflow, body, request, { + requestId, + receivedAt, + triggerTimestampMs, + }) - // Shared trigger filter (event, source, threads, emoji, name, channels, - // interaction, self-drop, bot). - if (shouldSkipSlackTriggerEvent(payload, providerConfig)) { + if (result.outcome === 'ignored' && result.reason === 'filtered') { const rawEvent = payload.event as Record | undefined + const providerConfig = (foundWebhook.providerConfig as Record) || {} logger.info(`[${requestId}] Event skipped by trigger filter for webhook ${foundWebhook.id}`, { eventKey: resolveSlackEventKey(payload), configuredEvent: providerConfig.eventType, @@ -50,32 +51,6 @@ export async function dispatchSlackWebhooks( threadsSetting: providerConfig.threads, botId: rawEvent?.bot_id, }) - continue } - - if (foundWebhook.blockId) { - const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId) - if (!blockExists) { - logger.info( - `[${requestId}] Trigger block ${foundWebhook.blockId} not in deployment for ${foundWorkflow.id}` - ) - continue - } - } - - const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId) - if (preprocessResult.error) { - logger.warn(`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}`) - continue - } - - await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { - requestId, - actorUserId: preprocessResult.actorUserId, - executionId: preprocessResult.executionId, - correlation: preprocessResult.correlation, - receivedAt, - triggerTimestampMs, - }) } } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 79e5075fd1d..9b54931dd94 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 924, - zodRoutes: 924, + totalRoutes: 926, + zodRoutes: 926, nonZodRoutes: 0, } as const From 987ba1387519cf9bf8df6f1c4dbdc41155f58027 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 15:42:36 -0700 Subject: [PATCH 18/22] fix(slack): workspace-scope bot credentials at deploy; recreate webhooks on routing transitions - a bot credential id is semi-public (embedded in Slack Request URLs), so the custom deploy branch now rejects credentials outside the workflow's workspace - needsRecreation also compares path/routingKey, so a row from an older routing model can't survive redeploy as a stale delivery surface --- apps/sim/app/api/auth/oauth/utils.ts | 4 ++++ apps/sim/lib/webhooks/deploy.ts | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 97e5e0e7ae1..367821cb7d0 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -230,6 +230,8 @@ export interface SlackBotCredentialSecrets { teamId: string botUserId?: string teamName?: string + /** Owning workspace — callers with a user/workflow context must verify it. */ + workspaceId: string | null } /** @@ -250,6 +252,7 @@ export async function getSlackBotCredential( type: credential.type, providerId: credential.providerId, encryptedServiceAccountKey: credential.encryptedServiceAccountKey, + workspaceId: credential.workspaceId, }) .from(credential) .where(eq(credential.id, credentialId)) @@ -275,6 +278,7 @@ export async function getSlackBotCredential( teamId: blob.teamId, botUserId: blob.botUserId, teamName: blob.teamName, + workspaceId: row.workspaceId ?? null, } } diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 37022aa625a..60decead333 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -583,6 +583,21 @@ export async function saveTriggerWebhooksForDeploy({ }, } } + // The credential must belong to the workflow's workspace: bot credential + // ids are semi-public (they're embedded in Slack Request URLs), so a + // pasted foreign id must never bind another tenant's bot to this + // workflow. + const workflowWorkspace = + typeof workflow.workspaceId === 'string' ? workflow.workspaceId : undefined + if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) { + return { + success: false, + error: { + message: 'The selected Slack bot credential is not available in this workspace.', + status: 400, + }, + } + } effectiveProvider = 'slack' effectivePath = null routingKey = botCredentialId @@ -636,6 +651,11 @@ export async function saveTriggerWebhooksForDeploy({ const needsRecreation = forceRecreateSubscriptions || existingWh.provider !== effectiveProvider || + // Routing transitions (path-based <-> routing-key, or a changed key) + // must recreate the row even when the provider config compares equal — + // otherwise a stale delivery surface stays active on the old route. + (existingWh.path ?? null) !== effectivePath || + ((existingWh.routingKey as string | null) ?? null) !== routingKey || hasWebhookConfigChanged(existingConfig, providerConfig) if (needsRecreation) { From 9146a33337661b75a5bc288c2cc4c81821c98e56 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 15:47:41 -0700 Subject: [PATCH 19/22] test(slack): pin fail-closed behavior for empty/missing event selection --- apps/sim/lib/webhooks/providers/slack.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/sim/lib/webhooks/providers/slack.test.ts b/apps/sim/lib/webhooks/providers/slack.test.ts index 118659f449a..839f8807e56 100644 --- a/apps/sim/lib/webhooks/providers/slack.test.ts +++ b/apps/sim/lib/webhooks/providers/slack.test.ts @@ -458,6 +458,12 @@ describe('shouldSkipSlackTriggerEvent', () => { expect(fires({ eventType: 'reaction_added', emoji: 'eyes, thumbsup' }, event)).toBe(true) }) + it('fails closed when no eventType and the legacy events selection is empty or missing', () => { + const event = { type: 'message', channel_type: 'channel', channel: 'C1', ts: '7.0' } + expect(fires({}, event)).toBe(false) + expect(fires({ events: [] }, event)).toBe(false) + }) + it('honors the legacy events array for pre-redesign webhooks', () => { expect( fires( From b77dd69445ef93b4649fbfaa81fdf194a18e3505 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 16:02:57 -0700 Subject: [PATCH 20/22] fix(slack): 409 on custom-bot name collision instead of silently returning the existing credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service-account dedupe matches on displayName, which defaults to the Slack team name — shared by every bot in that workspace. A second unnamed bot create returned the first credential as success, orphaning the new id already pasted into the Slack Request URL. Same-id replays stay idempotent; different-id collisions now fail loudly so the wizard prompts for a distinct name. --- apps/sim/app/api/credentials/route.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 39ce6f4a20e..db1dc91c47c 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -417,6 +417,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) if (existingCredential) { + // A retried custom-bot create with the SAME pre-generated id is an + // idempotent replay and falls through to the normal existing-credential + // path. Any other name collision must fail loudly: returning the existing + // row as success would orphan the new id already embedded in the user's + // Slack Request URL (Slack would post to a URL no credential resolves). + if ( + resolvedProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID && + clientCredentialId && + existingCredential.id !== clientCredentialId + ) { + return NextResponse.json( + { + code: 'duplicate_display_name', + error: `A Slack bot named "${resolvedDisplayName}" already exists in this workspace. Give this bot a different name.`, + }, + { status: 409 } + ) + } + const access = await getCredentialActorContext(existingCredential.id, session.user.id, { workspaceAccess, }) From 470e3c5ca7c6dcda244c77a3d34bbaf6041a377b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 16:15:42 -0700 Subject: [PATCH 21/22] fix(slack): reconnect surfaces Atlassian error codes and persists name/description edits - PUT credential route now returns the Atlassian provider code (providerErrorCode -> code) so reconnect failures map to specific token/domain messages, matching create - Google/Atlassian reconnect send + seed displayName/description (parity with Slack); edits are no longer silently discarded, and empty fields don't clobber existing values --- apps/sim/app/api/credentials/[id]/route.ts | 8 +++- .../connect-service-account-modal.tsx | 40 ++++++++++++++----- .../lib/credentials/orchestration/index.ts | 11 ++++- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index bd067d20a7e..19f8ba89f1d 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -99,7 +99,13 @@ export const PUT = withRouteHandler( : result.errorCode === 'validation' ? 400 : 500 - return NextResponse.json({ error: result.error }, { status }) + return NextResponse.json( + { + error: result.error, + ...(result.providerErrorCode ? { code: result.providerErrorCode } : {}), + }, + { status } + ) } const access = await getCredentialActorContext(id, session.user.id) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index f50ac47225e..16375108878 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -142,6 +142,8 @@ export function ConnectServiceAccountModal({ serviceName={serviceName} serviceIcon={serviceIcon} credentialId={credentialId} + initialDisplayName={credentialDisplayName} + initialDescription={credentialDescription} /> ) } @@ -153,6 +155,8 @@ export function ConnectServiceAccountModal({ serviceName={serviceName} serviceIcon={serviceIcon} credentialId={credentialId} + initialDisplayName={credentialDisplayName} + initialDescription={credentialDescription} /> ) } @@ -165,6 +169,9 @@ interface ProviderModalProps { serviceIcon: ComponentType<{ className?: string }> /** When set, reconnect (rotate secrets on) this credential in place. */ credentialId?: string + /** Existing name/description, seeded into the fields on reconnect. */ + initialDisplayName?: string + initialDescription?: string } /** @@ -179,11 +186,13 @@ function GoogleServiceAccountModal({ serviceName, serviceIcon: ServiceIcon, credentialId, + initialDisplayName, + initialDescription, }: ProviderModalProps) { const [jsonInput, setJsonInput] = useState('') const [uploadedFileName, setUploadedFileName] = useState(null) - const [displayName, setDisplayName] = useState('') - const [description, setDescription] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') const [error, setError] = useState(null) const createCredential = useCreateWorkspaceCredential() @@ -193,10 +202,10 @@ function GoogleServiceAccountModal({ if (open) return setJsonInput('') setUploadedFileName(null) - setDisplayName('') - setDescription('') + setDisplayName(initialDisplayName ?? '') + setDescription(initialDescription ?? '') setError(null) - }, [open]) + }, [open, initialDisplayName, initialDescription]) /** * Try to auto-populate display name from the JSON `client_email`. Silent on @@ -249,7 +258,12 @@ function GoogleServiceAccountModal({ } try { if (credentialId) { - await updateCredential.mutateAsync({ credentialId, serviceAccountJson: trimmed }) + await updateCredential.mutateAsync({ + credentialId, + serviceAccountJson: trimmed, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + }) } else { await createCredential.mutateAsync({ workspaceId, @@ -359,11 +373,13 @@ function AtlassianServiceAccountModal({ serviceName, serviceIcon: ServiceIcon, credentialId, + initialDisplayName, + initialDescription, }: ProviderModalProps) { const [apiToken, setApiToken] = useState('') const [domain, setDomain] = useState('') - const [displayName, setDisplayName] = useState('') - const [description, setDescription] = useState('') + const [displayName, setDisplayName] = useState(initialDisplayName ?? '') + const [description, setDescription] = useState(initialDescription ?? '') const [error, setError] = useState(null) const createCredential = useCreateWorkspaceCredential() @@ -373,10 +389,10 @@ function AtlassianServiceAccountModal({ if (open) return setApiToken('') setDomain('') - setDisplayName('') - setDescription('') + setDisplayName(initialDisplayName ?? '') + setDescription(initialDescription ?? '') setError(null) - }, [open]) + }, [open, initialDisplayName, initialDescription]) const trimmedToken = apiToken.trim() const normalizedDomain = normalizeAtlassianDomain(domain) @@ -395,6 +411,8 @@ function AtlassianServiceAccountModal({ credentialId, apiToken: trimmedToken, domain: normalizedDomain, + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, }) } else { await createCredential.mutateAsync({ diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 8e7342bfdb0..66cdbe63c33 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -54,6 +54,8 @@ export interface PerformCredentialResult { success: boolean error?: string errorCode?: CredentialOrchestrationErrorCode + /** Provider-specific code (e.g. Atlassian `invalid_credentials`) for client message mapping. */ + providerErrorCode?: string workspaceId?: string updatedFields?: string[] } @@ -139,7 +141,14 @@ export async function performUpdateCredential( return { success: false, error: error.message, errorCode: 'validation' } } if (error instanceof AtlassianValidationError) { - return { success: false, error: error.code, errorCode: 'validation' } + // Surface the provider code so the client maps it to the specific + // token/domain message (create returns it too). + return { + success: false, + error: error.code, + errorCode: 'validation', + providerErrorCode: error.code, + } } throw error } From 58d28519d58d0dd93d5a1894780ba60f46b7e23c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 10 Jul 2026 16:35:15 -0700 Subject: [PATCH 22/22] fix(slack): require bot name; propagate rotated bot_user_id to webhooks on reconnect - the setup wizard now requires a bot name (canAdvance), so the credential name, manifest app name, and uniqueness key all use the user's choice instead of the shared Slack team-name fallback that collided for a second bot in one workspace - reconnect that changes the bot user id (recreated Slack app) now updates the bot_user_id cached in each bound webhook's providerConfig, so reaction self-drop keeps working instead of letting the bot's own reactions re-enter --- .../connect-slack-bot-modal.tsx | 5 ++++- .../lib/credentials/orchestration/index.ts | 20 +++++++++++++++++-- .../service-account-secret.test.ts | 1 + .../lib/credentials/service-account-secret.ts | 3 +++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx index 0a6933988d5..7526064411e 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal.tsx @@ -200,7 +200,10 @@ export function ConnectSlackBotModal({ title={isReconnect ? 'Reconnect a custom Slack bot' : 'Create a custom Slack bot'} doneLabel='Done' > - + {/* Bot name is required so the credential name, the manifest app name, and + uniqueness all use the user's choice — never the shared Slack team name + fallback, which collides for a second bot in the same workspace. */} + 0}> key !== 'updatedAt') recordAudit({ workspaceId: access.credential.workspaceId, diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 4fe7eb52cb3..42b1be22b6c 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -59,6 +59,7 @@ describe('verifyAndBuildServiceAccountSecret', () => { expect(result.providerId).toBe(SLACK_CUSTOM_BOT_PROVIDER_ID) expect(result.displayName).toBe('Acme') expect(result.auditMetadata.slackTeamId).toBe('T1') + expect(result.botUserId).toBe('U_BOT') const blob = JSON.parse(result.encryptedServiceAccountKey) expect(blob).toMatchObject({ signingSecret: 'sec', diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index 1f59d0e0007..579fae92531 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -29,6 +29,8 @@ export interface ServiceAccountSecretResult { encryptedServiceAccountKey: string displayName: string auditMetadata: Record + /** Slack custom bot: the derived bot user id (for reaction self-drop). */ + botUserId?: string } /** Thrown when a service-account secret is missing or fails provider verification. */ @@ -114,6 +116,7 @@ export async function verifyAndBuildServiceAccountSecret( encryptedServiceAccountKey: encrypted, displayName: teamName || 'Slack bot', auditMetadata: { slackTeamId: teamId }, + botUserId, } }