diff --git a/apps/docs/content/docs/en/execution/api.mdx b/apps/docs/content/docs/en/execution/api.mdx index ba378a8bc74..37fda5f1422 100644 --- a/apps/docs/content/docs/en/execution/api.mdx +++ b/apps/docs/content/docs/en/execution/api.mdx @@ -240,32 +240,78 @@ Retrieve execution details including the workflow state snapshot. -## Webhook Subscriptions +## Notifications -Get real-time notifications when workflow executions complete. Webhooks are configured through the Sim UI in the workflow editor. +Get real-time notifications when workflow executions complete via webhook, email, or Slack. Notifications are configured at the workspace level from the Logs page. ### Configuration -Webhooks can be configured for each workflow through the workflow editor UI. Click the webhook icon in the control bar to set up your webhook subscriptions. +Configure notifications from the Logs page by clicking the menu button and selecting "Configure Notifications". -
-
+**Notification Channels:** +- **Webhook**: Send HTTP POST requests to your endpoint +- **Email**: Receive email notifications with execution details +- **Slack**: Post messages to a Slack channel -**Available Configuration Options:** +**Workflow Selection:** +- Select specific workflows to monitor +- Or choose "All Workflows" to include current and future workflows + +**Filtering Options:** +- `levelFilter`: Log levels to receive (`info`, `error`) +- `triggerFilter`: Trigger types to receive (`api`, `webhook`, `schedule`, `manual`, `chat`) + +**Optional Data:** +- `includeFinalOutput`: Include the workflow's final output +- `includeTraceSpans`: Include detailed execution trace spans +- `includeRateLimits`: Include rate limit information (sync/async limits and remaining) +- `includeUsageData`: Include billing period usage and limits + +### Alert Rules + +Instead of receiving notifications for every execution, configure alert rules to be notified only when issues are detected: + +**Consecutive Failures** +- Alert after X consecutive failed executions (e.g., 3 failures in a row) +- Resets when an execution succeeds + +**Failure Rate** +- Alert when failure rate exceeds X% over the last Y hours +- Requires minimum 5 executions in the window +- Only triggers after the full time window has elapsed + +**Latency Threshold** +- Alert when any execution takes longer than X seconds +- Useful for catching slow or hanging workflows + +**Latency Spike** +- Alert when execution is X% slower than the average +- Compares against the average duration over the configured time window +- Requires minimum 5 executions to establish baseline + +**Cost Threshold** +- Alert when a single execution costs more than $X +- Useful for catching expensive LLM calls + +**No Activity** +- Alert when no executions occur within X hours +- Useful for monitoring scheduled workflows that should run regularly + +**Error Count** +- Alert when error count exceeds X within a time window +- Tracks total errors, not consecutive + +All alert types include a 1-hour cooldown to prevent notification spam. + +### Webhook Configuration + +For webhooks, additional options are available: - `url`: Your webhook endpoint URL - `secret`: Optional secret for HMAC signature verification -- `includeFinalOutput`: Include the workflow's final output in the payload -- `includeTraceSpans`: Include detailed execution trace spans -- `includeRateLimits`: Include the workflow owner's rate limit information -- `includeUsageData`: Include the workflow owner's usage and billing data -- `levelFilter`: Array of log levels to receive (`info`, `error`) -- `triggerFilter`: Array of trigger types to receive (`api`, `webhook`, `schedule`, `manual`, `chat`) -- `active`: Enable/disable the webhook subscription -### Webhook Payload +### Payload Structure -When a workflow execution completes, Sim sends a POST request to your webhook URL: +When a workflow execution completes, Sim sends the following payload (via webhook POST, email, or Slack): ```json { @@ -316,7 +362,7 @@ When a workflow execution completes, Sim sends a POST request to your webhook UR ### Webhook Headers -Each webhook request includes these headers: +Each webhook request includes these headers (webhook channel only): - `sim-event`: Event type (always `workflow.execution.completed`) - `sim-timestamp`: Unix timestamp in milliseconds diff --git a/apps/docs/content/docs/en/execution/logging.mdx b/apps/docs/content/docs/en/execution/logging.mdx index 364ae6fa3e0..1853a8be097 100644 --- a/apps/docs/content/docs/en/execution/logging.mdx +++ b/apps/docs/content/docs/en/execution/logging.mdx @@ -147,4 +147,4 @@ The snapshot provides: - Learn about [Cost Calculation](/execution/costs) to understand workflow pricing - Explore the [External API](/execution/api) for programmatic log access -- Set up [Webhook notifications](/execution/api#webhook-subscriptions) for real-time alerts \ No newline at end of file +- Set up [Notifications](/execution/api#notifications) for real-time alerts via webhook, email, or Slack \ No newline at end of file diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts new file mode 100644 index 00000000000..418a04c0271 --- /dev/null +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -0,0 +1,40 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('AuthAccountsAPI') + +export async function GET(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const provider = searchParams.get('provider') + + const whereConditions = [eq(account.userId, session.user.id)] + + if (provider) { + whereConditions.push(eq(account.providerId, provider)) + } + + const accounts = await db + .select({ + id: account.id, + accountId: account.accountId, + providerId: account.providerId, + }) + .from(account) + .where(and(...whereConditions)) + + return NextResponse.json({ accounts }) + } catch (error) { + logger.error('Failed to fetch accounts', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/notifications/poll/route.ts b/apps/sim/app/api/notifications/poll/route.ts new file mode 100644 index 00000000000..7b402559605 --- /dev/null +++ b/apps/sim/app/api/notifications/poll/route.ts @@ -0,0 +1,62 @@ +import { nanoid } from 'nanoid' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { createLogger } from '@/lib/logs/console/logger' +import { pollInactivityAlerts } from '@/lib/notifications/inactivity-polling' + +const logger = createLogger('InactivityAlertPoll') + +export const maxDuration = 120 + +const LOCK_KEY = 'inactivity-alert-polling-lock' +const LOCK_TTL_SECONDS = 120 + +export async function GET(request: NextRequest) { + const requestId = nanoid() + logger.info(`Inactivity alert polling triggered (${requestId})`) + + try { + const authError = verifyCronAuth(request, 'Inactivity alert polling') + if (authError) { + return authError + } + + const locked = await acquireLock(LOCK_KEY, requestId, LOCK_TTL_SECONDS) + + if (!locked) { + return NextResponse.json( + { + success: true, + message: 'Polling already in progress – skipped', + requestId, + status: 'skip', + }, + { status: 202 } + ) + } + + const results = await pollInactivityAlerts() + + return NextResponse.json({ + success: true, + message: 'Inactivity alert polling completed', + requestId, + status: 'completed', + ...results, + }) + } catch (error) { + logger.error(`Error during inactivity alert polling (${requestId}):`, error) + return NextResponse.json( + { + success: false, + message: 'Inactivity alert polling failed', + error: error instanceof Error ? error.message : 'Unknown error', + requestId, + }, + { status: 500 } + ) + } finally { + await releaseLock(LOCK_KEY).catch(() => {}) + } +} diff --git a/apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts b/apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts deleted file mode 100644 index a957f62c900..00000000000 --- a/apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { db } from '@sim/db' -import { permissions, workflow, workflowLogWebhook } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { encryptSecret } from '@/lib/core/security/encryption' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('WorkflowLogWebhookUpdate') - -type WebhookUpdatePayload = Pick< - typeof workflowLogWebhook.$inferInsert, - | 'url' - | 'includeFinalOutput' - | 'includeTraceSpans' - | 'includeRateLimits' - | 'includeUsageData' - | 'levelFilter' - | 'triggerFilter' - | 'secret' - | 'updatedAt' -> - -const UpdateWebhookSchema = z.object({ - url: z.string().url('Invalid webhook URL'), - secret: z.string().optional(), - includeFinalOutput: z.boolean(), - includeTraceSpans: z.boolean(), - includeRateLimits: z.boolean(), - includeUsageData: z.boolean(), - levelFilter: z.array(z.enum(['info', 'error'])), - triggerFilter: z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])), -}) - -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ id: string; webhookId: string }> } -) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workflowId, webhookId } = await params - const userId = session.user.id - - // Check if user has access to the workflow - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Check if webhook exists and belongs to this workflow - const existingWebhook = await db - .select() - .from(workflowLogWebhook) - .where( - and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) - ) - .limit(1) - - if (existingWebhook.length === 0) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - - const body = await request.json() - const validationResult = UpdateWebhookSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid request', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const data = validationResult.data - - // Check for duplicate URL (excluding current webhook) - const duplicateWebhook = await db - .select({ id: workflowLogWebhook.id }) - .from(workflowLogWebhook) - .where( - and(eq(workflowLogWebhook.workflowId, workflowId), eq(workflowLogWebhook.url, data.url)) - ) - .limit(1) - - if (duplicateWebhook.length > 0 && duplicateWebhook[0].id !== webhookId) { - return NextResponse.json( - { error: 'A webhook with this URL already exists for this workflow' }, - { status: 409 } - ) - } - - // Prepare update data - const updateData: WebhookUpdatePayload = { - url: data.url, - includeFinalOutput: data.includeFinalOutput, - includeTraceSpans: data.includeTraceSpans, - includeRateLimits: data.includeRateLimits, - includeUsageData: data.includeUsageData, - levelFilter: data.levelFilter, - triggerFilter: data.triggerFilter, - updatedAt: new Date(), - } - - // Only update secret if provided - if (data.secret) { - const { encrypted } = await encryptSecret(data.secret) - updateData.secret = encrypted - } - - const updatedWebhooks = await db - .update(workflowLogWebhook) - .set(updateData) - .where(eq(workflowLogWebhook.id, webhookId)) - .returning() - - if (updatedWebhooks.length === 0) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - - const updatedWebhook = updatedWebhooks[0] - - logger.info('Webhook updated', { - webhookId, - workflowId, - userId, - }) - - return NextResponse.json({ - data: { - id: updatedWebhook.id, - url: updatedWebhook.url, - includeFinalOutput: updatedWebhook.includeFinalOutput, - includeTraceSpans: updatedWebhook.includeTraceSpans, - includeRateLimits: updatedWebhook.includeRateLimits, - includeUsageData: updatedWebhook.includeUsageData, - levelFilter: updatedWebhook.levelFilter, - triggerFilter: updatedWebhook.triggerFilter, - active: updatedWebhook.active, - createdAt: updatedWebhook.createdAt.toISOString(), - updatedAt: updatedWebhook.updatedAt.toISOString(), - }, - }) - } catch (error) { - logger.error('Failed to update webhook', { error }) - return NextResponse.json({ error: 'Failed to update webhook' }, { status: 500 }) - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string; webhookId: string }> } -) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workflowId, webhookId } = await params - const userId = session.user.id - - // Check if user has access to the workflow - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Delete the webhook (will cascade delete deliveries) - const deletedWebhook = await db - .delete(workflowLogWebhook) - .where( - and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) - ) - .returning() - - if (deletedWebhook.length === 0) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - - logger.info('Webhook deleted', { - webhookId, - workflowId, - userId, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to delete webhook', { error }) - return NextResponse.json({ error: 'Failed to delete webhook' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workflows/[id]/log-webhook/route.ts b/apps/sim/app/api/workflows/[id]/log-webhook/route.ts deleted file mode 100644 index 51f5bfbbd7e..00000000000 --- a/apps/sim/app/api/workflows/[id]/log-webhook/route.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { db } from '@sim/db' -import { permissions, workflow, workflowLogWebhook } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { encryptSecret } from '@/lib/core/security/encryption' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('WorkflowLogWebhookAPI') - -const CreateWebhookSchema = z.object({ - url: z.string().url(), - secret: z.string().optional(), - includeFinalOutput: z.boolean().optional().default(false), - includeTraceSpans: z.boolean().optional().default(false), - includeRateLimits: z.boolean().optional().default(false), - includeUsageData: z.boolean().optional().default(false), - levelFilter: z - .array(z.enum(['info', 'error'])) - .optional() - .default(['info', 'error']), - triggerFilter: z - .array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])) - .optional() - .default(['api', 'webhook', 'schedule', 'manual', 'chat']), - active: z.boolean().optional().default(true), -}) - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workflowId } = await params - const userId = session.user.id - - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const webhooks = await db - .select({ - id: workflowLogWebhook.id, - url: workflowLogWebhook.url, - includeFinalOutput: workflowLogWebhook.includeFinalOutput, - includeTraceSpans: workflowLogWebhook.includeTraceSpans, - includeRateLimits: workflowLogWebhook.includeRateLimits, - includeUsageData: workflowLogWebhook.includeUsageData, - levelFilter: workflowLogWebhook.levelFilter, - triggerFilter: workflowLogWebhook.triggerFilter, - active: workflowLogWebhook.active, - createdAt: workflowLogWebhook.createdAt, - updatedAt: workflowLogWebhook.updatedAt, - }) - .from(workflowLogWebhook) - .where(eq(workflowLogWebhook.workflowId, workflowId)) - - return NextResponse.json({ data: webhooks }) - } catch (error) { - logger.error('Error fetching log webhooks', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workflowId } = await params - const userId = session.user.id - - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const body = await request.json() - const validationResult = CreateWebhookSchema.safeParse(body) - - if (!validationResult.success) { - return NextResponse.json( - { error: 'Invalid request', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const data = validationResult.data - - // Check for duplicate URL - const existingWebhook = await db - .select({ id: workflowLogWebhook.id }) - .from(workflowLogWebhook) - .where( - and(eq(workflowLogWebhook.workflowId, workflowId), eq(workflowLogWebhook.url, data.url)) - ) - .limit(1) - - if (existingWebhook.length > 0) { - return NextResponse.json( - { error: 'A webhook with this URL already exists for this workflow' }, - { status: 409 } - ) - } - - let encryptedSecret: string | null = null - - if (data.secret) { - const { encrypted } = await encryptSecret(data.secret) - encryptedSecret = encrypted - } - - const [webhook] = await db - .insert(workflowLogWebhook) - .values({ - id: uuidv4(), - workflowId, - url: data.url, - secret: encryptedSecret, - includeFinalOutput: data.includeFinalOutput, - includeTraceSpans: data.includeTraceSpans, - includeRateLimits: data.includeRateLimits, - includeUsageData: data.includeUsageData, - levelFilter: data.levelFilter, - triggerFilter: data.triggerFilter, - active: data.active, - }) - .returning() - - logger.info('Created log webhook', { - workflowId, - webhookId: webhook.id, - url: data.url, - }) - - return NextResponse.json({ - data: { - id: webhook.id, - url: webhook.url, - includeFinalOutput: webhook.includeFinalOutput, - includeTraceSpans: webhook.includeTraceSpans, - includeRateLimits: webhook.includeRateLimits, - includeUsageData: webhook.includeUsageData, - levelFilter: webhook.levelFilter, - triggerFilter: webhook.triggerFilter, - active: webhook.active, - createdAt: webhook.createdAt, - updatedAt: webhook.updatedAt, - }, - }) - } catch (error) { - logger.error('Error creating log webhook', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workflowId } = await params - const userId = session.user.id - const { searchParams } = new URL(request.url) - const webhookId = searchParams.get('webhookId') - - if (!webhookId) { - return NextResponse.json({ error: 'webhookId is required' }, { status: 400 }) - } - - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const deleted = await db - .delete(workflowLogWebhook) - .where( - and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) - ) - .returning({ id: workflowLogWebhook.id }) - - if (deleted.length === 0) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - - logger.info('Deleted log webhook', { - workflowId, - webhookId, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting log webhook', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts b/apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts deleted file mode 100644 index 44d93521c00..00000000000 --- a/apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { createHmac } from 'crypto' -import { db } from '@sim/db' -import { permissions, workflow, workflowLogWebhook } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' -import { getSession } from '@/lib/auth' -import { decryptSecret } from '@/lib/core/security/encryption' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('WorkflowLogWebhookTestAPI') - -function generateSignature(secret: string, timestamp: number, body: string): string { - const signatureBase = `${timestamp}.${body}` - const hmac = createHmac('sha256', secret) - hmac.update(signatureBase) - return hmac.digest('hex') -} - -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: workflowId } = await params - const userId = session.user.id - const { searchParams } = new URL(request.url) - const webhookId = searchParams.get('webhookId') - - if (!webhookId) { - return NextResponse.json({ error: 'webhookId is required' }, { status: 400 }) - } - - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const [webhook] = await db - .select() - .from(workflowLogWebhook) - .where( - and(eq(workflowLogWebhook.id, webhookId), eq(workflowLogWebhook.workflowId, workflowId)) - ) - .limit(1) - - if (!webhook) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - - const timestamp = Date.now() - const eventId = `evt_test_${uuidv4()}` - const executionId = `exec_test_${uuidv4()}` - const logId = `log_test_${uuidv4()}` - - const payload = { - id: eventId, - type: 'workflow.execution.completed', - timestamp, - data: { - workflowId, - executionId, - status: 'success', - level: 'info', - trigger: 'manual', - startedAt: new Date(timestamp - 5000).toISOString(), - endedAt: new Date(timestamp).toISOString(), - totalDurationMs: 5000, - cost: { - total: 0.00123, - tokens: { prompt: 100, completion: 50, total: 150 }, - models: { - 'gpt-4o': { - input: 0.001, - output: 0.00023, - total: 0.00123, - tokens: { prompt: 100, completion: 50, total: 150 }, - }, - }, - }, - files: null, - }, - links: { - log: `/v1/logs/${logId}`, - execution: `/v1/logs/executions/${executionId}`, - }, - } - - if (webhook.includeFinalOutput) { - ;(payload.data as any).finalOutput = { - message: 'This is a test webhook delivery', - test: true, - } - } - - if (webhook.includeTraceSpans) { - ;(payload.data as any).traceSpans = [ - { - id: 'span_test_1', - name: 'Test Block', - type: 'block', - status: 'success', - startTime: new Date(timestamp - 5000).toISOString(), - endTime: new Date(timestamp).toISOString(), - duration: 5000, - }, - ] - } - - if (webhook.includeRateLimits) { - ;(payload.data as any).rateLimits = { - sync: { - limit: 150, - remaining: 45, - resetAt: new Date(timestamp + 60000).toISOString(), - }, - async: { - limit: 1000, - remaining: 50, - resetAt: new Date(timestamp + 60000).toISOString(), - }, - } - } - - if (webhook.includeUsageData) { - ;(payload.data as any).usage = { - currentPeriodCost: 2.45, - limit: 10, - plan: 'pro', - isExceeded: false, - } - } - - const body = JSON.stringify(payload) - const deliveryId = `delivery_test_${uuidv4()}` - const headers: Record = { - 'Content-Type': 'application/json', - 'sim-event': 'workflow.execution.completed', - 'sim-timestamp': timestamp.toString(), - 'sim-delivery-id': deliveryId, - 'Idempotency-Key': deliveryId, - } - - if (webhook.secret) { - const { decrypted } = await decryptSecret(webhook.secret) - const signature = generateSignature(decrypted, timestamp, body) - headers['sim-signature'] = `t=${timestamp},v1=${signature}` - } - - logger.info(`Sending test webhook to ${webhook.url}`, { workflowId, webhookId }) - - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 10000) - - try { - const response = await fetch(webhook.url, { - method: 'POST', - headers, - body, - signal: controller.signal, - }) - - clearTimeout(timeoutId) - - const responseBody = await response.text().catch(() => '') - const truncatedBody = responseBody.slice(0, 500) - - const result = { - success: response.ok, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - body: truncatedBody, - timestamp: new Date().toISOString(), - } - - logger.info(`Test webhook completed`, { - workflowId, - webhookId, - status: response.status, - success: response.ok, - }) - - return NextResponse.json({ data: result }) - } catch (error: any) { - clearTimeout(timeoutId) - - if (error.name === 'AbortError') { - logger.error(`Test webhook timed out`, { workflowId, webhookId }) - return NextResponse.json({ - data: { - success: false, - error: 'Request timeout after 10 seconds', - timestamp: new Date().toISOString(), - }, - }) - } - - logger.error(`Test webhook failed`, { - workflowId, - webhookId, - error: error.message, - }) - - return NextResponse.json({ - data: { - success: false, - error: error.message, - timestamp: new Date().toISOString(), - }, - }) - } - } catch (error) { - logger.error('Error testing webhook', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts new file mode 100644 index 00000000000..a7bca617d18 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -0,0 +1,318 @@ +import { db } from '@sim/db' +import { workflow, workspaceNotificationSubscription } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/core/security/encryption' +import { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants' + +const logger = createLogger('WorkspaceNotificationAPI') + +const levelFilterSchema = z.array(z.enum(['info', 'error'])) +const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])) + +const alertRuleSchema = z.enum([ + 'consecutive_failures', + 'failure_rate', + 'latency_threshold', + 'latency_spike', + 'cost_threshold', + 'no_activity', + 'error_count', +]) + +const alertConfigSchema = z + .object({ + rule: alertRuleSchema, + consecutiveFailures: z.number().int().min(1).max(100).optional(), + failureRatePercent: z.number().int().min(1).max(100).optional(), + windowHours: z.number().int().min(1).max(168).optional(), + durationThresholdMs: z.number().int().min(1000).max(3600000).optional(), + latencySpikePercent: z.number().int().min(10).max(1000).optional(), + costThresholdDollars: z.number().min(0.01).max(1000).optional(), + inactivityHours: z.number().int().min(1).max(168).optional(), + errorCountThreshold: z.number().int().min(1).max(1000).optional(), + }) + .refine( + (data) => { + switch (data.rule) { + case 'consecutive_failures': + return data.consecutiveFailures !== undefined + case 'failure_rate': + return data.failureRatePercent !== undefined && data.windowHours !== undefined + case 'latency_threshold': + return data.durationThresholdMs !== undefined + case 'latency_spike': + return data.latencySpikePercent !== undefined && data.windowHours !== undefined + case 'cost_threshold': + return data.costThresholdDollars !== undefined + case 'no_activity': + return data.inactivityHours !== undefined + case 'error_count': + return data.errorCountThreshold !== undefined && data.windowHours !== undefined + default: + return false + } + }, + { message: 'Missing required fields for alert rule' } + ) + .nullable() + +const webhookConfigSchema = z.object({ + url: z.string().url(), + secret: z.string().optional(), +}) + +const slackConfigSchema = z.object({ + channelId: z.string(), + channelName: z.string(), + accountId: z.string(), +}) + +const updateNotificationSchema = z + .object({ + workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).optional(), + allWorkflows: z.boolean().optional(), + levelFilter: levelFilterSchema.optional(), + triggerFilter: triggerFilterSchema.optional(), + includeFinalOutput: z.boolean().optional(), + includeTraceSpans: z.boolean().optional(), + includeRateLimits: z.boolean().optional(), + includeUsageData: z.boolean().optional(), + alertConfig: alertConfigSchema.optional(), + webhookConfig: webhookConfigSchema.optional(), + emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(), + slackConfig: slackConfigSchema.optional(), + active: z.boolean().optional(), + }) + .refine((data) => !(data.allWorkflows && data.workflowIds && data.workflowIds.length > 0), { + message: 'Cannot specify both allWorkflows and workflowIds', + }) + +type RouteParams = { params: Promise<{ id: string; notificationId: string }> } + +async function checkWorkspaceWriteAccess( + userId: string, + workspaceId: string +): Promise<{ hasAccess: boolean; permission: string | null }> { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + const hasAccess = permission === 'write' || permission === 'admin' + return { hasAccess, permission } +} + +async function getSubscription(notificationId: string, workspaceId: string) { + const [subscription] = await db + .select() + .from(workspaceNotificationSubscription) + .where( + and( + eq(workspaceNotificationSubscription.id, notificationId), + eq(workspaceNotificationSubscription.workspaceId, workspaceId) + ) + ) + .limit(1) + return subscription +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workspaceId, notificationId } = await params + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const subscription = await getSubscription(notificationId, workspaceId) + + if (!subscription) { + return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) + } + + return NextResponse.json({ + data: { + id: subscription.id, + notificationType: subscription.notificationType, + workflowIds: subscription.workflowIds, + allWorkflows: subscription.allWorkflows, + levelFilter: subscription.levelFilter, + triggerFilter: subscription.triggerFilter, + includeFinalOutput: subscription.includeFinalOutput, + includeTraceSpans: subscription.includeTraceSpans, + includeRateLimits: subscription.includeRateLimits, + includeUsageData: subscription.includeUsageData, + webhookConfig: subscription.webhookConfig, + emailRecipients: subscription.emailRecipients, + slackConfig: subscription.slackConfig, + alertConfig: subscription.alertConfig, + active: subscription.active, + createdAt: subscription.createdAt, + updatedAt: subscription.updatedAt, + }, + }) + } catch (error) { + logger.error('Error fetching notification', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function PUT(request: NextRequest, { params }: RouteParams) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workspaceId, notificationId } = await params + const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) + + if (!hasAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const existingSubscription = await getSubscription(notificationId, workspaceId) + + if (!existingSubscription) { + return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) + } + + const body = await request.json() + const validationResult = updateNotificationSchema.safeParse(body) + + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid request', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const data = validationResult.data + + if (data.workflowIds && data.workflowIds.length > 0) { + const workflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), inArray(workflow.id, data.workflowIds))) + + const validIds = new Set(workflowsInWorkspace.map((w) => w.id)) + const invalidIds = data.workflowIds.filter((id) => !validIds.has(id)) + + if (invalidIds.length > 0) { + return NextResponse.json( + { error: 'Some workflow IDs do not belong to this workspace', invalidIds }, + { status: 400 } + ) + } + } + + const updateData: Record = { updatedAt: new Date() } + + if (data.workflowIds !== undefined) updateData.workflowIds = data.workflowIds + if (data.allWorkflows !== undefined) updateData.allWorkflows = data.allWorkflows + if (data.levelFilter !== undefined) updateData.levelFilter = data.levelFilter + if (data.triggerFilter !== undefined) updateData.triggerFilter = data.triggerFilter + if (data.includeFinalOutput !== undefined) + updateData.includeFinalOutput = data.includeFinalOutput + if (data.includeTraceSpans !== undefined) updateData.includeTraceSpans = data.includeTraceSpans + if (data.includeRateLimits !== undefined) updateData.includeRateLimits = data.includeRateLimits + if (data.includeUsageData !== undefined) updateData.includeUsageData = data.includeUsageData + if (data.alertConfig !== undefined) updateData.alertConfig = data.alertConfig + if (data.emailRecipients !== undefined) updateData.emailRecipients = data.emailRecipients + if (data.slackConfig !== undefined) updateData.slackConfig = data.slackConfig + if (data.active !== undefined) updateData.active = data.active + + // Handle webhookConfig with secret encryption + if (data.webhookConfig !== undefined) { + let webhookConfig = data.webhookConfig + if (webhookConfig?.secret) { + const { encrypted } = await encryptSecret(webhookConfig.secret) + webhookConfig = { ...webhookConfig, secret: encrypted } + } + updateData.webhookConfig = webhookConfig + } + + const [subscription] = await db + .update(workspaceNotificationSubscription) + .set(updateData) + .where(eq(workspaceNotificationSubscription.id, notificationId)) + .returning() + + logger.info('Updated notification subscription', { + workspaceId, + subscriptionId: subscription.id, + }) + + return NextResponse.json({ + data: { + id: subscription.id, + notificationType: subscription.notificationType, + workflowIds: subscription.workflowIds, + allWorkflows: subscription.allWorkflows, + levelFilter: subscription.levelFilter, + triggerFilter: subscription.triggerFilter, + includeFinalOutput: subscription.includeFinalOutput, + includeTraceSpans: subscription.includeTraceSpans, + includeRateLimits: subscription.includeRateLimits, + includeUsageData: subscription.includeUsageData, + webhookConfig: subscription.webhookConfig, + emailRecipients: subscription.emailRecipients, + slackConfig: subscription.slackConfig, + alertConfig: subscription.alertConfig, + active: subscription.active, + createdAt: subscription.createdAt, + updatedAt: subscription.updatedAt, + }, + }) + } catch (error) { + logger.error('Error updating notification', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workspaceId, notificationId } = await params + const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) + + if (!hasAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const deleted = await db + .delete(workspaceNotificationSubscription) + .where( + and( + eq(workspaceNotificationSubscription.id, notificationId), + eq(workspaceNotificationSubscription.workspaceId, workspaceId) + ) + ) + .returning({ id: workspaceNotificationSubscription.id }) + + if (deleted.length === 0) { + return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) + } + + logger.info('Deleted notification subscription', { + workspaceId, + subscriptionId: notificationId, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error('Error deleting notification', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts new file mode 100644 index 00000000000..ab387c4245f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -0,0 +1,319 @@ +import { createHmac } from 'crypto' +import { db } from '@sim/db' +import { account, workspaceNotificationSubscription } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { getSession } from '@/lib/auth' +import { decryptSecret } from '@/lib/core/security/encryption' +import { createLogger } from '@/lib/logs/console/logger' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('WorkspaceNotificationTestAPI') + +type RouteParams = { params: Promise<{ id: string; notificationId: string }> } + +interface WebhookConfig { + url: string + secret?: string +} + +interface SlackConfig { + channelId: string + channelName: string + accountId: string +} + +function generateSignature(secret: string, timestamp: number, body: string): string { + const signatureBase = `${timestamp}.${body}` + const hmac = createHmac('sha256', secret) + hmac.update(signatureBase) + return hmac.digest('hex') +} + +function buildTestPayload(subscription: typeof workspaceNotificationSubscription.$inferSelect) { + const timestamp = Date.now() + const eventId = `evt_test_${uuidv4()}` + const executionId = `exec_test_${uuidv4()}` + + const payload: Record = { + id: eventId, + type: 'workflow.execution.completed', + timestamp, + data: { + workflowId: 'test-workflow-id', + workflowName: 'Test Workflow', + executionId, + status: 'success', + level: 'info', + trigger: 'manual', + startedAt: new Date(timestamp - 5000).toISOString(), + endedAt: new Date(timestamp).toISOString(), + totalDurationMs: 5000, + cost: { + total: 0.00123, + tokens: { prompt: 100, completion: 50, total: 150 }, + }, + }, + links: { + log: `/workspace/logs`, + }, + } + + const data = payload.data as Record + + if (subscription.includeFinalOutput) { + data.finalOutput = { message: 'This is a test notification', test: true } + } + + if (subscription.includeTraceSpans) { + data.traceSpans = [ + { + id: 'span_test_1', + name: 'Test Block', + type: 'block', + status: 'success', + startTime: new Date(timestamp - 5000).toISOString(), + endTime: new Date(timestamp).toISOString(), + duration: 5000, + }, + ] + } + + if (subscription.includeRateLimits) { + data.rateLimits = { + sync: { limit: 150, remaining: 45, resetAt: new Date(timestamp + 60000).toISOString() }, + async: { limit: 1000, remaining: 50, resetAt: new Date(timestamp + 60000).toISOString() }, + } + } + + if (subscription.includeUsageData) { + data.usage = { currentPeriodCost: 2.45, limit: 10, plan: 'pro', isExceeded: false } + } + + return { payload, timestamp } +} + +async function testWebhook(subscription: typeof workspaceNotificationSubscription.$inferSelect) { + const webhookConfig = subscription.webhookConfig as WebhookConfig | null + if (!webhookConfig?.url) { + return { success: false, error: 'No webhook URL configured' } + } + + const { payload, timestamp } = buildTestPayload(subscription) + const body = JSON.stringify(payload) + const deliveryId = `delivery_test_${uuidv4()}` + + const headers: Record = { + 'Content-Type': 'application/json', + 'sim-event': 'workflow.execution.completed', + 'sim-timestamp': timestamp.toString(), + 'sim-delivery-id': deliveryId, + 'Idempotency-Key': deliveryId, + } + + if (webhookConfig.secret) { + const { decrypted } = await decryptSecret(webhookConfig.secret) + const signature = generateSignature(decrypted, timestamp, body) + headers['sim-signature'] = `t=${timestamp},v1=${signature}` + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) + + try { + const response = await fetch(webhookConfig.url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + const responseBody = await response.text().catch(() => '') + + return { + success: response.ok, + status: response.status, + statusText: response.statusText, + body: responseBody.slice(0, 500), + timestamp: new Date().toISOString(), + } + } catch (error: unknown) { + clearTimeout(timeoutId) + const err = error as Error & { name?: string } + if (err.name === 'AbortError') { + return { success: false, error: 'Request timeout after 10 seconds' } + } + return { success: false, error: err.message } + } +} + +async function testEmail(subscription: typeof workspaceNotificationSubscription.$inferSelect) { + if (!subscription.emailRecipients || subscription.emailRecipients.length === 0) { + return { success: false, error: 'No email recipients configured' } + } + + const { payload } = buildTestPayload(subscription) + const data = (payload as Record).data as Record + + const result = await sendEmail({ + to: subscription.emailRecipients, + subject: `[Test] Workflow Execution: ${data.workflowName}`, + text: `This is a test notification from Sim Studio.\n\nWorkflow: ${data.workflowName}\nStatus: ${data.status}\nDuration: ${data.totalDurationMs}ms\n\nThis notification is configured for workspace notifications.`, + html: ` +
+

Test Notification

+

This is a test notification from Sim Studio.

+ + + + +
Workflow${data.workflowName}
Status${data.status}
Duration${data.totalDurationMs}ms
+

This notification is configured for workspace notifications.

+
+ `, + emailType: 'notifications', + }) + + return { + success: result.success, + message: result.message, + timestamp: new Date().toISOString(), + } +} + +async function testSlack( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + userId: string +) { + const slackConfig = subscription.slackConfig as SlackConfig | null + if (!slackConfig?.channelId || !slackConfig?.accountId) { + return { success: false, error: 'No Slack channel or account configured' } + } + + const [slackAccount] = await db + .select({ accessToken: account.accessToken }) + .from(account) + .where(and(eq(account.id, slackConfig.accountId), eq(account.userId, userId))) + .limit(1) + + if (!slackAccount?.accessToken) { + return { success: false, error: 'Slack account not found or not connected' } + } + + const { payload } = buildTestPayload(subscription) + const data = (payload as Record).data as Record + + const slackPayload = { + channel: slackConfig.channelId, + blocks: [ + { + type: 'header', + text: { type: 'plain_text', text: '🧪 Test Notification', emoji: true }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Workflow:*\n${data.workflowName}` }, + { type: 'mrkdwn', text: `*Status:*\n✅ ${data.status}` }, + { type: 'mrkdwn', text: `*Duration:*\n${data.totalDurationMs}ms` }, + { type: 'mrkdwn', text: `*Trigger:*\n${data.trigger}` }, + ], + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: 'This is a test notification from Sim Studio workspace notifications.', + }, + ], + }, + ], + text: `Test notification: ${data.workflowName} - ${data.status}`, + } + + try { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${slackAccount.accessToken}`, + }, + body: JSON.stringify(slackPayload), + }) + + const result = await response.json() + + return { + success: result.ok, + error: result.error, + channel: result.channel, + timestamp: new Date().toISOString(), + } + } catch (error: unknown) { + const err = error as Error + return { success: false, error: err.message } + } +} + +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workspaceId, notificationId } = await params + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + if (permission !== 'write' && permission !== 'admin') { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const [subscription] = await db + .select() + .from(workspaceNotificationSubscription) + .where( + and( + eq(workspaceNotificationSubscription.id, notificationId), + eq(workspaceNotificationSubscription.workspaceId, workspaceId) + ) + ) + .limit(1) + + if (!subscription) { + return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) + } + + let result: Record + + switch (subscription.notificationType) { + case 'webhook': + result = await testWebhook(subscription) + break + case 'email': + result = await testEmail(subscription) + break + case 'slack': + result = await testSlack(subscription, session.user.id) + break + default: + return NextResponse.json({ error: 'Unknown notification type' }, { status: 400 }) + } + + logger.info('Test notification sent', { + workspaceId, + subscriptionId: notificationId, + type: subscription.notificationType, + success: result.success, + }) + + return NextResponse.json({ data: result }) + } catch (error) { + logger.error('Error testing notification', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/workspaces/[id]/notifications/constants.ts b/apps/sim/app/api/workspaces/[id]/notifications/constants.ts new file mode 100644 index 00000000000..036f32a5346 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/notifications/constants.ts @@ -0,0 +1,8 @@ +/** Maximum email recipients per notification */ +export const MAX_EMAIL_RECIPIENTS = 10 + +/** Maximum notifications per type per workspace */ +export const MAX_NOTIFICATIONS_PER_TYPE = 10 + +/** Maximum workflow IDs per notification */ +export const MAX_WORKFLOW_IDS = 1000 diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts new file mode 100644 index 00000000000..9eb99ed5feb --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -0,0 +1,284 @@ +import { db } from '@sim/db' +import { workflow, workspaceNotificationSubscription } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { encryptSecret } from '@/lib/core/security/encryption' +import { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants' + +const logger = createLogger('WorkspaceNotificationsAPI') + +const notificationTypeSchema = z.enum(['webhook', 'email', 'slack']) +const levelFilterSchema = z.array(z.enum(['info', 'error'])) +const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])) + +const alertRuleSchema = z.enum([ + 'consecutive_failures', + 'failure_rate', + 'latency_threshold', + 'latency_spike', + 'cost_threshold', + 'no_activity', + 'error_count', +]) + +const alertConfigSchema = z + .object({ + rule: alertRuleSchema, + consecutiveFailures: z.number().int().min(1).max(100).optional(), + failureRatePercent: z.number().int().min(1).max(100).optional(), + windowHours: z.number().int().min(1).max(168).optional(), + durationThresholdMs: z.number().int().min(1000).max(3600000).optional(), + latencySpikePercent: z.number().int().min(10).max(1000).optional(), + costThresholdDollars: z.number().min(0.01).max(1000).optional(), + inactivityHours: z.number().int().min(1).max(168).optional(), + errorCountThreshold: z.number().int().min(1).max(1000).optional(), + }) + .refine( + (data) => { + switch (data.rule) { + case 'consecutive_failures': + return data.consecutiveFailures !== undefined + case 'failure_rate': + return data.failureRatePercent !== undefined && data.windowHours !== undefined + case 'latency_threshold': + return data.durationThresholdMs !== undefined + case 'latency_spike': + return data.latencySpikePercent !== undefined && data.windowHours !== undefined + case 'cost_threshold': + return data.costThresholdDollars !== undefined + case 'no_activity': + return data.inactivityHours !== undefined + case 'error_count': + return data.errorCountThreshold !== undefined && data.windowHours !== undefined + default: + return false + } + }, + { message: 'Missing required fields for alert rule' } + ) + .nullable() + +const webhookConfigSchema = z.object({ + url: z.string().url(), + secret: z.string().optional(), +}) + +const slackConfigSchema = z.object({ + channelId: z.string(), + channelName: z.string(), + accountId: z.string(), +}) + +const createNotificationSchema = z + .object({ + notificationType: notificationTypeSchema, + workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).default([]), + allWorkflows: z.boolean().default(false), + levelFilter: levelFilterSchema.default(['info', 'error']), + triggerFilter: triggerFilterSchema.default(['api', 'webhook', 'schedule', 'manual', 'chat']), + includeFinalOutput: z.boolean().default(false), + includeTraceSpans: z.boolean().default(false), + includeRateLimits: z.boolean().default(false), + includeUsageData: z.boolean().default(false), + alertConfig: alertConfigSchema.optional(), + webhookConfig: webhookConfigSchema.optional(), + emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(), + slackConfig: slackConfigSchema.optional(), + }) + .refine( + (data) => { + if (data.notificationType === 'webhook') return !!data.webhookConfig?.url + if (data.notificationType === 'email') + return !!data.emailRecipients && data.emailRecipients.length > 0 + if (data.notificationType === 'slack') + return !!data.slackConfig?.channelId && !!data.slackConfig?.accountId + return false + }, + { message: 'Missing required fields for notification type' } + ) + .refine((data) => !(data.allWorkflows && data.workflowIds.length > 0), { + message: 'Cannot specify both allWorkflows and workflowIds', + }) + +async function checkWorkspaceWriteAccess( + userId: string, + workspaceId: string +): Promise<{ hasAccess: boolean; permission: string | null }> { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + const hasAccess = permission === 'write' || permission === 'admin' + return { hasAccess, permission } +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workspaceId } = await params + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + + if (!permission) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + const subscriptions = await db + .select({ + id: workspaceNotificationSubscription.id, + notificationType: workspaceNotificationSubscription.notificationType, + workflowIds: workspaceNotificationSubscription.workflowIds, + allWorkflows: workspaceNotificationSubscription.allWorkflows, + levelFilter: workspaceNotificationSubscription.levelFilter, + triggerFilter: workspaceNotificationSubscription.triggerFilter, + includeFinalOutput: workspaceNotificationSubscription.includeFinalOutput, + includeTraceSpans: workspaceNotificationSubscription.includeTraceSpans, + includeRateLimits: workspaceNotificationSubscription.includeRateLimits, + includeUsageData: workspaceNotificationSubscription.includeUsageData, + webhookConfig: workspaceNotificationSubscription.webhookConfig, + emailRecipients: workspaceNotificationSubscription.emailRecipients, + slackConfig: workspaceNotificationSubscription.slackConfig, + alertConfig: workspaceNotificationSubscription.alertConfig, + active: workspaceNotificationSubscription.active, + createdAt: workspaceNotificationSubscription.createdAt, + updatedAt: workspaceNotificationSubscription.updatedAt, + }) + .from(workspaceNotificationSubscription) + .where(eq(workspaceNotificationSubscription.workspaceId, workspaceId)) + .orderBy(workspaceNotificationSubscription.createdAt) + + return NextResponse.json({ data: subscriptions }) + } catch (error) { + logger.error('Error fetching notifications', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id: workspaceId } = await params + const { hasAccess } = await checkWorkspaceWriteAccess(session.user.id, workspaceId) + + if (!hasAccess) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const body = await request.json() + const validationResult = createNotificationSchema.safeParse(body) + + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid request', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const data = validationResult.data + + const existingCount = await db + .select({ id: workspaceNotificationSubscription.id }) + .from(workspaceNotificationSubscription) + .where( + and( + eq(workspaceNotificationSubscription.workspaceId, workspaceId), + eq(workspaceNotificationSubscription.notificationType, data.notificationType) + ) + ) + + if (existingCount.length >= MAX_NOTIFICATIONS_PER_TYPE) { + return NextResponse.json( + { + error: `Maximum ${MAX_NOTIFICATIONS_PER_TYPE} ${data.notificationType} notifications per workspace`, + }, + { status: 400 } + ) + } + + if (!data.allWorkflows && data.workflowIds.length > 0) { + const workflowsInWorkspace = await db + .select({ id: workflow.id }) + .from(workflow) + .where(and(eq(workflow.workspaceId, workspaceId), inArray(workflow.id, data.workflowIds))) + + const validIds = new Set(workflowsInWorkspace.map((w) => w.id)) + const invalidIds = data.workflowIds.filter((id) => !validIds.has(id)) + + if (invalidIds.length > 0) { + return NextResponse.json( + { error: 'Some workflow IDs do not belong to this workspace', invalidIds }, + { status: 400 } + ) + } + } + + // Encrypt webhook secret if provided + let webhookConfig = data.webhookConfig || null + if (webhookConfig?.secret) { + const { encrypted } = await encryptSecret(webhookConfig.secret) + webhookConfig = { ...webhookConfig, secret: encrypted } + } + + const [subscription] = await db + .insert(workspaceNotificationSubscription) + .values({ + id: uuidv4(), + workspaceId, + notificationType: data.notificationType, + workflowIds: data.workflowIds, + allWorkflows: data.allWorkflows, + levelFilter: data.levelFilter, + triggerFilter: data.triggerFilter, + includeFinalOutput: data.includeFinalOutput, + includeTraceSpans: data.includeTraceSpans, + includeRateLimits: data.includeRateLimits, + includeUsageData: data.includeUsageData, + alertConfig: data.alertConfig || null, + webhookConfig, + emailRecipients: data.emailRecipients || null, + slackConfig: data.slackConfig || null, + createdBy: session.user.id, + }) + .returning() + + logger.info('Created notification subscription', { + workspaceId, + subscriptionId: subscription.id, + type: data.notificationType, + }) + + return NextResponse.json({ + data: { + id: subscription.id, + notificationType: subscription.notificationType, + workflowIds: subscription.workflowIds, + allWorkflows: subscription.allWorkflows, + levelFilter: subscription.levelFilter, + triggerFilter: subscription.triggerFilter, + includeFinalOutput: subscription.includeFinalOutput, + includeTraceSpans: subscription.includeTraceSpans, + includeRateLimits: subscription.includeRateLimits, + includeUsageData: subscription.includeUsageData, + webhookConfig: subscription.webhookConfig, + emailRecipients: subscription.emailRecipients, + slackConfig: subscription.slackConfig, + alertConfig: subscription.alertConfig, + active: subscription.active, + createdAt: subscription.createdAt, + updatedAt: subscription.updatedAt, + }, + }) + } catch (error) { + logger.error('Error creating notification', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx index 7456240e0be..a32f727b91b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/controls.tsx @@ -1,23 +1,21 @@ import type { ReactNode } from 'react' -import { ArrowUp, Loader2, RefreshCw, Search } from 'lucide-react' -import { Button, Tooltip } from '@/components/emcn' +import { ArrowUp, Bell, Loader2, RefreshCw, Search } from 'lucide-react' +import { + Button, + Popover, + PopoverContent, + PopoverItem, + PopoverScrollArea, + PopoverTrigger, + Tooltip, +} from '@/components/emcn' +import { MoreHorizontal } from '@/components/emcn/icons' import { Input } from '@/components/ui/input' import { cn } from '@/lib/core/utils/cn' import { soehne } from '@/app/_styles/fonts/soehne/soehne' import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline' -export function Controls({ - searchQuery, - setSearchQuery, - isRefetching, - resetToNow, - live, - setLive, - viewMode, - setViewMode, - searchComponent, - onExport, -}: { +interface ControlsProps { searchQuery?: string setSearchQuery?: (v: string) => void isRefetching: boolean @@ -29,7 +27,24 @@ export function Controls({ searchComponent?: ReactNode showExport?: boolean onExport?: () => void -}) { + canConfigureNotifications?: boolean + onConfigureNotifications?: () => void +} + +export function Controls({ + searchQuery, + setSearchQuery, + isRefetching, + resetToNow, + live, + setLive, + viewMode, + setViewMode, + searchComponent, + onExport, + canConfigureNotifications, + onConfigureNotifications, +}: ControlsProps) { return (
{viewMode !== 'dashboard' && ( - - - - - Export CSV - + + + + + + Export as CSV + + + + Configure Notifications + + + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/index.ts new file mode 100644 index 00000000000..dcbd03e572f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/index.ts @@ -0,0 +1,2 @@ +export { NotificationSettings } from './notification-settings' +export { WorkflowSelector } from './workflow-selector' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/notification-settings.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/notification-settings.tsx new file mode 100644 index 00000000000..70797a98815 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/notification-settings.tsx @@ -0,0 +1,1320 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { AlertCircle, Check, Pencil, Play, Trash2, X } from 'lucide-react' +import { + Button, + Combobox, + Input, + Label, + Modal, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, + Switch, + Tooltip, +} from '@/components/emcn' +import { + ModalBody, + ModalTabs, + ModalTabsContent, + ModalTabsList, + ModalTabsTrigger, +} from '@/components/emcn/components/modal/modal' +import { Skeleton } from '@/components/ui' +import { createLogger } from '@/lib/logs/console/logger' +import { + type NotificationSubscription, + useCreateNotification, + useDeleteNotification, + useNotifications, + useTestNotification, + useToggleNotificationActive, + useUpdateNotification, +} from '@/hooks/queries/notifications' +import { useConnectOAuthService } from '@/hooks/queries/oauth-connections' +import { useSlackAccounts } from '@/hooks/use-slack-accounts' +import { SlackChannelSelector } from './slack-channel-selector' +import { WorkflowSelector } from './workflow-selector' + +const logger = createLogger('NotificationSettings') + +type NotificationType = 'webhook' | 'email' | 'slack' +type LogLevel = 'info' | 'error' +type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' +type AlertRule = + | 'consecutive_failures' + | 'failure_rate' + | 'latency_threshold' + | 'latency_spike' + | 'cost_threshold' + | 'no_activity' + | 'error_count' + +const ALERT_RULES: { value: AlertRule; label: string; description: string }[] = [ + { + value: 'consecutive_failures', + label: 'Consecutive Failures', + description: 'After X failures in a row', + }, + { value: 'failure_rate', label: 'Failure Rate', description: 'When failure % exceeds threshold' }, + { + value: 'latency_threshold', + label: 'Latency Threshold', + description: 'When execution exceeds duration', + }, + { value: 'latency_spike', label: 'Latency Spike', description: 'When slower than average by %' }, + { + value: 'cost_threshold', + label: 'Cost Threshold', + description: 'When execution cost exceeds $', + }, + { value: 'no_activity', label: 'No Activity', description: 'When no executions in time window' }, + { value: 'error_count', label: 'Error Count', description: 'When errors exceed count in window' }, +] + +interface NotificationSettingsProps { + workspaceId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +const LOG_LEVELS: LogLevel[] = ['info', 'error'] +const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat'] + +function formatAlertConfigLabel(config: { + rule: AlertRule + consecutiveFailures?: number + failureRatePercent?: number + windowHours?: number + durationThresholdMs?: number + latencySpikePercent?: number + costThresholdDollars?: number + inactivityHours?: number + errorCountThreshold?: number +}): string { + switch (config.rule) { + case 'consecutive_failures': + return `${config.consecutiveFailures} consecutive failures` + case 'failure_rate': + return `${config.failureRatePercent}% failure rate in ${config.windowHours}h` + case 'latency_threshold': + return `>${Math.round((config.durationThresholdMs || 0) / 1000)}s duration` + case 'latency_spike': + return `${config.latencySpikePercent}% above avg in ${config.windowHours}h` + case 'cost_threshold': + return `>$${config.costThresholdDollars} per execution` + case 'no_activity': + return `No activity in ${config.inactivityHours}h` + case 'error_count': + return `${config.errorCountThreshold} errors in ${config.windowHours}h` + default: + return 'Alert rule' + } +} + +export function NotificationSettings({ + workspaceId, + open, + onOpenChange, +}: NotificationSettingsProps) { + const [activeTab, setActiveTab] = useState('webhook') + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [testStatus, setTestStatus] = useState<{ + id: string + success: boolean + message: string + } | null>(null) + + const [formData, setFormData] = useState({ + workflowIds: [] as string[], + allWorkflows: false, + levelFilter: ['info', 'error'] as LogLevel[], + triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'] as TriggerType[], + includeFinalOutput: false, + includeTraceSpans: false, + includeRateLimits: false, + includeUsageData: false, + webhookUrl: '', + webhookSecret: '', + emailRecipients: '', + slackChannelId: '', + slackChannelName: '', + slackAccountId: '', + useAlertRule: false, + alertRule: 'consecutive_failures' as AlertRule, + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, + durationThresholdMs: 30000, + latencySpikePercent: 100, + costThresholdDollars: 1, + inactivityHours: 24, + errorCountThreshold: 10, + }) + + const [formErrors, setFormErrors] = useState>({}) + + const { data: subscriptions = [], isLoading } = useNotifications(open ? workspaceId : undefined) + const createNotification = useCreateNotification() + const updateNotification = useUpdateNotification() + const toggleActive = useToggleNotificationActive() + const deleteNotification = useDeleteNotification() + const testNotification = useTestNotification() + + const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts() + const connectSlack = useConnectOAuthService() + + const filteredSubscriptions = useMemo(() => { + return subscriptions.filter((s) => s.notificationType === activeTab) + }, [subscriptions, activeTab]) + + const resetForm = useCallback(() => { + setFormData({ + workflowIds: [], + allWorkflows: false, + levelFilter: ['info', 'error'], + triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'], + includeFinalOutput: false, + includeTraceSpans: false, + includeRateLimits: false, + includeUsageData: false, + webhookUrl: '', + webhookSecret: '', + emailRecipients: '', + slackChannelId: '', + slackChannelName: '', + slackAccountId: '', + useAlertRule: false, + alertRule: 'consecutive_failures', + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, + durationThresholdMs: 30000, + latencySpikePercent: 100, + costThresholdDollars: 1, + inactivityHours: 24, + errorCountThreshold: 10, + }) + setFormErrors({}) + setEditingId(null) + }, []) + + const handleClose = useCallback(() => { + resetForm() + setShowForm(false) + setTestStatus(null) + onOpenChange(false) + }, [onOpenChange, resetForm]) + + const validateForm = (): boolean => { + const errors: Record = {} + + if (!formData.allWorkflows && formData.workflowIds.length === 0) { + errors.workflows = 'Select at least one workflow or enable "All Workflows"' + } + + if (formData.levelFilter.length === 0) { + errors.levelFilter = 'Select at least one log level' + } + + if (formData.triggerFilter.length === 0) { + errors.triggerFilter = 'Select at least one trigger type' + } + + if (activeTab === 'webhook') { + if (!formData.webhookUrl) { + errors.webhookUrl = 'Webhook URL is required' + } else { + try { + const url = new URL(formData.webhookUrl) + if (!['http:', 'https:'].includes(url.protocol)) { + errors.webhookUrl = 'URL must start with http:// or https://' + } + } catch { + errors.webhookUrl = 'Invalid URL format' + } + } + } + + if (activeTab === 'email') { + const emails = formData.emailRecipients + .split(',') + .map((e) => e.trim()) + .filter(Boolean) + if (emails.length === 0) { + errors.emailRecipients = 'At least one email address is required' + } else if (emails.length > 10) { + errors.emailRecipients = 'Maximum 10 email recipients allowed' + } else { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + const invalidEmails = emails.filter((e) => !emailRegex.test(e)) + if (invalidEmails.length > 0) { + errors.emailRecipients = `Invalid email addresses: ${invalidEmails.join(', ')}` + } + } + } + + if (activeTab === 'slack') { + if (!formData.slackAccountId) { + errors.slackAccountId = 'Select a Slack account' + } + if (!formData.slackChannelId) { + errors.slackChannelId = 'Select a Slack channel' + } + } + + if (formData.useAlertRule) { + switch (formData.alertRule) { + case 'consecutive_failures': + if (formData.consecutiveFailures < 1 || formData.consecutiveFailures > 100) { + errors.consecutiveFailures = 'Must be between 1 and 100' + } + break + case 'failure_rate': + if (formData.failureRatePercent < 1 || formData.failureRatePercent > 100) { + errors.failureRatePercent = 'Must be between 1 and 100' + } + if (formData.windowHours < 1 || formData.windowHours > 168) { + errors.windowHours = 'Must be between 1 and 168 hours' + } + break + case 'latency_threshold': + if (formData.durationThresholdMs < 1000 || formData.durationThresholdMs > 3600000) { + errors.durationThresholdMs = 'Must be between 1s and 1 hour' + } + break + case 'latency_spike': + if (formData.latencySpikePercent < 10 || formData.latencySpikePercent > 1000) { + errors.latencySpikePercent = 'Must be between 10% and 1000%' + } + if (formData.windowHours < 1 || formData.windowHours > 168) { + errors.windowHours = 'Must be between 1 and 168 hours' + } + break + case 'cost_threshold': + if (formData.costThresholdDollars < 0.01 || formData.costThresholdDollars > 1000) { + errors.costThresholdDollars = 'Must be between $0.01 and $1000' + } + break + case 'no_activity': + if (formData.inactivityHours < 1 || formData.inactivityHours > 168) { + errors.inactivityHours = 'Must be between 1 and 168 hours' + } + break + case 'error_count': + if (formData.errorCountThreshold < 1 || formData.errorCountThreshold > 1000) { + errors.errorCountThreshold = 'Must be between 1 and 1000' + } + if (formData.windowHours < 1 || formData.windowHours > 168) { + errors.windowHours = 'Must be between 1 and 168 hours' + } + break + } + } + + setFormErrors(errors) + return Object.keys(errors).length === 0 + } + + const handleSave = async () => { + if (!validateForm()) return + + const alertConfig = formData.useAlertRule + ? { + rule: formData.alertRule, + ...(formData.alertRule === 'consecutive_failures' && { + consecutiveFailures: formData.consecutiveFailures, + }), + ...(formData.alertRule === 'failure_rate' && { + failureRatePercent: formData.failureRatePercent, + windowHours: formData.windowHours, + }), + ...(formData.alertRule === 'latency_threshold' && { + durationThresholdMs: formData.durationThresholdMs, + }), + ...(formData.alertRule === 'latency_spike' && { + latencySpikePercent: formData.latencySpikePercent, + windowHours: formData.windowHours, + }), + ...(formData.alertRule === 'cost_threshold' && { + costThresholdDollars: formData.costThresholdDollars, + }), + ...(formData.alertRule === 'no_activity' && { + inactivityHours: formData.inactivityHours, + }), + ...(formData.alertRule === 'error_count' && { + errorCountThreshold: formData.errorCountThreshold, + windowHours: formData.windowHours, + }), + } + : null + + const payload = { + notificationType: activeTab, + workflowIds: formData.workflowIds, + allWorkflows: formData.allWorkflows, + levelFilter: formData.levelFilter, + triggerFilter: formData.triggerFilter, + includeFinalOutput: formData.includeFinalOutput, + includeTraceSpans: formData.includeTraceSpans, + includeRateLimits: formData.includeRateLimits, + includeUsageData: formData.includeUsageData, + alertConfig, + ...(activeTab === 'webhook' && { + webhookConfig: { + url: formData.webhookUrl, + secret: formData.webhookSecret || undefined, + }, + }), + ...(activeTab === 'email' && { + emailRecipients: formData.emailRecipients + .split(',') + .map((e) => e.trim()) + .filter(Boolean), + }), + ...(activeTab === 'slack' && { + slackConfig: { + channelId: formData.slackChannelId, + channelName: formData.slackChannelName, + accountId: formData.slackAccountId, + }, + }), + } + + try { + if (editingId) { + await updateNotification.mutateAsync({ + workspaceId, + notificationId: editingId, + data: payload, + }) + } else { + await createNotification.mutateAsync({ + workspaceId, + data: payload, + }) + } + resetForm() + setShowForm(false) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save notification' + setFormErrors({ general: message }) + } + } + + const handleEdit = (subscription: NotificationSubscription) => { + setActiveTab(subscription.notificationType) + setEditingId(subscription.id) + setFormData({ + workflowIds: subscription.workflowIds || [], + allWorkflows: subscription.allWorkflows, + levelFilter: subscription.levelFilter as LogLevel[], + triggerFilter: subscription.triggerFilter as TriggerType[], + includeFinalOutput: subscription.includeFinalOutput, + includeTraceSpans: subscription.includeTraceSpans, + includeRateLimits: subscription.includeRateLimits, + includeUsageData: subscription.includeUsageData, + webhookUrl: subscription.webhookConfig?.url || '', + webhookSecret: '', + emailRecipients: subscription.emailRecipients?.join(', ') || '', + slackChannelId: subscription.slackConfig?.channelId || '', + slackChannelName: subscription.slackConfig?.channelName || '', + slackAccountId: subscription.slackConfig?.accountId || '', + useAlertRule: !!subscription.alertConfig, + alertRule: subscription.alertConfig?.rule || 'consecutive_failures', + consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3, + failureRatePercent: subscription.alertConfig?.failureRatePercent || 50, + windowHours: subscription.alertConfig?.windowHours || 24, + durationThresholdMs: subscription.alertConfig?.durationThresholdMs || 30000, + latencySpikePercent: subscription.alertConfig?.latencySpikePercent || 100, + costThresholdDollars: subscription.alertConfig?.costThresholdDollars || 1, + inactivityHours: subscription.alertConfig?.inactivityHours || 24, + errorCountThreshold: subscription.alertConfig?.errorCountThreshold || 10, + }) + setShowForm(true) + } + + const handleDelete = async () => { + if (!deletingId) return + + try { + await deleteNotification.mutateAsync({ + workspaceId, + notificationId: deletingId, + }) + } catch (error) { + logger.error('Failed to delete notification', { error }) + } finally { + setShowDeleteDialog(false) + setDeletingId(null) + } + } + + const handleTest = async (id: string) => { + setTestStatus(null) + try { + const result = await testNotification.mutateAsync({ + workspaceId, + notificationId: id, + }) + setTestStatus({ + id, + success: result.data?.success ?? false, + message: + result.data?.error || (result.data?.success ? 'Test sent successfully' : 'Test failed'), + }) + } catch (error) { + setTestStatus({ id, success: false, message: 'Failed to send test' }) + } + } + + const handleToggleActive = (subscription: NotificationSubscription) => { + toggleActive.mutate({ + workspaceId, + notificationId: subscription.id, + active: !subscription.active, + }) + } + + const renderSubscriptionItem = (subscription: NotificationSubscription) => { + const identifier = + subscription.notificationType === 'webhook' + ? subscription.webhookConfig?.url + : subscription.notificationType === 'email' + ? subscription.emailRecipients?.join(', ') + : `#${subscription.slackConfig?.channelName || subscription.slackConfig?.channelId}` + + return ( +
+
+
+
+ + {identifier} + +
+ {testStatus?.id === subscription.id && ( +
+ {testStatus.success ? ( + + ) : ( + + )} + {testStatus.message} +
+ )} +
+ +
+ handleToggleActive(subscription)} + /> + + + + + Test notification + + + + + + Edit + + + + + + Delete + +
+
+ +
+ {subscription.allWorkflows ? ( + All workflows + ) : ( + + {subscription.workflowIds.length} workflow(s) + + )} + + {subscription.levelFilter.map((level) => ( + + {level} + + ))} + + {subscription.triggerFilter.slice(0, 3).map((trigger) => ( + + {trigger} + + ))} + {subscription.triggerFilter.length > 3 && ( + + +{subscription.triggerFilter.length - 3} + + )} + {subscription.alertConfig && ( + <> + + + {formatAlertConfigLabel(subscription.alertConfig)} + + + )} +
+
+ ) + } + + const renderForm = () => ( +
+
+

+ {editingId ? 'Edit Notification' : 'Create New Notification'} +

+

+ Configure {activeTab} notifications for workflow executions +

+
+ + {formErrors.general && ( +
+
+ +

{formErrors.general}

+
+
+ )} + +
+ { + setFormData({ ...formData, workflowIds: ids, allWorkflows: all }) + setFormErrors({ ...formErrors, workflows: '' }) + }} + error={formErrors.workflows} + /> + +
+
+
+ +

+ {formData.useAlertRule + ? 'Notify when failure patterns are detected' + : 'Notify on every matching execution'} +

+
+ setFormData({ ...formData, useAlertRule: checked })} + /> +
+ + {formData.useAlertRule && ( +
+
+ + ({ + value: rule.value, + label: rule.label, + }))} + value={formData.alertRule} + onChange={(value) => setFormData({ ...formData, alertRule: value as AlertRule })} + placeholder='Select alert rule' + /> +

+ {ALERT_RULES.find((r) => r.value === formData.alertRule)?.description} +

+
+ + {formData.alertRule === 'consecutive_failures' && ( +
+ + + setFormData({ + ...formData, + consecutiveFailures: Number.parseInt(e.target.value) || 1, + }) + } + className='w-32' + /> + {formErrors.consecutiveFailures && ( +

{formErrors.consecutiveFailures}

+ )} +
+ )} + + {formData.alertRule === 'failure_rate' && ( +
+
+ + + setFormData({ + ...formData, + failureRatePercent: Number.parseInt(e.target.value) || 1, + }) + } + /> + {formErrors.failureRatePercent && ( +

{formErrors.failureRatePercent}

+ )} +
+
+ + + setFormData({ + ...formData, + windowHours: Number.parseInt(e.target.value) || 1, + }) + } + /> + {formErrors.windowHours && ( +

{formErrors.windowHours}

+ )} +
+
+ )} + + {formData.alertRule === 'latency_threshold' && ( +
+ + + setFormData({ + ...formData, + durationThresholdMs: (Number.parseInt(e.target.value) || 1) * 1000, + }) + } + className='w-32' + /> + {formErrors.durationThresholdMs && ( +

{formErrors.durationThresholdMs}

+ )} +
+ )} + + {formData.alertRule === 'latency_spike' && ( +
+
+ + + setFormData({ + ...formData, + latencySpikePercent: Number.parseInt(e.target.value) || 10, + }) + } + /> + {formErrors.latencySpikePercent && ( +

{formErrors.latencySpikePercent}

+ )} +
+
+ + + setFormData({ + ...formData, + windowHours: Number.parseInt(e.target.value) || 1, + }) + } + /> + {formErrors.windowHours && ( +

{formErrors.windowHours}

+ )} +
+
+ )} + + {formData.alertRule === 'cost_threshold' && ( +
+ + + setFormData({ + ...formData, + costThresholdDollars: Number.parseFloat(e.target.value) || 0.01, + }) + } + className='w-32' + /> + {formErrors.costThresholdDollars && ( +

{formErrors.costThresholdDollars}

+ )} +
+ )} + + {formData.alertRule === 'no_activity' && ( +
+ + + setFormData({ + ...formData, + inactivityHours: Number.parseInt(e.target.value) || 1, + }) + } + className='w-32' + /> + {formErrors.inactivityHours && ( +

{formErrors.inactivityHours}

+ )} +
+ )} + + {formData.alertRule === 'error_count' && ( +
+
+ + + setFormData({ + ...formData, + errorCountThreshold: Number.parseInt(e.target.value) || 1, + }) + } + /> + {formErrors.errorCountThreshold && ( +

{formErrors.errorCountThreshold}

+ )} +
+
+ + + setFormData({ + ...formData, + windowHours: Number.parseInt(e.target.value) || 1, + }) + } + /> + {formErrors.windowHours && ( +

{formErrors.windowHours}

+ )} +
+
+ )} +
+ )} +
+ + {activeTab === 'webhook' && ( + <> +
+ + { + setFormData({ ...formData, webhookUrl: e.target.value }) + setFormErrors({ ...formErrors, webhookUrl: '' }) + }} + /> + {formErrors.webhookUrl && ( +

{formErrors.webhookUrl}

+ )} +
+
+ + setFormData({ ...formData, webhookSecret: e.target.value })} + /> +

+ Used to sign webhook payloads with HMAC-SHA256 +

+
+ + )} + + {activeTab === 'email' && ( +
+ + { + setFormData({ ...formData, emailRecipients: e.target.value }) + setFormErrors({ ...formErrors, emailRecipients: '' }) + }} + /> +

+ Comma-separated list of email addresses (max 10) +

+ {formErrors.emailRecipients && ( +

{formErrors.emailRecipients}

+ )} +
+ )} + + {activeTab === 'slack' && ( + <> +
+ + {isLoadingSlackAccounts ? ( + + ) : slackAccounts.length === 0 ? ( +
+

+ No Slack accounts connected +

+ +
+ ) : ( + ({ + value: acc.id, + label: acc.accountId, + }))} + value={formData.slackAccountId} + onChange={(value) => { + setFormData({ + ...formData, + slackAccountId: value, + slackChannelId: '', + }) + setFormErrors({ ...formErrors, slackAccountId: '', slackChannelId: '' }) + }} + placeholder='Select account...' + /> + )} + {formErrors.slackAccountId && ( +

{formErrors.slackAccountId}

+ )} +
+ {slackAccounts.length > 0 && ( +
+ + { + setFormData({ + ...formData, + slackChannelId: channelId, + slackChannelName: channelName, + }) + setFormErrors({ ...formErrors, slackChannelId: '' }) + }} + disabled={!formData.slackAccountId} + error={formErrors.slackChannelId} + /> +
+ )} + + )} + +
+
+ +

+ Select which log levels trigger notifications +

+
+ ({ + label: level.charAt(0).toUpperCase() + level.slice(1), + value: level, + }))} + multiSelect + multiSelectValues={formData.levelFilter} + onMultiSelectChange={(values) => { + setFormData({ ...formData, levelFilter: values as LogLevel[] }) + setFormErrors({ ...formErrors, levelFilter: '' }) + }} + placeholder='Select log levels...' + overlayContent={ + formData.levelFilter.length > 0 ? ( +
+ {formData.levelFilter.map((level) => ( + + ))} +
+ ) : null + } + /> + {formErrors.levelFilter && ( +

{formErrors.levelFilter}

+ )} +
+ +
+
+ +

+ Select which trigger types send notifications +

+
+ ({ + label: trigger.charAt(0).toUpperCase() + trigger.slice(1), + value: trigger, + }))} + multiSelect + multiSelectValues={formData.triggerFilter} + onMultiSelectChange={(values) => { + setFormData({ ...formData, triggerFilter: values as TriggerType[] }) + setFormErrors({ ...formErrors, triggerFilter: '' }) + }} + placeholder='Select trigger types...' + overlayContent={ + formData.triggerFilter.length > 0 ? ( +
+ {formData.triggerFilter.slice(0, 3).map((trigger) => ( + + ))} + {formData.triggerFilter.length > 3 && ( + + +{formData.triggerFilter.length - 3} + + )} +
+ ) : null + } + /> + {formErrors.triggerFilter && ( +

{formErrors.triggerFilter}

+ )} +
+ +
+
+ +

+ Additional data to include in notifications +

+
+ { + setFormData({ + ...formData, + includeFinalOutput: values.includes('includeFinalOutput'), + includeTraceSpans: values.includes('includeTraceSpans'), + includeRateLimits: values.includes('includeRateLimits'), + includeUsageData: values.includes('includeUsageData'), + }) + }} + placeholder='Select data to include...' + overlayContent={(() => { + const labels: Record = { + includeFinalOutput: 'Final Output', + includeTraceSpans: 'Trace Spans', + includeRateLimits: 'Rate Limits', + includeUsageData: 'Usage Data', + } + const selected = [ + formData.includeFinalOutput && 'includeFinalOutput', + formData.includeTraceSpans && 'includeTraceSpans', + formData.includeRateLimits && 'includeRateLimits', + formData.includeUsageData && 'includeUsageData', + ].filter(Boolean) as string[] + + if (selected.length === 0) return null + + return ( +
+ {selected.slice(0, 2).map((key) => ( + + ))} + {selected.length > 2 && ( + + +{selected.length - 2} + + )} +
+ ) + })()} + /> +
+
+
+ ) + + const renderTabContent = () => { + if (showForm) { + return renderForm() + } + + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( +
+ + +
+ ))} +
+ ) + } + + if (filteredSubscriptions.length === 0) { + return ( +
+

+ No {activeTab} notifications configured +

+
+ ) + } + + return
{filteredSubscriptions.map(renderSubscriptionItem)}
+ } + + return ( + <> + + + Notifications + + { + if (!showForm) { + setActiveTab(value as NotificationType) + } + }} + className='flex min-h-0 flex-1 flex-col' + > + {!showForm && ( + + Webhook + Email + Slack + + )} + + + {renderTabContent()} + {renderTabContent()} + {renderTabContent()} + + + + {showForm ? ( + + + + + ) : ( + + + + )} + + + + + + + Delete notification? + + This will permanently remove the notification and stop all deliveries.{' '} + This action cannot be undone. + + + + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/slack-channel-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/slack-channel-selector.tsx new file mode 100644 index 00000000000..f50ebbe2907 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/slack-channel-selector.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Hash, Lock } from 'lucide-react' +import { Combobox, type ComboboxOption } from '@/components/emcn' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('SlackChannelSelector') + +interface SlackChannel { + id: string + name: string + isPrivate: boolean +} + +interface SlackChannelSelectorProps { + accountId: string + value: string + onChange: (channelId: string, channelName: string) => void + disabled?: boolean + error?: string +} + +/** + * Standalone Slack channel selector that fetches channels for a given account. + */ +export function SlackChannelSelector({ + accountId, + value, + onChange, + disabled = false, + error, +}: SlackChannelSelectorProps) { + const [channels, setChannels] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [fetchError, setFetchError] = useState(null) + + const fetchChannels = useCallback(async () => { + if (!accountId) { + setChannels([]) + return + } + + setIsLoading(true) + setFetchError(null) + + try { + const response = await fetch('/api/tools/slack/channels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: accountId }), + }) + + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to fetch channels') + } + + const data = await response.json() + setChannels(data.channels || []) + } catch (err) { + logger.error('Failed to fetch Slack channels', { error: err }) + setFetchError(err instanceof Error ? err.message : 'Failed to fetch channels') + setChannels([]) + } finally { + setIsLoading(false) + } + }, [accountId]) + + useEffect(() => { + fetchChannels() + }, [fetchChannels]) + + const options: ComboboxOption[] = channels.map((channel) => ({ + label: channel.name, + value: channel.id, + icon: channel.isPrivate ? Lock : Hash, + })) + + const selectedChannel = channels.find((c) => c.id === value) + + if (!accountId) { + return ( +
+

Select a Slack account first

+
+ ) + } + + const handleChange = (channelId: string) => { + const channel = channels.find((c) => c.id === channelId) + onChange(channelId, channel?.name || '') + } + + return ( +
+ + {selectedChannel && !fetchError && ( +

+ {selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name} +

+ )} + {error &&

{error}

} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/workflow-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/workflow-selector.tsx new file mode 100644 index 00000000000..7b560ba79cd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/workflow-selector.tsx @@ -0,0 +1,183 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { Layers, X } from 'lucide-react' +import { Button, Combobox, type ComboboxOption } from '@/components/emcn' +import { Label, Skeleton } from '@/components/ui' + +interface WorkflowSelectorProps { + workspaceId: string + selectedIds: string[] + allWorkflows: boolean + onChange: (ids: string[], allWorkflows: boolean) => void + error?: string +} + +const ALL_WORKFLOWS_VALUE = '__all_workflows__' + +/** + * Multi-select workflow selector with "All Workflows" option. + */ +export function WorkflowSelector({ + workspaceId, + selectedIds, + allWorkflows, + onChange, + error, +}: WorkflowSelectorProps) { + const [workflows, setWorkflows] = useState>([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const load = async () => { + try { + setIsLoading(true) + const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`) + if (response.ok) { + const data = await response.json() + setWorkflows(data.data || []) + } + } catch { + setWorkflows([]) + } finally { + setIsLoading(false) + } + } + load() + }, [workspaceId]) + + const options: ComboboxOption[] = useMemo(() => { + const workflowOptions = workflows.map((w) => ({ + label: w.name, + value: w.id, + })) + + return [ + { + label: 'All Workflows', + value: ALL_WORKFLOWS_VALUE, + icon: Layers, + }, + ...workflowOptions, + ] + }, [workflows]) + + const currentValues = useMemo(() => { + if (allWorkflows) { + return [ALL_WORKFLOWS_VALUE] + } + return selectedIds + }, [allWorkflows, selectedIds]) + + const handleMultiSelectChange = (values: string[]) => { + const hasAllWorkflows = values.includes(ALL_WORKFLOWS_VALUE) + const hadAllWorkflows = allWorkflows + + if (hasAllWorkflows && !hadAllWorkflows) { + // User selected "All Workflows" - clear individual selections + onChange([], true) + } else if (!hasAllWorkflows && hadAllWorkflows) { + // User deselected "All Workflows" - switch to individual selection + onChange( + values.filter((v) => v !== ALL_WORKFLOWS_VALUE), + false + ) + } else { + // Normal individual workflow selection/deselection + onChange( + values.filter((v) => v !== ALL_WORKFLOWS_VALUE), + false + ) + } + } + + const handleRemove = (e: React.MouseEvent, id: string) => { + e.preventDefault() + e.stopPropagation() + if (id === ALL_WORKFLOWS_VALUE) { + onChange([], false) + } else { + onChange( + selectedIds.filter((i) => i !== id), + false + ) + } + } + + const selectedWorkflows = useMemo(() => { + return workflows.filter((w) => selectedIds.includes(w.id)) + }, [workflows, selectedIds]) + + // Render overlay content showing selected items as tags + const overlayContent = useMemo(() => { + if (allWorkflows) { + return ( +
+ +
+ ) + } + + if (selectedWorkflows.length === 0) { + return null + } + + return ( +
+ {selectedWorkflows.slice(0, 2).map((w) => ( + + ))} + {selectedWorkflows.length > 2 && ( + + +{selectedWorkflows.length - 2} + + )} +
+ ) + }, [allWorkflows, selectedWorkflows, selectedIds]) + + if (isLoading) { + return ( +
+ + +
+ ) + } + + return ( +
+ + +

+ Select which workflows should trigger this notification +

+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx index 21a5fe77f2b..884707e1a99 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Search, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Button, Popover, PopoverAnchor, PopoverContent } from '@/components/emcn' @@ -120,6 +120,17 @@ export function AutocompleteSearch({ getSuggestions: (input) => suggestionEngine.getSuggestions(input), }) + const lastExternalValue = useRef(value) + useEffect(() => { + // Only re-initialize if value changed externally (not from user typing) + if (value !== lastExternalValue.current) { + lastExternalValue.current = value + const parsed = parseQuery(value) + initializeFromQuery(parsed.textSearch, parsed.filters) + } + }, [value, initializeFromQuery]) + + // Initial sync on mount useEffect(() => { if (value) { const parsed = parseQuery(value) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index c35afcdc836..0fa81ad6986 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -8,10 +8,12 @@ import { cn } from '@/lib/core/utils/cn' import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import Controls from '@/app/workspace/[workspaceId]/logs/components/dashboard/controls' +import { NotificationSettings } from '@/app/workspace/[workspaceId]/logs/components/notification-settings/notification-settings' import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/components/search/search' import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar' import Dashboard from '@/app/workspace/[workspaceId]/logs/dashboard' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useFolders } from '@/hooks/queries/folders' import { useLogDetail, useLogsList } from '@/hooks/queries/logs' import { useDebounce } from '@/hooks/use-debounce' @@ -58,7 +60,6 @@ export default function Logs() { level, workflowIds, folderIds, - searchQuery: storeSearchQuery, setSearchQuery: setStoreSearchQuery, triggers, viewMode, @@ -77,14 +78,24 @@ export default function Logs() { const scrollContainerRef = useRef(null) const isInitialized = useRef(false) - const [searchQuery, setSearchQuery] = useState(storeSearchQuery) + const [searchQuery, setSearchQuery] = useState('') const debouncedSearchQuery = useDebounce(searchQuery, 300) + // Sync search query from URL on mount (client-side only) + useEffect(() => { + const urlSearch = new URLSearchParams(window.location.search).get('search') || '' + if (urlSearch && urlSearch !== searchQuery) { + setSearchQuery(urlSearch) + } + }, []) + const [, setAvailableWorkflows] = useState([]) const [, setAvailableFolders] = useState([]) const [isLive, setIsLive] = useState(false) const isSearchOpenRef = useRef(false) + const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) + const userPermissions = useUserPermissionsContext() const logFilters = useMemo( () => ({ @@ -111,10 +122,6 @@ export default function Logs() { return logsQuery.data.pages.flatMap((page) => page.logs) }, [logsQuery.data?.pages]) - useEffect(() => { - setSearchQuery(storeSearchQuery) - }, [storeSearchQuery]) - const foldersQuery = useFolders(workspaceId) const { getFolderTree } = useFolderStore() @@ -166,10 +173,10 @@ export default function Logs() { }, [workspaceId, getFolderTree, foldersQuery.data]) useEffect(() => { - if (isInitialized.current && debouncedSearchQuery !== storeSearchQuery) { + if (isInitialized.current) { setStoreSearchQuery(debouncedSearchQuery) } - }, [debouncedSearchQuery, storeSearchQuery]) + }, [debouncedSearchQuery, setStoreSearchQuery]) const handleLogClick = (log: WorkflowLog) => { setSelectedLog(log) @@ -249,6 +256,8 @@ export default function Logs() { useEffect(() => { const handlePopState = () => { initializeFromURL() + const params = new URLSearchParams(window.location.search) + setSearchQuery(params.get('search') || '') } window.addEventListener('popstate', handlePopState) @@ -381,6 +390,8 @@ export default function Logs() { } showExport={true} onExport={handleExport} + canConfigureNotifications={userPermissions.canEdit} + onConfigureNotifications={() => setIsNotificationSettingsOpen(true)} /> {/* Table container */} @@ -599,6 +610,12 @@ export default function Logs() { hasNext={selectedLogIndex < logs.length - 1} hasPrev={selectedLogIndex > 0} /> + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx deleted file mode 100644 index 4e6349ff19a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx +++ /dev/null @@ -1,1192 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { - AlertCircle, - Check, - Copy, - Eye, - EyeOff, - Pencil, - Play, - Plus, - RefreshCw, - Search, - Trash2, -} from 'lucide-react' -import { - Button as EmcnButton, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Tooltip, -} from '@/components/emcn' -import { - Button, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - Input, - Label, - Skeleton, - Switch, -} from '@/components/ui' -import { generatePassword } from '@/lib/core/security/encryption' -import { cn } from '@/lib/core/utils/cn' -import { createLogger } from '@/lib/logs/console/logger' -import type { - LogLevel as StoreLogLevel, - TriggerType as StoreTriggerType, -} from '@/stores/logs/filters/types' - -const logger = createLogger('WebhookSettings') - -type NotificationLogLevel = Exclude -type NotificationTrigger = Exclude - -interface WebhookConfig { - id: string - url: string - includeFinalOutput: boolean - includeTraceSpans: boolean - includeRateLimits: boolean - includeUsageData: boolean - levelFilter: NotificationLogLevel[] - triggerFilter: NotificationTrigger[] - active: boolean - createdAt: string - updatedAt: string -} - -interface WebhookSettingsProps { - workflowId: string - open: boolean - onOpenChange: (open: boolean) => void -} - -export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSettingsProps) { - const [webhooks, setWebhooks] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [isCreating, setIsCreating] = useState(false) - const [isTesting, setIsTesting] = useState(null) - const [showSecret, setShowSecret] = useState(false) - const [editingWebhookId, setEditingWebhookId] = useState(null) - const [showForm, setShowForm] = useState(false) - const [copySuccess, setCopySuccess] = useState>({}) - const [searchTerm, setSearchTerm] = useState('') - const [isGenerating, setIsGenerating] = useState(false) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [webhookToDelete, setWebhookToDelete] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) - const [operationStatus, setOperationStatus] = useState<{ - type: 'success' | 'error' | null - message: string - }>({ type: null, message: '' }) - const [testStatus, setTestStatus] = useState<{ - webhookId: string - type: 'success' | 'error' - message: string - } | null>(null) - - interface EditableWebhookPayload { - url: string - secret: string - includeFinalOutput: boolean - includeTraceSpans: boolean - includeRateLimits: boolean - includeUsageData: boolean - levelFilter: NotificationLogLevel[] - triggerFilter: NotificationTrigger[] - } - - // Filter webhooks based on search term - const filteredWebhooks = webhooks.filter((webhook) => - webhook.url.toLowerCase().includes(searchTerm.toLowerCase()) - ) - - const [newWebhook, setNewWebhook] = useState({ - url: '', - secret: '', - includeFinalOutput: false, - includeTraceSpans: false, - includeRateLimits: false, - includeUsageData: false, - levelFilter: ['info', 'error'], - triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'], - }) - const [fieldErrors, setFieldErrors] = useState<{ - url?: string[] - levelFilter?: string[] - triggerFilter?: string[] - general?: string[] - }>({}) - - useEffect(() => { - if (open) { - loadWebhooks() - } - }, [open, workflowId]) - - const loadWebhooks = async () => { - try { - setIsLoading(true) - const response = await fetch(`/api/workflows/${workflowId}/log-webhook`) - if (response.ok) { - const data = await response.json() - const list: WebhookConfig[] = data.data || [] - setWebhooks(list) - // Show form if no webhooks exist - if (list.length === 0) { - setShowForm(true) - } - } - } catch (error) { - logger.error('Failed to load webhooks', { error }) - setOperationStatus({ - type: 'error', - message: 'Failed to load webhook configurations', - }) - } finally { - setIsLoading(false) - } - } - - const createWebhook = async () => { - setFieldErrors({}) // Clear any previous errors - - if (!newWebhook.url) { - setFieldErrors({ url: ['Please enter a webhook URL'] }) - return - } - - // Validate URL format - try { - const url = new URL(newWebhook.url) - if (!['http:', 'https:'].includes(url.protocol)) { - setFieldErrors({ url: ['URL must start with http:// or https://'] }) - return - } - } catch { - setFieldErrors({ url: ['Please enter a valid URL (e.g., https://example.com/webhook)'] }) - return - } - - // Validate filters are not empty - if (newWebhook.levelFilter.length === 0) { - setFieldErrors({ levelFilter: ['Please select at least one log level filter'] }) - return - } - - if (newWebhook.triggerFilter.length === 0) { - setFieldErrors({ triggerFilter: ['Please select at least one trigger filter'] }) - return - } - - // Check for duplicate URL - const existingWebhook = webhooks.find((w) => w.url === newWebhook.url) - if (existingWebhook) { - setFieldErrors({ url: ['A webhook with this URL already exists'] }) - return - } - - try { - setIsCreating(true) - setFieldErrors({}) - const response = await fetch(`/api/workflows/${workflowId}/log-webhook`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newWebhook), - }) - - if (response.ok) { - // Refresh the webhooks list to ensure consistency and avoid duplicates - await loadWebhooks() - setNewWebhook({ - url: '', - secret: '', - includeFinalOutput: false, - includeTraceSpans: false, - includeRateLimits: false, - includeUsageData: false, - levelFilter: ['info', 'error'], - triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'], - }) - setFieldErrors({}) - setShowForm(false) - setOperationStatus({ - type: 'success', - message: 'Webhook created successfully', - }) - } else { - const error = await response.json() - // Show detailed validation errors if available - if (error.details && Array.isArray(error.details)) { - const errorMessages = error.details.map((e: any) => e.message || e.path?.join('.')) - setFieldErrors({ general: [`Validation failed: ${errorMessages.join(', ')}`] }) - } else { - setFieldErrors({ general: [error.error || 'Failed to create webhook'] }) - } - } - } catch (error) { - logger.error('Failed to create webhook', { error }) - setFieldErrors({ general: ['Failed to create webhook. Please try again.'] }) - } finally { - setIsCreating(false) - } - } - - const handleDeleteClick = (webhookId: string) => { - setWebhookToDelete(webhookId) - setShowDeleteDialog(true) - } - - const confirmDeleteWebhook = async () => { - if (!webhookToDelete) return - - try { - setIsDeleting(true) - const response = await fetch( - `/api/workflows/${workflowId}/log-webhook?webhookId=${webhookToDelete}`, - { - method: 'DELETE', - } - ) - - if (response.ok) { - // Refresh the webhooks list to ensure consistency - await loadWebhooks() - setOperationStatus({ - type: 'success', - message: 'Webhook deleted successfully', - }) - } else { - setOperationStatus({ - type: 'error', - message: 'Failed to delete webhook', - }) - } - } catch (error) { - logger.error('Failed to delete webhook', { error }) - setOperationStatus({ - type: 'error', - message: 'Failed to delete webhook', - }) - } finally { - setIsDeleting(false) - setShowDeleteDialog(false) - setWebhookToDelete(null) - } - } - - const handleDeleteDialogClose = () => { - setShowDeleteDialog(false) - setWebhookToDelete(null) - } - - const testWebhook = async (webhookId: string) => { - try { - setIsTesting(webhookId) - const response = await fetch( - `/api/workflows/${workflowId}/log-webhook/test?webhookId=${webhookId}`, - { - method: 'POST', - } - ) - - if (response.ok) { - const data = await response.json() - if (data.data.success) { - setTestStatus({ - webhookId, - type: 'success', - message: `Test webhook sent successfully (${data.data.status})`, - }) - } else { - setTestStatus({ - webhookId, - type: 'error', - message: `Test webhook failed: ${data.data.error || data.data.statusText}`, - }) - } - } else { - setTestStatus({ - webhookId, - type: 'error', - message: 'Failed to send test webhook', - }) - } - } catch (error) { - logger.error('Failed to test webhook', { error }) - setTestStatus({ - webhookId, - type: 'error', - message: 'Failed to test webhook', - }) - } finally { - setIsTesting(null) - } - } - - // Remove copyWebhookId function as it's not used - - const handleGeneratePassword = async () => { - setIsGenerating(true) - // Add a small delay for visual feedback - await new Promise((resolve) => setTimeout(resolve, 300)) - const password = generatePassword(24) - setNewWebhook({ ...newWebhook, secret: password }) - setFieldErrors({}) - setIsGenerating(false) - } - - const copyToClipboard = (text: string, webhookId: string) => { - navigator.clipboard.writeText(text) - setCopySuccess((prev) => ({ ...prev, [webhookId]: true })) - setTimeout(() => { - setCopySuccess((prev) => ({ ...prev, [webhookId]: false })) - }, 2000) - } - - const [originalWebhook, setOriginalWebhook] = useState(null) - - const startEditWebhook = (webhook: WebhookConfig) => { - setEditingWebhookId(webhook.id) - setOriginalWebhook(webhook) - setNewWebhook({ - url: webhook.url, - secret: '', // Don't expose the existing secret - includeFinalOutput: webhook.includeFinalOutput, - includeTraceSpans: webhook.includeTraceSpans, - includeRateLimits: webhook.includeRateLimits || false, - includeUsageData: webhook.includeUsageData || false, - levelFilter: webhook.levelFilter, - triggerFilter: webhook.triggerFilter, - }) - setSearchTerm('') - setShowForm(true) - } - - const cancelEdit = () => { - setEditingWebhookId(null) - setOriginalWebhook(null) - setFieldErrors({}) - setOperationStatus({ type: null, message: '' }) - setNewWebhook({ - url: '', - secret: '', - includeFinalOutput: false, - includeTraceSpans: false, - includeRateLimits: false, - includeUsageData: false, - levelFilter: ['info', 'error'], - triggerFilter: ['api', 'webhook', 'schedule', 'manual', 'chat'], - }) - setShowForm(false) - } - - const hasChanges = () => { - if (!originalWebhook) return false - return ( - newWebhook.url !== originalWebhook.url || - newWebhook.includeFinalOutput !== originalWebhook.includeFinalOutput || - newWebhook.includeTraceSpans !== originalWebhook.includeTraceSpans || - newWebhook.includeRateLimits !== (originalWebhook.includeRateLimits || false) || - newWebhook.includeUsageData !== (originalWebhook.includeUsageData || false) || - JSON.stringify([...newWebhook.levelFilter].sort()) !== - JSON.stringify([...originalWebhook.levelFilter].sort()) || - JSON.stringify([...newWebhook.triggerFilter].sort()) !== - JSON.stringify([...originalWebhook.triggerFilter].sort()) || - newWebhook.secret !== '' - ) - } - - const handleCloseModal = () => { - cancelEdit() - setOperationStatus({ type: null, message: '' }) - setTestStatus(null) - setSearchTerm('') - setShowDeleteDialog(false) - setWebhookToDelete(null) - onOpenChange(false) - } - - const updateWebhook = async () => { - if (!editingWebhookId) return - - // Validate URL format - try { - const url = new URL(newWebhook.url) - if (!['http:', 'https:'].includes(url.protocol)) { - setFieldErrors({ url: ['URL must start with http:// or https://'] }) - return - } - } catch { - setFieldErrors({ url: ['Please enter a valid URL (e.g., https://example.com/webhook)'] }) - return - } - - // Validate filters are not empty - if (newWebhook.levelFilter.length === 0) { - setFieldErrors({ levelFilter: ['Please select at least one log level filter'] }) - return - } - - if (newWebhook.triggerFilter.length === 0) { - setFieldErrors({ triggerFilter: ['Please select at least one trigger filter'] }) - return - } - - // Check for duplicate URL (excluding current webhook) - const existingWebhook = webhooks.find( - (w) => w.url === newWebhook.url && w.id !== editingWebhookId - ) - if (existingWebhook) { - setFieldErrors({ url: ['A webhook with this URL already exists'] }) - return - } - - try { - setIsCreating(true) - interface UpdateWebhookPayload { - url: string - includeFinalOutput: boolean - includeTraceSpans: boolean - includeRateLimits: boolean - includeUsageData: boolean - levelFilter: NotificationLogLevel[] - triggerFilter: NotificationTrigger[] - secret?: string - active?: boolean - } - - let updateData: UpdateWebhookPayload = { - url: newWebhook.url, - includeFinalOutput: newWebhook.includeFinalOutput, - includeTraceSpans: newWebhook.includeTraceSpans, - includeRateLimits: newWebhook.includeRateLimits, - includeUsageData: newWebhook.includeUsageData, - levelFilter: newWebhook.levelFilter, - triggerFilter: newWebhook.triggerFilter, - } - - // Only include secret if it was changed - if (newWebhook.secret) { - updateData = { ...updateData, secret: newWebhook.secret } - } - - const response = await fetch(`/api/workflows/${workflowId}/log-webhook/${editingWebhookId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updateData), - }) - - if (response.ok) { - await loadWebhooks() - cancelEdit() - setOperationStatus({ - type: 'success', - message: 'Webhook updated successfully', - }) - } else { - const error = await response.json() - setFieldErrors({ general: [error.error || 'Failed to update webhook'] }) - } - } catch (error) { - logger.error('Failed to update webhook', { error }) - setFieldErrors({ general: ['Failed to update webhook'] }) - } finally { - setIsCreating(false) - } - } - - return ( - - - {/* Hidden dummy inputs to prevent browser password manager autofill */} - - - - - Webhook Notifications - - -
- {/* Fixed Header with Search */} - {!showForm && ( -
-
- - setSearchTerm(e.target.value)} - className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' - /> -
-
- )} - - {/* Scrollable Content */} -
-
- {!showForm ? ( -
- {isLoading ? ( -
- {/* Show 2 skeleton webhooks */} - {[1, 2].map((index) => ( -
- {' '} - {/* WEBHOOK 1/2 label */} -
-
-
- {/* URL */} -
-
- {/* Test */} - {/* Edit */} - {/* Delete */} -
-
-
- {/* Level filters */} - - - {/* bullet */} - {/* Trigger filters */} - - - - - - {/* bullet */} - {/* Data options */} - - -
-
-
- ))} -
- ) : webhooks.length === 0 ? ( -
- Click "Add Webhook" below to get started -
- ) : ( - <> - {filteredWebhooks.map((webhook, index) => ( -
- -
-
-
-
- - {webhook.url} - -
- - - - - - Copy webhook URL - - - - {/* Test Status inline for this specific webhook */} - {testStatus && - testStatus.webhookId === webhook.id && - testStatus.type === 'error' && ( -
- - {testStatus.message} -
- )} -
- -
- - - - - - Test webhook - - - - - - - - Edit webhook - - - - - - - - Delete webhook - - -
-
- -
- {webhook.levelFilter.map((level) => ( - - {level} - - ))} - - {webhook.triggerFilter.map((trigger) => ( - - {trigger} - - ))} - {(webhook.includeFinalOutput || - webhook.includeTraceSpans || - webhook.includeRateLimits || - webhook.includeUsageData) && ( - <> - - {webhook.includeFinalOutput && ( - - output - - )} - {webhook.includeTraceSpans && ( - - traces - - )} - {webhook.includeRateLimits && ( - - limits - - )} - {webhook.includeUsageData && ( - usage - )} - - )} -
-
-
- ))} - {/* Show message when search has no results but there are webhooks */} - {searchTerm.trim() && - filteredWebhooks.length === 0 && - webhooks.length > 0 && ( -
- No webhooks found matching "{searchTerm}" -
- )} - - )} -
- ) : ( -
- {/* Form Header */} -
-

- {editingWebhookId ? 'Edit Webhook' : 'Create New Webhook'} -

-

- Configure webhook notifications for workflow executions -

-
- - {/* General errors */} - {fieldErrors.general && fieldErrors.general.length > 0 && ( -
-
- -
- {fieldErrors.general.map((error, index) => ( -

{error}

- ))} -
-
-
- )} - -
-
- - { - setNewWebhook({ ...newWebhook, url: e.target.value }) - setFieldErrors({ ...fieldErrors, url: undefined }) - }} - className='h-9 rounded-[8px]' - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' - data-form-type='other' - /> -

- The URL where webhook notifications will be sent -

- {fieldErrors.url && fieldErrors.url.length > 0 && ( -
- {fieldErrors.url.map((error, index) => ( -

{error}

- ))} -
- )} -
- -
- -
- { - setNewWebhook({ ...newWebhook, secret: e.target.value }) - setFieldErrors({ ...fieldErrors, general: undefined }) - }} - className='h-9 rounded-[8px] pr-32' - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' - data-form-type='other' - style={ - !showSecret - ? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties) - : undefined - } - /> -
- - - - - - Generate secure secret - - - - - - - - Copy secret - - - - - - - - {showSecret ? 'Hide secret' : 'Show secret'} - - -
-
-

- Used to sign webhook payloads with HMAC-SHA256 -

-
- -
- -
- {(['info', 'error'] as NotificationLogLevel[]).map((level) => ( -
-
- -

- Receive notifications for {level} level logs -

-
- { - if (checked) { - setNewWebhook({ - ...newWebhook, - levelFilter: [...newWebhook.levelFilter, level], - }) - } else { - setNewWebhook({ - ...newWebhook, - levelFilter: newWebhook.levelFilter.filter((l) => l !== level), - }) - } - setFieldErrors({ ...fieldErrors, levelFilter: undefined }) - }} - /> -
- ))} -
- {fieldErrors.levelFilter && fieldErrors.levelFilter.length > 0 && ( -
- {fieldErrors.levelFilter.map((error, index) => ( -

{error}

- ))} -
- )} -
- -
- -
- {( - ['api', 'webhook', 'schedule', 'manual', 'chat'] as NotificationTrigger[] - ).map((trigger) => ( -
-
- -

- Notify when workflow is triggered via {trigger} -

-
- { - if (checked) { - setNewWebhook({ - ...newWebhook, - triggerFilter: [...newWebhook.triggerFilter, trigger], - }) - } else { - setNewWebhook({ - ...newWebhook, - triggerFilter: newWebhook.triggerFilter.filter( - (t) => t !== trigger - ), - }) - } - setFieldErrors({ ...fieldErrors, triggerFilter: undefined }) - }} - /> -
- ))} -
- {fieldErrors.triggerFilter && fieldErrors.triggerFilter.length > 0 && ( -
- {fieldErrors.triggerFilter.map((error, index) => ( -

{error}

- ))} -
- )} -
- -
- -
-
-
- -

- Include workflow execution results -

-
- - setNewWebhook({ ...newWebhook, includeFinalOutput: checked }) - } - /> -
-
-
- -

- Detailed execution steps -

-
- - setNewWebhook({ ...newWebhook, includeTraceSpans: checked }) - } - /> -
-
-
- -

- Workflow execution limits -

-
- - setNewWebhook({ ...newWebhook, includeRateLimits: checked }) - } - /> -
-
-
- -

- Billing period cost and limits -

-
- - setNewWebhook({ ...newWebhook, includeUsageData: checked }) - } - /> -
-
-

- By default, only basic metadata and cost information is included -

-
-
-
- )} -
-
-
- - {/* Footer */} -
-
- {showForm ? ( - <> - - - - ) : isLoading ? ( - <> - -
- - ) : ( - <> - -
- - )} -
-
- - - {/* Delete Confirmation Dialog */} - - - Delete Webhook - -

- This will permanently remove the webhook configuration and stop all notifications.{' '} - This action cannot be undone. -

-
- - - Cancel - - - {isDeleting ? 'Deleting...' : 'Delete'} - - -
-
-
- ) -} diff --git a/apps/sim/background/logs-webhook-delivery.ts b/apps/sim/background/logs-webhook-delivery.ts deleted file mode 100644 index 394af902ec3..00000000000 --- a/apps/sim/background/logs-webhook-delivery.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { createHmac } from 'crypto' -import { db } from '@sim/db' -import { - workflowLogWebhook, - workflowLogWebhookDelivery, - workflow as workflowTable, -} from '@sim/db/schema' -import { task, wait } from '@trigger.dev/sdk' -import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' -import { v4 as uuidv4 } from 'uuid' -import { decryptSecret } from '@/lib/core/security/encryption' -import { createLogger } from '@/lib/logs/console/logger' -import type { WorkflowExecutionLog } from '@/lib/logs/types' - -const logger = createLogger('LogsWebhookDelivery') - -// Quick retry strategy: 5 attempts over ~15 minutes -// Most webhook failures are transient and resolve quickly -const MAX_ATTEMPTS = 5 -const RETRY_DELAYS = [ - 5 * 1000, // 5 seconds (1st retry) - 15 * 1000, // 15 seconds (2nd retry) - 60 * 1000, // 1 minute (3rd retry) - 3 * 60 * 1000, // 3 minutes (4th retry) - 10 * 60 * 1000, // 10 minutes (5th and final retry) -] - -// Add jitter to prevent thundering herd problem (up to 10% of delay) -function getRetryDelayWithJitter(baseDelay: number): number { - const jitter = Math.random() * 0.1 * baseDelay - return Math.floor(baseDelay + jitter) -} - -interface WebhookPayload { - id: string - type: 'workflow.execution.completed' - timestamp: number - data: { - workflowId: string - executionId: string - status: 'success' | 'error' - level: string - trigger: string - startedAt: string - endedAt: string - totalDurationMs: number - cost?: any - files?: any - finalOutput?: any - traceSpans?: any[] - rateLimits?: { - sync: { - limit: number - remaining: number - resetAt: string - } - async: { - limit: number - remaining: number - resetAt: string - } - } - usage?: { - currentPeriodCost: number - limit: number - plan: string - isExceeded: boolean - } - } - links: { - log: string - execution: string - } -} - -function generateSignature(secret: string, timestamp: number, body: string): string { - const signatureBase = `${timestamp}.${body}` - const hmac = createHmac('sha256', secret) - hmac.update(signatureBase) - return hmac.digest('hex') -} - -export const logsWebhookDelivery = task({ - id: 'logs-webhook-delivery', - retry: { - maxAttempts: 1, // We handle retries manually within the task - }, - run: async (params: { - deliveryId: string - subscriptionId: string - log: WorkflowExecutionLog - }) => { - const { deliveryId, subscriptionId, log } = params - - try { - const [subscription] = await db - .select() - .from(workflowLogWebhook) - .where(eq(workflowLogWebhook.id, subscriptionId)) - .limit(1) - - if (!subscription || !subscription.active) { - logger.warn(`Subscription ${subscriptionId} not found or inactive`) - await db - .update(workflowLogWebhookDelivery) - .set({ - status: 'failed', - errorMessage: 'Subscription not found or inactive', - updatedAt: new Date(), - }) - .where(eq(workflowLogWebhookDelivery.id, deliveryId)) - return - } - - // Atomically claim this delivery row for processing and increment attempts - const claimed = await db - .update(workflowLogWebhookDelivery) - .set({ - status: 'in_progress', - attempts: sql`${workflowLogWebhookDelivery.attempts} + 1`, - lastAttemptAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(workflowLogWebhookDelivery.id, deliveryId), - eq(workflowLogWebhookDelivery.status, 'pending'), - // Only claim if not scheduled in the future or schedule has arrived - or( - isNull(workflowLogWebhookDelivery.nextAttemptAt), - lte(workflowLogWebhookDelivery.nextAttemptAt, new Date()) - ) - ) - ) - .returning({ attempts: workflowLogWebhookDelivery.attempts }) - - if (claimed.length === 0) { - logger.info(`Delivery ${deliveryId} not claimable (already in progress or not due)`) - return - } - - const attempts = claimed[0].attempts - const timestamp = Date.now() - const eventId = `evt_${uuidv4()}` - - const payload: WebhookPayload = { - id: eventId, - type: 'workflow.execution.completed', - timestamp, - data: { - workflowId: log.workflowId, - executionId: log.executionId, - status: log.level === 'error' ? 'error' : 'success', - level: log.level, - trigger: log.trigger, - startedAt: log.startedAt, - endedAt: log.endedAt || log.startedAt, - totalDurationMs: log.totalDurationMs, - cost: log.cost, - files: (log as any).files, - }, - links: { - log: `/v1/logs/${log.id}`, - execution: `/v1/logs/executions/${log.executionId}`, - }, - } - - if (subscription.includeFinalOutput && log.executionData) { - payload.data.finalOutput = (log.executionData as any).finalOutput - } - - if (subscription.includeTraceSpans && log.executionData) { - payload.data.traceSpans = (log.executionData as any).traceSpans - } - - // Fetch rate limits and usage data if requested - if ((subscription.includeRateLimits || subscription.includeUsageData) && log.executionData) { - const executionData = log.executionData as any - - const needsRateLimits = subscription.includeRateLimits && executionData.includeRateLimits - const needsUsage = subscription.includeUsageData && executionData.includeUsageData - if (needsRateLimits || needsUsage) { - const { getUserLimits } = await import('@/app/api/v1/logs/meta') - const workflow = await db - .select() - .from(workflowTable) - .where(eq(workflowTable.id, log.workflowId)) - .limit(1) - - if (workflow.length > 0) { - try { - const limits = await getUserLimits(workflow[0].userId) - if (needsRateLimits) { - payload.data.rateLimits = limits.workflowExecutionRateLimit - } - if (needsUsage) { - payload.data.usage = limits.usage - } - } catch (error) { - logger.warn('Failed to fetch limits/usage for webhook', { error }) - } - } - } - } - - const body = JSON.stringify(payload) - const headers: Record = { - 'Content-Type': 'application/json', - 'sim-event': 'workflow.execution.completed', - 'sim-timestamp': timestamp.toString(), - 'sim-delivery-id': deliveryId, - 'Idempotency-Key': deliveryId, - } - - if (subscription.secret) { - const { decrypted } = await decryptSecret(subscription.secret) - const signature = generateSignature(decrypted, timestamp, body) - headers['sim-signature'] = `t=${timestamp},v1=${signature}` - } - - logger.info(`Attempting webhook delivery ${deliveryId} (attempt ${attempts})`, { - url: subscription.url, - executionId: log.executionId, - }) - - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 30000) - - try { - const response = await fetch(subscription.url, { - method: 'POST', - headers, - body, - signal: controller.signal, - }) - - clearTimeout(timeoutId) - - const responseBody = await response.text().catch(() => '') - const truncatedBody = responseBody.slice(0, 1000) - - if (response.ok) { - await db - .update(workflowLogWebhookDelivery) - .set({ - status: 'success', - attempts, - lastAttemptAt: new Date(), - responseStatus: response.status, - responseBody: truncatedBody, - errorMessage: null, - updatedAt: new Date(), - }) - .where( - and( - eq(workflowLogWebhookDelivery.id, deliveryId), - eq(workflowLogWebhookDelivery.status, 'in_progress') - ) - ) - - logger.info(`Webhook delivery ${deliveryId} succeeded`, { - status: response.status, - executionId: log.executionId, - }) - - return { success: true } - } - - const isRetryable = response.status >= 500 || response.status === 429 - - if (!isRetryable || attempts >= MAX_ATTEMPTS) { - await db - .update(workflowLogWebhookDelivery) - .set({ - status: 'failed', - attempts, - lastAttemptAt: new Date(), - responseStatus: response.status, - responseBody: truncatedBody, - errorMessage: `HTTP ${response.status}`, - updatedAt: new Date(), - }) - .where( - and( - eq(workflowLogWebhookDelivery.id, deliveryId), - eq(workflowLogWebhookDelivery.status, 'in_progress') - ) - ) - - logger.warn(`Webhook delivery ${deliveryId} failed permanently`, { - status: response.status, - attempts, - executionId: log.executionId, - }) - - return { success: false } - } - - const baseDelay = RETRY_DELAYS[Math.min(attempts - 1, RETRY_DELAYS.length - 1)] - const delayWithJitter = getRetryDelayWithJitter(baseDelay) - const nextAttemptAt = new Date(Date.now() + delayWithJitter) - - await db - .update(workflowLogWebhookDelivery) - .set({ - status: 'pending', - attempts, - lastAttemptAt: new Date(), - nextAttemptAt, - responseStatus: response.status, - responseBody: truncatedBody, - errorMessage: `HTTP ${response.status} - will retry`, - updatedAt: new Date(), - }) - .where( - and( - eq(workflowLogWebhookDelivery.id, deliveryId), - eq(workflowLogWebhookDelivery.status, 'in_progress') - ) - ) - - // Schedule the next retry - await wait.for({ seconds: delayWithJitter / 1000 }) - - // Recursively call the task for retry - await logsWebhookDelivery.trigger({ - deliveryId, - subscriptionId, - log, - }) - - return { success: false, retrying: true } - } catch (error: any) { - clearTimeout(timeoutId) - - if (error.name === 'AbortError') { - logger.error(`Webhook delivery ${deliveryId} timed out`, { - executionId: log.executionId, - attempts, - }) - error.message = 'Request timeout after 30 seconds' - } - - const baseDelay = RETRY_DELAYS[Math.min(attempts - 1, RETRY_DELAYS.length - 1)] - const delayWithJitter = getRetryDelayWithJitter(baseDelay) - const nextAttemptAt = new Date(Date.now() + delayWithJitter) - - await db - .update(workflowLogWebhookDelivery) - .set({ - status: attempts >= MAX_ATTEMPTS ? 'failed' : 'pending', - attempts, - lastAttemptAt: new Date(), - nextAttemptAt: attempts >= MAX_ATTEMPTS ? null : nextAttemptAt, - errorMessage: error.message, - updatedAt: new Date(), - }) - .where( - and( - eq(workflowLogWebhookDelivery.id, deliveryId), - eq(workflowLogWebhookDelivery.status, 'in_progress') - ) - ) - - if (attempts >= MAX_ATTEMPTS) { - logger.error(`Webhook delivery ${deliveryId} failed after ${attempts} attempts`, { - error: error.message, - executionId: log.executionId, - }) - return { success: false } - } - - // Schedule the next retry - await wait.for({ seconds: delayWithJitter / 1000 }) - - // Recursively call the task for retry - await logsWebhookDelivery.trigger({ - deliveryId, - subscriptionId, - log, - }) - - return { success: false, retrying: true } - } - } catch (error: any) { - logger.error(`Webhook delivery ${deliveryId} encountered unexpected error`, { - error: error.message, - stack: error.stack, - }) - - // Mark as failed for unexpected errors - await db - .update(workflowLogWebhookDelivery) - .set({ - status: 'failed', - errorMessage: `Unexpected error: ${error.message}`, - updatedAt: new Date(), - }) - .where(eq(workflowLogWebhookDelivery.id, deliveryId)) - - return { success: false, error: error.message } - } - }, -}) diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts new file mode 100644 index 00000000000..540c5aa4fc3 --- /dev/null +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -0,0 +1,686 @@ +import { createHmac } from 'crypto' +import { db } from '@sim/db' +import { + account, + workflow as workflowTable, + workspaceNotificationDelivery, + workspaceNotificationSubscription, +} from '@sim/db/schema' +import { task } from '@trigger.dev/sdk' +import { and, eq, isNull, lte, or, sql } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { decryptSecret } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { createLogger } from '@/lib/logs/console/logger' +import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' +import { sendEmail } from '@/lib/messaging/email/mailer' +import type { AlertConfig } from '@/lib/notifications/alert-rules' +import { RateLimiter } from '@/services/queue' + +const logger = createLogger('WorkspaceNotificationDelivery') + +const MAX_ATTEMPTS = 5 +const RETRY_DELAYS = [5 * 1000, 15 * 1000, 60 * 1000, 3 * 60 * 1000, 10 * 60 * 1000] + +function getRetryDelayWithJitter(baseDelay: number): number { + const jitter = Math.random() * 0.1 * baseDelay + return Math.floor(baseDelay + jitter) +} + +interface NotificationPayload { + id: string + type: 'workflow.execution.completed' + timestamp: number + data: { + workflowId: string + workflowName?: string + executionId: string + status: 'success' | 'error' + level: string + trigger: string + startedAt: string + endedAt: string + totalDurationMs: number + cost?: Record + finalOutput?: unknown + traceSpans?: unknown[] + rateLimits?: Record + usage?: Record + } +} + +function generateSignature(secret: string, timestamp: number, body: string): string { + const signatureBase = `${timestamp}.${body}` + const hmac = createHmac('sha256', secret) + hmac.update(signatureBase) + return hmac.digest('hex') +} + +async function buildPayload( + log: WorkflowExecutionLog, + subscription: typeof workspaceNotificationSubscription.$inferSelect +): Promise { + const workflowData = await db + .select({ name: workflowTable.name, userId: workflowTable.userId }) + .from(workflowTable) + .where(eq(workflowTable.id, log.workflowId)) + .limit(1) + + const timestamp = Date.now() + const executionData = (log.executionData || {}) as Record + const userId = workflowData[0]?.userId + + const payload: NotificationPayload = { + id: `evt_${uuidv4()}`, + type: 'workflow.execution.completed', + timestamp, + data: { + workflowId: log.workflowId, + workflowName: workflowData[0]?.name || 'Unknown Workflow', + executionId: log.executionId, + status: log.level === 'error' ? 'error' : 'success', + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt, + endedAt: log.endedAt, + totalDurationMs: log.totalDurationMs, + cost: log.cost as Record, + }, + } + + if (subscription.includeFinalOutput && executionData.finalOutput) { + payload.data.finalOutput = executionData.finalOutput + } + + if (subscription.includeTraceSpans && executionData.traceSpans) { + payload.data.traceSpans = executionData.traceSpans as unknown[] + } + + if (subscription.includeRateLimits && userId) { + try { + const userSubscription = await getHighestPrioritySubscription(userId) + const rateLimiter = new RateLimiter() + const triggerType = log.trigger === 'api' ? 'api' : 'manual' + + const [syncStatus, asyncStatus] = await Promise.all([ + rateLimiter.getRateLimitStatusWithSubscription( + userId, + userSubscription, + triggerType, + false + ), + rateLimiter.getRateLimitStatusWithSubscription(userId, userSubscription, triggerType, true), + ]) + + payload.data.rateLimits = { + sync: { + limit: syncStatus.limit, + remaining: syncStatus.remaining, + resetAt: syncStatus.resetAt.toISOString(), + }, + async: { + limit: asyncStatus.limit, + remaining: asyncStatus.remaining, + resetAt: asyncStatus.resetAt.toISOString(), + }, + } + } catch (error) { + logger.warn('Failed to fetch rate limits for notification', { error, userId }) + } + } + + if (subscription.includeUsageData && userId) { + try { + const usageData = await checkUsageStatus(userId) + payload.data.usage = { + currentPeriodCost: usageData.currentUsage, + limit: usageData.limit, + percentUsed: usageData.percentUsed, + isExceeded: usageData.isExceeded, + } + } catch (error) { + logger.warn('Failed to fetch usage data for notification', { error, userId }) + } + } + + return payload +} + +interface WebhookConfig { + url: string + secret?: string +} + +interface SlackConfig { + channelId: string + channelName: string + accountId: string +} + +async function deliverWebhook( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + payload: NotificationPayload +): Promise<{ success: boolean; status?: number; error?: string }> { + const webhookConfig = subscription.webhookConfig as WebhookConfig | null + if (!webhookConfig?.url) { + return { success: false, error: 'No webhook URL configured' } + } + + const body = JSON.stringify(payload) + const deliveryId = `delivery_${uuidv4()}` + const headers: Record = { + 'Content-Type': 'application/json', + 'sim-event': 'workflow.execution.completed', + 'sim-timestamp': payload.timestamp.toString(), + 'sim-delivery-id': deliveryId, + 'Idempotency-Key': deliveryId, + } + + if (webhookConfig.secret) { + const { decrypted } = await decryptSecret(webhookConfig.secret) + const signature = generateSignature(decrypted, payload.timestamp, body) + headers['sim-signature'] = `t=${payload.timestamp},v1=${signature}` + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) + + try { + const response = await fetch(webhookConfig.url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + return { + success: response.ok, + status: response.status, + error: response.ok ? undefined : `HTTP ${response.status}`, + } + } catch (error: unknown) { + clearTimeout(timeoutId) + const err = error as Error & { name?: string } + return { + success: false, + error: err.name === 'AbortError' ? 'Request timeout' : err.message, + } + } +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms` + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` + return `${(ms / 60000).toFixed(1)}m` +} + +function formatCost(cost?: Record): string { + if (!cost?.total) return 'N/A' + const total = cost.total as number + return `$${total.toFixed(4)}` +} + +function buildLogUrl(workspaceId: string, executionId: string): string { + return `${getBaseUrl()}/workspace/${workspaceId}/logs?search=${encodeURIComponent(executionId)}` +} + +function formatAlertReason(alertConfig: AlertConfig): string { + switch (alertConfig.rule) { + case 'consecutive_failures': + return `${alertConfig.consecutiveFailures} consecutive failures detected` + case 'failure_rate': + return `Failure rate exceeded ${alertConfig.failureRatePercent}% over ${alertConfig.windowHours}h` + case 'latency_threshold': + return `Execution exceeded ${Math.round((alertConfig.durationThresholdMs || 0) / 1000)}s duration threshold` + case 'latency_spike': + return `Execution was ${alertConfig.latencySpikePercent}% slower than average` + case 'cost_threshold': + return `Execution cost exceeded $${alertConfig.costThresholdDollars} threshold` + case 'no_activity': + return `No workflow activity detected in ${alertConfig.inactivityHours}h` + case 'error_count': + return `${alertConfig.errorCountThreshold} errors detected in ${alertConfig.windowHours}h window` + default: + return 'Alert condition met' + } +} + +function formatJsonForEmail(data: unknown, label: string): string { + if (!data) return '' + const json = JSON.stringify(data, null, 2) + const escapedJson = json.replace(//g, '>') + return ` +
+

${label}

+
${escapedJson}
+
+ ` +} + +async function deliverEmail( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + payload: NotificationPayload, + alertConfig?: AlertConfig +): Promise<{ success: boolean; error?: string }> { + if (!subscription.emailRecipients || subscription.emailRecipients.length === 0) { + return { success: false, error: 'No email recipients configured' } + } + + const isError = payload.data.status !== 'success' + const statusText = isError ? 'Error' : 'Success' + const logUrl = buildLogUrl(subscription.workspaceId, payload.data.executionId) + const baseUrl = getBaseUrl() + const alertReason = alertConfig ? formatAlertReason(alertConfig) : null + + // Build subject line + const subject = alertReason + ? `Alert: ${payload.data.workflowName}` + : isError + ? `Error Alert: ${payload.data.workflowName}` + : `Workflow Completed: ${payload.data.workflowName}` + + let includedDataHtml = '' + let includedDataText = '' + + if (payload.data.finalOutput) { + includedDataHtml += formatJsonForEmail(payload.data.finalOutput, 'Final Output') + includedDataText += `\n\nFinal Output:\n${JSON.stringify(payload.data.finalOutput, null, 2)}` + } + + if ( + payload.data.traceSpans && + Array.isArray(payload.data.traceSpans) && + payload.data.traceSpans.length > 0 + ) { + includedDataHtml += formatJsonForEmail(payload.data.traceSpans, 'Trace Spans') + includedDataText += `\n\nTrace Spans:\n${JSON.stringify(payload.data.traceSpans, null, 2)}` + } + + if (payload.data.rateLimits) { + includedDataHtml += formatJsonForEmail(payload.data.rateLimits, 'Rate Limits') + includedDataText += `\n\nRate Limits:\n${JSON.stringify(payload.data.rateLimits, null, 2)}` + } + + if (payload.data.usage) { + includedDataHtml += formatJsonForEmail(payload.data.usage, 'Usage Data') + includedDataText += `\n\nUsage Data:\n${JSON.stringify(payload.data.usage, null, 2)}` + } + + const result = await sendEmail({ + to: subscription.emailRecipients, + subject, + html: ` + + + + + + + +
+ +
+ Sim Studio +
+ + +
+
+
+
+
+ + +
+

+ ${alertReason ? 'Alert Triggered' : isError ? 'Workflow Execution Failed' : 'Workflow Execution Completed'} +

+ ${alertReason ? `

Reason: ${alertReason}

` : ''} + + + + + + + + + + + + + + + + + + + + + + +
Workflow${payload.data.workflowName}
Status${statusText}
Trigger${payload.data.trigger}
Duration${formatDuration(payload.data.totalDurationMs)}
Cost${formatCost(payload.data.cost)}
+ + + View Execution Log → + + + ${includedDataHtml} + +

+ Best regards,
+ The Sim Team +

+
+
+ + +
+

+ © ${new Date().getFullYear()} Sim Studio, All Rights Reserved +

+

+ Privacy Policy • + Terms of Service +

+
+ + + `, + text: `${subject}\n${alertReason ? `\nReason: ${alertReason}\n` : ''}\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${formatDuration(payload.data.totalDurationMs)}\nCost: ${formatCost(payload.data.cost)}\n\nView Log: ${logUrl}${includedDataText}`, + emailType: 'notifications', + }) + + return { success: result.success, error: result.success ? undefined : result.message } +} + +async function deliverSlack( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + payload: NotificationPayload, + alertConfig?: AlertConfig +): Promise<{ success: boolean; error?: string }> { + const slackConfig = subscription.slackConfig as SlackConfig | null + if (!slackConfig?.channelId || !slackConfig?.accountId) { + return { success: false, error: 'No Slack channel or account configured' } + } + + const [slackAccount] = await db + .select({ accessToken: account.accessToken, userId: account.userId }) + .from(account) + .where(eq(account.id, slackConfig.accountId)) + .limit(1) + + if (!slackAccount?.accessToken) { + return { success: false, error: 'Slack account not found or not connected' } + } + + const alertReason = alertConfig ? formatAlertReason(alertConfig) : null + const statusEmoji = alertReason + ? ':warning:' + : payload.data.status === 'success' + ? ':white_check_mark:' + : ':x:' + const statusColor = alertReason + ? '#d97706' + : payload.data.status === 'success' + ? '#22c55e' + : '#ef4444' + const logUrl = buildLogUrl(subscription.workspaceId, payload.data.executionId) + + const blocks: Array> = [] + + if (alertReason) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Reason:* ${alertReason}`, + }, + }) + } + + blocks.push( + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` }, + { type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` }, + { type: 'mrkdwn', text: `*Duration:*\n${formatDuration(payload.data.totalDurationMs)}` }, + { type: 'mrkdwn', text: `*Cost:*\n${formatCost(payload.data.cost)}` }, + ], + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'View Log →', emoji: true }, + url: logUrl, + style: 'primary', + }, + ], + } + ) + + if (payload.data.finalOutput) { + const outputStr = JSON.stringify(payload.data.finalOutput, null, 2) + const truncated = outputStr.length > 2900 ? `${outputStr.slice(0, 2900)}...` : outputStr + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Final Output:*\n\`\`\`${truncated}\`\`\``, + }, + }) + } + + if ( + payload.data.traceSpans && + Array.isArray(payload.data.traceSpans) && + payload.data.traceSpans.length > 0 + ) { + const spansSummary = (payload.data.traceSpans as TraceSpan[]) + .map((span) => { + const status = span.status === 'success' ? '✓' : '✗' + return `${status} ${span.name || 'Unknown'} (${formatDuration(span.duration || 0)})` + }) + .join('\n') + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Trace Spans:*\n\`\`\`${spansSummary}\`\`\``, + }, + }) + } + + if (payload.data.rateLimits) { + const limitsStr = JSON.stringify(payload.data.rateLimits, null, 2) + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Rate Limits:*\n\`\`\`${limitsStr}\`\`\``, + }, + }) + } + + if (payload.data.usage) { + const usageStr = JSON.stringify(payload.data.usage, null, 2) + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Usage Data:*\n\`\`\`${usageStr}\`\`\``, + }, + }) + } + + blocks.push({ + type: 'context', + elements: [{ type: 'mrkdwn', text: `Execution ID: \`${payload.data.executionId}\`` }], + }) + + const fallbackText = alertReason + ? `⚠️ Alert: ${payload.data.workflowName} - ${alertReason}` + : `${payload.data.status === 'success' ? '✅' : '❌'} Workflow ${payload.data.workflowName}: ${payload.data.status}` + + const slackPayload = { + channel: slackConfig.channelId, + attachments: [{ color: statusColor, blocks }], + text: fallbackText, + } + + try { + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${slackAccount.accessToken}`, + }, + body: JSON.stringify(slackPayload), + }) + + const result = await response.json() + + return { success: result.ok, error: result.ok ? undefined : result.error } + } catch (error: unknown) { + const err = error as Error + return { success: false, error: err.message } + } +} + +async function updateDeliveryStatus( + deliveryId: string, + status: 'success' | 'failed' | 'pending', + error?: string, + responseStatus?: number, + nextAttemptAt?: Date +) { + await db + .update(workspaceNotificationDelivery) + .set({ + status, + errorMessage: error || null, + responseStatus: responseStatus || null, + nextAttemptAt: nextAttemptAt || null, + updatedAt: new Date(), + }) + .where(eq(workspaceNotificationDelivery.id, deliveryId)) +} + +export interface NotificationDeliveryParams { + deliveryId: string + subscriptionId: string + notificationType: 'webhook' | 'email' | 'slack' + log: WorkflowExecutionLog + alertConfig?: AlertConfig +} + +export async function executeNotificationDelivery(params: NotificationDeliveryParams) { + const { deliveryId, subscriptionId, notificationType, log, alertConfig } = params + + try { + const [subscription] = await db + .select() + .from(workspaceNotificationSubscription) + .where(eq(workspaceNotificationSubscription.id, subscriptionId)) + .limit(1) + + if (!subscription || !subscription.active) { + logger.warn(`Subscription ${subscriptionId} not found or inactive`) + await updateDeliveryStatus(deliveryId, 'failed', 'Subscription not found or inactive') + return + } + + const claimed = await db + .update(workspaceNotificationDelivery) + .set({ + status: 'in_progress', + attempts: sql`${workspaceNotificationDelivery.attempts} + 1`, + lastAttemptAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(workspaceNotificationDelivery.id, deliveryId), + eq(workspaceNotificationDelivery.status, 'pending'), + or( + isNull(workspaceNotificationDelivery.nextAttemptAt), + lte(workspaceNotificationDelivery.nextAttemptAt, new Date()) + ) + ) + ) + .returning({ attempts: workspaceNotificationDelivery.attempts }) + + if (claimed.length === 0) { + logger.info(`Delivery ${deliveryId} not claimable`) + return + } + + const attempts = claimed[0].attempts + const payload = await buildPayload(log, subscription) + + let result: { success: boolean; status?: number; error?: string } + + switch (notificationType) { + case 'webhook': + result = await deliverWebhook(subscription, payload) + break + case 'email': + result = await deliverEmail(subscription, payload, alertConfig) + break + case 'slack': + result = await deliverSlack(subscription, payload, alertConfig) + break + default: + result = { success: false, error: 'Unknown notification type' } + } + + if (result.success) { + await updateDeliveryStatus(deliveryId, 'success', undefined, result.status) + logger.info(`${notificationType} notification delivered successfully`, { deliveryId }) + } else { + if (attempts < MAX_ATTEMPTS) { + const retryDelay = getRetryDelayWithJitter( + RETRY_DELAYS[attempts - 1] || RETRY_DELAYS[RETRY_DELAYS.length - 1] + ) + const nextAttemptAt = new Date(Date.now() + retryDelay) + + await updateDeliveryStatus( + deliveryId, + 'pending', + result.error, + result.status, + nextAttemptAt + ) + + logger.info( + `${notificationType} notification failed, scheduled retry ${attempts}/${MAX_ATTEMPTS}`, + { + deliveryId, + error: result.error, + } + ) + } else { + await updateDeliveryStatus(deliveryId, 'failed', result.error, result.status) + logger.error(`${notificationType} notification failed after ${MAX_ATTEMPTS} attempts`, { + deliveryId, + error: result.error, + }) + } + } + } catch (error) { + logger.error('Notification delivery failed', { deliveryId, error }) + await updateDeliveryStatus(deliveryId, 'failed', 'Internal error') + } +} + +export const workspaceNotificationDeliveryTask = task({ + id: 'workspace-notification-delivery', + retry: { maxAttempts: 1 }, + run: async (params: NotificationDeliveryParams) => executeNotificationDelivery(params), +}) diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index d9829ab9fe2..e33dd81f6ab 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -13,7 +13,7 @@ import { useState, } from 'react' import { cva, type VariantProps } from 'class-variance-authority' -import { ChevronDown, Loader2 } from 'lucide-react' +import { ChevronDown, Loader2, Search } from 'lucide-react' import { cn } from '@/lib/core/utils/cn' import { Input } from '../input/input' import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover' @@ -86,6 +86,10 @@ export interface ComboboxProps error?: string | null /** Callback when popover open state changes */ onOpenChange?: (open: boolean) => void + /** Enable search input in dropdown (useful for multiselect) */ + searchable?: boolean + /** Placeholder for search input */ + searchPlaceholder?: string /** Size variant */ size?: 'default' | 'sm' /** Dropdown alignment */ @@ -122,6 +126,8 @@ const Combobox = forwardRef( isLoading = false, error = null, onOpenChange, + searchable = false, + searchPlaceholder = 'Search...', align = 'start', dropdownWidth = 'trigger', ...props @@ -130,6 +136,8 @@ const Combobox = forwardRef( ) => { const [open, setOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) + const [searchQuery, setSearchQuery] = useState('') + const searchInputRef = useRef(null) const containerRef = useRef(null) const dropdownRef = useRef(null) const internalInputRef = useRef(null) @@ -143,26 +151,38 @@ const Combobox = forwardRef( ) /** - * Filter options based on current value + * Filter options based on current value or search query */ const filteredOptions = useMemo(() => { - if (!filterOptions || !value || !open) return options + let result = options - const currentValue = value.toString().toLowerCase() + // Filter by editable input value + if (filterOptions && value && open) { + const currentValue = value.toString().toLowerCase() + const exactMatch = options.find( + (opt) => opt.value === value || opt.label.toLowerCase() === currentValue + ) + if (!exactMatch) { + result = result.filter((option) => { + const label = option.label.toLowerCase() + const optionValue = option.value.toLowerCase() + return label.includes(currentValue) || optionValue.includes(currentValue) + }) + } + } - // If value exactly matches an option, show all - const exactMatch = options.find( - (opt) => opt.value === value || opt.label.toLowerCase() === currentValue - ) - if (exactMatch) return options + // Filter by search query (for searchable mode) + if (searchable && searchQuery) { + const query = searchQuery.toLowerCase() + result = result.filter((option) => { + const label = option.label.toLowerCase() + const optionValue = option.value.toLowerCase() + return label.includes(query) || optionValue.includes(query) + }) + } - // Filter options - return options.filter((option) => { - const label = option.label.toLowerCase() - const optionValue = option.value.toLowerCase() - return label.includes(currentValue) || optionValue.includes(currentValue) - }) - }, [options, value, open, filterOptions]) + return result + }, [options, value, open, filterOptions, searchable, searchQuery]) /** * Handles selection of an option @@ -348,6 +368,7 @@ const Combobox = forwardRef( open={open} onOpenChange={(next) => { setOpen(next) + if (!next) setSearchQuery('') onOpenChange?.(next) }} > @@ -453,6 +474,9 @@ const Combobox = forwardRef( style={typeof dropdownWidth === 'number' ? { width: `${dropdownWidth}px` } : undefined} onOpenAutoFocus={(e) => { e.preventDefault() + if (searchable) { + setTimeout(() => searchInputRef.current?.focus(), 0) + } }} onInteractOutside={(e) => { // If the user clicks the anchor/trigger while the popover is open, @@ -464,6 +488,24 @@ const Combobox = forwardRef( } }} > + {searchable && ( +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setOpen(false) + setSearchQuery('') + } + }} + /> +
+ )} { @@ -498,7 +540,9 @@ const Combobox = forwardRef( ) : filteredOptions.length === 0 ? (
- {editable && value ? 'No matching options found' : 'No options available'} + {searchQuery || (editable && value) + ? 'No matching options found' + : 'No options available'}
) : (
diff --git a/apps/sim/hooks/queries/notifications.ts b/apps/sim/hooks/queries/notifications.ts new file mode 100644 index 00000000000..2b05e421e34 --- /dev/null +++ b/apps/sim/hooks/queries/notifications.ts @@ -0,0 +1,297 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('NotificationQueries') + +/** + * Query key factories for notification-related queries + */ +export const notificationKeys = { + all: ['notifications'] as const, + lists: () => [...notificationKeys.all, 'list'] as const, + list: (workspaceId: string | undefined) => + [...notificationKeys.lists(), workspaceId ?? ''] as const, + details: () => [...notificationKeys.all, 'detail'] as const, + detail: (workspaceId: string, notificationId: string) => + [...notificationKeys.details(), workspaceId, notificationId] as const, +} + +type NotificationType = 'webhook' | 'email' | 'slack' +type LogLevel = 'info' | 'error' +type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' + +type AlertRuleType = + | 'consecutive_failures' + | 'failure_rate' + | 'latency_threshold' + | 'latency_spike' + | 'cost_threshold' + | 'no_activity' + | 'error_count' + +interface AlertConfig { + rule: AlertRuleType + consecutiveFailures?: number + failureRatePercent?: number + windowHours?: number + durationThresholdMs?: number + latencySpikePercent?: number + costThresholdDollars?: number + inactivityHours?: number + errorCountThreshold?: number +} + +interface WebhookConfig { + url: string + secret?: string +} + +interface SlackConfig { + channelId: string + channelName: string + accountId: string +} + +export interface NotificationSubscription { + id: string + notificationType: NotificationType + workflowIds: string[] + allWorkflows: boolean + levelFilter: LogLevel[] + triggerFilter: TriggerType[] + includeFinalOutput: boolean + includeTraceSpans: boolean + includeRateLimits: boolean + includeUsageData: boolean + webhookConfig?: WebhookConfig | null + emailRecipients?: string[] | null + slackConfig?: SlackConfig | null + alertConfig?: AlertConfig | null + active: boolean + createdAt: string + updatedAt: string +} + +/** + * Fetch notifications for a workspace + */ +async function fetchNotifications(workspaceId: string): Promise { + const response = await fetch(`/api/workspaces/${workspaceId}/notifications`) + if (!response.ok) { + throw new Error('Failed to fetch notifications') + } + const data = await response.json() + return data.data || [] +} + +/** + * Hook to fetch notifications for a workspace + */ +export function useNotifications(workspaceId?: string) { + return useQuery({ + queryKey: notificationKeys.list(workspaceId), + queryFn: () => fetchNotifications(workspaceId!), + enabled: Boolean(workspaceId), + staleTime: 30 * 1000, + }) +} + +interface CreateNotificationParams { + workspaceId: string + data: { + notificationType: NotificationType + workflowIds: string[] + allWorkflows: boolean + levelFilter: LogLevel[] + triggerFilter: TriggerType[] + includeFinalOutput: boolean + includeTraceSpans: boolean + includeRateLimits: boolean + includeUsageData: boolean + alertConfig?: AlertConfig | null + webhookConfig?: WebhookConfig + emailRecipients?: string[] + slackConfig?: SlackConfig + } +} + +/** + * Hook to create a notification + */ +export function useCreateNotification() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, data }: CreateNotificationParams) => { + const response = await fetch(`/api/workspaces/${workspaceId}/notifications`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to create notification') + } + return response.json() + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) + }, + onError: (error) => { + logger.error('Failed to create notification', { error }) + }, + }) +} + +interface UpdateNotificationParams { + workspaceId: string + notificationId: string + data: Partial & { active?: boolean } +} + +/** + * Hook to update a notification + */ +export function useUpdateNotification() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, notificationId, data }: UpdateNotificationParams) => { + const response = await fetch( + `/api/workspaces/${workspaceId}/notifications/${notificationId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + } + ) + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to update notification') + } + return response.json() + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) + }, + onError: (error) => { + logger.error('Failed to update notification', { error }) + }, + }) +} + +/** + * Hook to toggle notification active state with optimistic update + */ +export function useToggleNotificationActive() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + workspaceId, + notificationId, + active, + }: { + workspaceId: string + notificationId: string + active: boolean + }) => { + const response = await fetch( + `/api/workspaces/${workspaceId}/notifications/${notificationId}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ active }), + } + ) + if (!response.ok) { + throw new Error('Failed to toggle notification') + } + return response.json() + }, + onMutate: async ({ workspaceId, notificationId, active }) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: notificationKeys.list(workspaceId) }) + + // Snapshot previous value + const previousNotifications = queryClient.getQueryData( + notificationKeys.list(workspaceId) + ) + + // Optimistically update + queryClient.setQueryData( + notificationKeys.list(workspaceId), + (old) => old?.map((n) => (n.id === notificationId ? { ...n, active } : n)) + ) + + return { previousNotifications } + }, + onError: (error, { workspaceId }, context) => { + // Rollback on error + if (context?.previousNotifications) { + queryClient.setQueryData(notificationKeys.list(workspaceId), context.previousNotifications) + } + logger.error('Failed to toggle notification', { error }) + }, + }) +} + +interface DeleteNotificationParams { + workspaceId: string + notificationId: string +} + +/** + * Hook to delete a notification + */ +export function useDeleteNotification() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ workspaceId, notificationId }: DeleteNotificationParams) => { + const response = await fetch( + `/api/workspaces/${workspaceId}/notifications/${notificationId}`, + { + method: 'DELETE', + } + ) + if (!response.ok) { + throw new Error('Failed to delete notification') + } + return response.json() + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries({ queryKey: notificationKeys.list(workspaceId) }) + }, + onError: (error) => { + logger.error('Failed to delete notification', { error }) + }, + }) +} + +interface TestNotificationParams { + workspaceId: string + notificationId: string +} + +/** + * Hook to test a notification + */ +export function useTestNotification() { + return useMutation({ + mutationFn: async ({ workspaceId, notificationId }: TestNotificationParams) => { + const response = await fetch( + `/api/workspaces/${workspaceId}/notifications/${notificationId}/test`, + { method: 'POST' } + ) + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.error || 'Failed to send test notification') + } + return response.json() + }, + onError: (error) => { + logger.error('Failed to test notification', { error }) + }, + }) +} diff --git a/apps/sim/hooks/use-slack-accounts.ts b/apps/sim/hooks/use-slack-accounts.ts new file mode 100644 index 00000000000..4bb82543c52 --- /dev/null +++ b/apps/sim/hooks/use-slack-accounts.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from 'react' + +interface SlackAccount { + id: string + accountId: string + providerId: string +} + +interface UseSlackAccountsResult { + accounts: SlackAccount[] + isLoading: boolean + error: string | null + refetch: () => Promise +} + +/** + * Fetches and manages connected Slack accounts for the current user. + * @returns Object containing accounts array, loading state, error state, and refetch function + */ +export function useSlackAccounts(): UseSlackAccountsResult { + const [accounts, setAccounts] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchAccounts = useCallback(async () => { + try { + setIsLoading(true) + setError(null) + const response = await fetch('/api/auth/accounts?provider=slack') + if (response.ok) { + const data = await response.json() + setAccounts(data.accounts || []) + } else { + const data = await response.json().catch(() => ({})) + setError(data.error || 'Failed to load Slack accounts') + setAccounts([]) + } + } catch { + setError('Failed to load Slack accounts') + setAccounts([]) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchAccounts() + }, []) + + return { accounts, isLoading, error, refetch: fetchAccounts } +} diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts index bc6d3bb9dfc..a1b0cc20239 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -1,28 +1,83 @@ import { db } from '@sim/db' -import { workflowLogWebhook, workflowLogWebhookDelivery } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { + workflow, + workspaceNotificationDelivery, + workspaceNotificationSubscription, +} from '@sim/db/schema' +import { and, eq, or, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' +import { env, isTruthy } from '@/lib/core/config/env' import { createLogger } from '@/lib/logs/console/logger' import type { WorkflowExecutionLog } from '@/lib/logs/types' -import { logsWebhookDelivery } from '@/background/logs-webhook-delivery' +import { + type AlertCheckContext, + type AlertConfig, + shouldTriggerAlert, +} from '@/lib/notifications/alert-rules' +import { + executeNotificationDelivery, + workspaceNotificationDeliveryTask, +} from '@/background/workspace-notification-delivery' const logger = createLogger('LogsEventEmitter') +function prepareLogData( + log: WorkflowExecutionLog, + subscription: { + includeFinalOutput: boolean + includeTraceSpans: boolean + } +) { + const preparedLog = { ...log, executionData: {} as Record } + + if (log.executionData) { + const data = log.executionData as Record + const webhookData: Record = {} + + if (subscription.includeFinalOutput && data.finalOutput) { + webhookData.finalOutput = data.finalOutput + } + + if (subscription.includeTraceSpans && data.traceSpans) { + webhookData.traceSpans = data.traceSpans + } + + preparedLog.executionData = webhookData + } + + return preparedLog +} + export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): Promise { try { + const workflowData = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, log.workflowId)) + .limit(1) + + if (workflowData.length === 0 || !workflowData[0].workspaceId) return + + const workspaceId = workflowData[0].workspaceId + const subscriptions = await db .select() - .from(workflowLogWebhook) + .from(workspaceNotificationSubscription) .where( - and(eq(workflowLogWebhook.workflowId, log.workflowId), eq(workflowLogWebhook.active, true)) + and( + eq(workspaceNotificationSubscription.workspaceId, workspaceId), + eq(workspaceNotificationSubscription.active, true), + or( + eq(workspaceNotificationSubscription.allWorkflows, true), + sql`${log.workflowId} = ANY(${workspaceNotificationSubscription.workflowIds})` + ) + ) ) - if (subscriptions.length === 0) { - return - } + if (subscriptions.length === 0) return logger.debug( - `Found ${subscriptions.length} active webhook subscriptions for workflow ${log.workflowId}` + `Found ${subscriptions.length} active notification subscriptions for workspace ${workspaceId}` ) for (const subscription of subscriptions) { @@ -30,18 +85,42 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): const triggerMatches = subscription.triggerFilter?.includes(log.trigger) ?? true if (!levelMatches || !triggerMatches) { - logger.debug(`Skipping subscription ${subscription.id} due to filter mismatch`, { - level: log.level, - trigger: log.trigger, - levelFilter: subscription.levelFilter, - triggerFilter: subscription.triggerFilter, - }) + logger.debug(`Skipping subscription ${subscription.id} due to filter mismatch`) continue } + const alertConfig = subscription.alertConfig as AlertConfig | null + + if (alertConfig) { + const context: AlertCheckContext = { + workflowId: log.workflowId, + executionId: log.executionId, + status: log.level === 'error' ? 'error' : 'success', + durationMs: log.totalDurationMs || 0, + cost: (log.cost as { total?: number })?.total || 0, + } + + const shouldAlert = await shouldTriggerAlert(alertConfig, context, subscription.lastAlertAt) + + if (!shouldAlert) { + logger.debug(`Alert condition not met for subscription ${subscription.id}`) + continue + } + + await db + .update(workspaceNotificationSubscription) + .set({ lastAlertAt: new Date() }) + .where(eq(workspaceNotificationSubscription.id, subscription.id)) + + logger.info(`Alert triggered for subscription ${subscription.id}`, { + workflowId: log.workflowId, + alertConfig, + }) + } + const deliveryId = uuidv4() - await db.insert(workflowLogWebhookDelivery).values({ + await db.insert(workspaceNotificationDelivery).values({ id: deliveryId, subscriptionId: subscription.id, workflowId: log.workflowId, @@ -51,45 +130,29 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): nextAttemptAt: new Date(), }) - // Prepare the log data based on subscription settings - const webhookLog = { - ...log, - executionData: {}, - } - - // Only include executionData fields that are requested - if (log.executionData) { - const data = log.executionData as any - const webhookData: any = {} + const notificationLog = prepareLogData(log, subscription) - if (subscription.includeFinalOutput && data.finalOutput) { - webhookData.finalOutput = data.finalOutput - } - - if (subscription.includeTraceSpans && data.traceSpans) { - webhookData.traceSpans = data.traceSpans - } - - // For rate limits and usage, we'll need to fetch them in the webhook delivery - // since they're user-specific and may change - if (subscription.includeRateLimits) { - webhookData.includeRateLimits = true - } - - if (subscription.includeUsageData) { - webhookData.includeUsageData = true - } - - webhookLog.executionData = webhookData - } - - await logsWebhookDelivery.trigger({ + const payload = { deliveryId, subscriptionId: subscription.id, - log: webhookLog, - }) + notificationType: subscription.notificationType, + log: notificationLog, + alertConfig: alertConfig || undefined, + } + + const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) - logger.info(`Enqueued webhook delivery ${deliveryId} for subscription ${subscription.id}`) + if (useTrigger) { + await workspaceNotificationDeliveryTask.trigger(payload) + logger.info( + `Enqueued ${subscription.notificationType} notification ${deliveryId} via Trigger.dev` + ) + } else { + void executeNotificationDelivery(payload).catch((error) => { + logger.error(`Direct notification delivery failed for ${deliveryId}`, { error }) + }) + logger.info(`Enqueued ${subscription.notificationType} notification ${deliveryId} directly`) + } } } catch (error) { logger.error('Failed to emit workflow execution completed event', { diff --git a/apps/sim/lib/notifications/alert-rules.ts b/apps/sim/lib/notifications/alert-rules.ts new file mode 100644 index 00000000000..c07dc7e5e81 --- /dev/null +++ b/apps/sim/lib/notifications/alert-rules.ts @@ -0,0 +1,327 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { and, avg, count, desc, eq, gte } from 'drizzle-orm' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('AlertRules') + +/** + * Alert rule types supported by the notification system + */ +export type AlertRuleType = + | 'consecutive_failures' + | 'failure_rate' + | 'latency_threshold' + | 'latency_spike' + | 'cost_threshold' + | 'no_activity' + | 'error_count' + +/** + * Configuration for alert rules + */ +export interface AlertConfig { + rule: AlertRuleType + consecutiveFailures?: number + failureRatePercent?: number + windowHours?: number + durationThresholdMs?: number + latencySpikePercent?: number + costThresholdDollars?: number + inactivityHours?: number + errorCountThreshold?: number +} + +/** + * Metadata for alert rule types + */ +export interface AlertRuleDefinition { + type: AlertRuleType + name: string + description: string + requiredFields: (keyof AlertConfig)[] + defaultValues: Partial +} + +/** + * Registry of all alert rule definitions + */ +export const ALERT_RULES: Record = { + consecutive_failures: { + type: 'consecutive_failures', + name: 'Consecutive Failures', + description: 'Alert after X consecutive failed executions', + requiredFields: ['consecutiveFailures'], + defaultValues: { consecutiveFailures: 3 }, + }, + failure_rate: { + type: 'failure_rate', + name: 'Failure Rate', + description: 'Alert when failure rate exceeds X% over a time window', + requiredFields: ['failureRatePercent', 'windowHours'], + defaultValues: { failureRatePercent: 50, windowHours: 24 }, + }, + latency_threshold: { + type: 'latency_threshold', + name: 'Latency Threshold', + description: 'Alert when execution duration exceeds a threshold', + requiredFields: ['durationThresholdMs'], + defaultValues: { durationThresholdMs: 30000 }, + }, + latency_spike: { + type: 'latency_spike', + name: 'Latency Spike', + description: 'Alert when execution is X% slower than average', + requiredFields: ['latencySpikePercent', 'windowHours'], + defaultValues: { latencySpikePercent: 100, windowHours: 24 }, + }, + cost_threshold: { + type: 'cost_threshold', + name: 'Cost Threshold', + description: 'Alert when execution cost exceeds a threshold', + requiredFields: ['costThresholdDollars'], + defaultValues: { costThresholdDollars: 1 }, + }, + no_activity: { + type: 'no_activity', + name: 'No Activity', + description: 'Alert when no executions occur within a time window', + requiredFields: ['inactivityHours'], + defaultValues: { inactivityHours: 24 }, + }, + error_count: { + type: 'error_count', + name: 'Error Count', + description: 'Alert when error count exceeds threshold within time window', + requiredFields: ['errorCountThreshold', 'windowHours'], + defaultValues: { errorCountThreshold: 10, windowHours: 1 }, + }, +} + +/** + * Cooldown period in hours to prevent alert spam + */ +export const ALERT_COOLDOWN_HOURS = 1 + +/** + * Minimum executions required for rate-based alerts + */ +export const MIN_EXECUTIONS_FOR_RATE_ALERT = 5 + +/** + * Validates an alert configuration + */ +export function validateAlertConfig(config: AlertConfig): { valid: boolean; error?: string } { + const definition = ALERT_RULES[config.rule] + if (!definition) { + return { valid: false, error: `Unknown alert rule: ${config.rule}` } + } + + for (const field of definition.requiredFields) { + if (config[field] === undefined || config[field] === null) { + return { valid: false, error: `Missing required field: ${field}` } + } + } + + return { valid: true } +} + +/** + * Checks if a subscription is within its cooldown period + */ +export function isInCooldown(lastAlertAt: Date | null): boolean { + if (!lastAlertAt) return false + const cooldownEnd = new Date(lastAlertAt.getTime() + ALERT_COOLDOWN_HOURS * 60 * 60 * 1000) + return new Date() < cooldownEnd +} + +/** + * Context passed to alert check functions + */ +export interface AlertCheckContext { + workflowId: string + executionId: string + status: 'success' | 'error' + durationMs: number + cost: number +} + +/** + * Check if consecutive failures threshold is met + */ +async function checkConsecutiveFailures(workflowId: string, threshold: number): Promise { + const recentLogs = await db + .select({ level: workflowExecutionLogs.level }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.workflowId, workflowId)) + .orderBy(desc(workflowExecutionLogs.createdAt)) + .limit(threshold) + + if (recentLogs.length < threshold) return false + + return recentLogs.every((log) => log.level === 'error') +} + +/** + * Check if failure rate exceeds threshold + */ +async function checkFailureRate( + workflowId: string, + ratePercent: number, + windowHours: number +): Promise { + const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) + + const logs = await db + .select({ + level: workflowExecutionLogs.level, + createdAt: workflowExecutionLogs.createdAt, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.createdAt, windowStart) + ) + ) + .orderBy(workflowExecutionLogs.createdAt) + + if (logs.length < MIN_EXECUTIONS_FOR_RATE_ALERT) return false + + const oldestLog = logs[0] + if (oldestLog && oldestLog.createdAt > windowStart) { + return false + } + + const errorCount = logs.filter((log) => log.level === 'error').length + const failureRate = (errorCount / logs.length) * 100 + + return failureRate >= ratePercent +} + +/** + * Check if execution duration exceeds threshold + */ +function checkLatencyThreshold(durationMs: number, thresholdMs: number): boolean { + return durationMs > thresholdMs +} + +/** + * Check if execution duration is significantly above average + */ +async function checkLatencySpike( + workflowId: string, + currentDurationMs: number, + spikePercent: number, + windowHours: number +): Promise { + const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) + + const result = await db + .select({ + avgDuration: avg(workflowExecutionLogs.totalDurationMs), + count: count(), + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.createdAt, windowStart) + ) + ) + + const avgDuration = result[0]?.avgDuration + const execCount = result[0]?.count || 0 + + if (!avgDuration || execCount < MIN_EXECUTIONS_FOR_RATE_ALERT) return false + + const avgMs = Number(avgDuration) + const threshold = avgMs * (1 + spikePercent / 100) + + return currentDurationMs > threshold +} + +/** + * Check if execution cost exceeds threshold + */ +function checkCostThreshold(cost: number, thresholdDollars: number): boolean { + return cost > thresholdDollars +} + +/** + * Check if error count exceeds threshold within window + */ +async function checkErrorCount( + workflowId: string, + threshold: number, + windowHours: number +): Promise { + const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) + + const result = await db + .select({ count: count() }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + eq(workflowExecutionLogs.level, 'error'), + gte(workflowExecutionLogs.createdAt, windowStart) + ) + ) + + const errorCount = result[0]?.count || 0 + return errorCount >= threshold +} + +/** + * Evaluates if an alert should be triggered based on the configuration + */ +export async function shouldTriggerAlert( + config: AlertConfig, + context: AlertCheckContext, + lastAlertAt: Date | null +): Promise { + if (isInCooldown(lastAlertAt)) { + logger.debug('Subscription in cooldown, skipping alert check') + return false + } + + const { rule } = config + const { workflowId, status, durationMs, cost } = context + + switch (rule) { + case 'consecutive_failures': + if (status !== 'error') return false + return checkConsecutiveFailures(workflowId, config.consecutiveFailures!) + + case 'failure_rate': + if (status !== 'error') return false + return checkFailureRate(workflowId, config.failureRatePercent!, config.windowHours!) + + case 'latency_threshold': + return checkLatencyThreshold(durationMs, config.durationThresholdMs!) + + case 'latency_spike': + return checkLatencySpike( + workflowId, + durationMs, + config.latencySpikePercent!, + config.windowHours! + ) + + case 'cost_threshold': + return checkCostThreshold(cost, config.costThresholdDollars!) + + case 'no_activity': + // no_activity alerts are handled by the hourly polling job, not execution events + return false + + case 'error_count': + if (status !== 'error') return false + return checkErrorCount(workflowId, config.errorCountThreshold!, config.windowHours!) + + default: + logger.warn(`Unknown alert rule: ${rule}`) + return false + } +} diff --git a/apps/sim/lib/notifications/inactivity-polling.ts b/apps/sim/lib/notifications/inactivity-polling.ts new file mode 100644 index 00000000000..f5558088fc4 --- /dev/null +++ b/apps/sim/lib/notifications/inactivity-polling.ts @@ -0,0 +1,213 @@ +import { db } from '@sim/db' +import { + workflow, + workflowExecutionLogs, + workspaceNotificationDelivery, + workspaceNotificationSubscription, +} from '@sim/db/schema' +import { and, eq, gte, sql } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import { env, isTruthy } from '@/lib/core/config/env' +import { createLogger } from '@/lib/logs/console/logger' +import { + executeNotificationDelivery, + workspaceNotificationDeliveryTask, +} from '@/background/workspace-notification-delivery' +import type { AlertConfig } from './alert-rules' +import { isInCooldown } from './alert-rules' + +const logger = createLogger('InactivityPolling') + +interface InactivityCheckResult { + subscriptionId: string + workflowId: string + triggered: boolean + reason?: string +} + +/** + * Checks a single workflow for inactivity and triggers notification if needed + */ +async function checkWorkflowInactivity( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + workflowId: string, + alertConfig: AlertConfig +): Promise { + const result: InactivityCheckResult = { + subscriptionId: subscription.id, + workflowId, + triggered: false, + } + + if (isInCooldown(subscription.lastAlertAt)) { + result.reason = 'in_cooldown' + return result + } + + const windowStart = new Date(Date.now() - (alertConfig.inactivityHours || 24) * 60 * 60 * 1000) + + const recentLogs = await db + .select({ id: workflowExecutionLogs.id }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.createdAt, windowStart) + ) + ) + .limit(1) + + if (recentLogs.length > 0) { + result.reason = 'has_activity' + return result + } + + const [workflowData] = await db + .select({ + name: workflow.name, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowData || !workflowData.workspaceId) { + result.reason = 'workflow_not_found' + return result + } + + await db + .update(workspaceNotificationSubscription) + .set({ lastAlertAt: new Date() }) + .where(eq(workspaceNotificationSubscription.id, subscription.id)) + + const deliveryId = uuidv4() + + await db.insert(workspaceNotificationDelivery).values({ + id: deliveryId, + subscriptionId: subscription.id, + workflowId, + executionId: `inactivity_${Date.now()}`, + status: 'pending', + attempts: 0, + nextAttemptAt: new Date(), + }) + + const now = new Date().toISOString() + const mockLog = { + id: `inactivity_log_${uuidv4()}`, + workflowId, + executionId: `inactivity_${Date.now()}`, + stateSnapshotId: '', + level: 'info' as const, + trigger: 'system' as const, + startedAt: now, + endedAt: now, + totalDurationMs: 0, + executionData: {}, + cost: { total: 0 }, + workspaceId: workflowData.workspaceId, + createdAt: now, + } + + const payload = { + deliveryId, + subscriptionId: subscription.id, + notificationType: subscription.notificationType, + log: mockLog, + alertConfig, + } + + const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) + + if (useTrigger) { + await workspaceNotificationDeliveryTask.trigger(payload) + } else { + void executeNotificationDelivery(payload).catch((error) => { + logger.error(`Direct notification delivery failed for ${deliveryId}`, { error }) + }) + } + + result.triggered = true + result.reason = 'alert_sent' + + logger.info(`Inactivity alert triggered for workflow ${workflowId}`, { + subscriptionId: subscription.id, + inactivityHours: alertConfig.inactivityHours, + }) + + return result +} + +/** + * Polls all active no_activity subscriptions and triggers alerts as needed + */ +export async function pollInactivityAlerts(): Promise<{ + total: number + triggered: number + skipped: number + details: InactivityCheckResult[] +}> { + logger.info('Starting inactivity alert polling') + + const subscriptions = await db + .select() + .from(workspaceNotificationSubscription) + .where( + and( + eq(workspaceNotificationSubscription.active, true), + sql`${workspaceNotificationSubscription.alertConfig}->>'rule' = 'no_activity'` + ) + ) + + if (subscriptions.length === 0) { + logger.info('No active no_activity subscriptions found') + return { total: 0, triggered: 0, skipped: 0, details: [] } + } + + logger.info(`Found ${subscriptions.length} no_activity subscriptions to check`) + + const results: InactivityCheckResult[] = [] + let triggered = 0 + let skipped = 0 + + for (const subscription of subscriptions) { + const alertConfig = subscription.alertConfig as AlertConfig + if (!alertConfig || alertConfig.rule !== 'no_activity') { + continue + } + + let workflowIds: string[] = [] + + if (subscription.allWorkflows) { + const workflows = await db + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.workspaceId, subscription.workspaceId)) + + workflowIds = workflows.map((w) => w.id) + } else { + workflowIds = subscription.workflowIds || [] + } + + for (const workflowId of workflowIds) { + const result = await checkWorkflowInactivity(subscription, workflowId, alertConfig) + results.push(result) + + if (result.triggered) { + triggered++ + } else { + skipped++ + } + } + } + + logger.info(`Inactivity polling completed: ${triggered} alerts triggered, ${skipped} skipped`) + + return { + total: results.length, + triggered, + skipped, + details: results, + } +} diff --git a/apps/sim/stores/logs/filters/store.ts b/apps/sim/stores/logs/filters/store.ts index 60d37451273..3722e866a24 100644 --- a/apps/sim/stores/logs/filters/store.ts +++ b/apps/sim/stores/logs/filters/store.ts @@ -209,10 +209,8 @@ export const useFilterStore = create((set, get) => ({ folderIds, triggers, searchQuery, - _isInitializing: false, // Clear the flag after initialization + _isInitializing: false, }) - - get().syncWithURL() }, syncWithURL: () => { diff --git a/packages/db/migrations/0116_flimsy_shape.sql b/packages/db/migrations/0116_flimsy_shape.sql new file mode 100644 index 00000000000..dd69b1f042a --- /dev/null +++ b/packages/db/migrations/0116_flimsy_shape.sql @@ -0,0 +1,96 @@ +CREATE TYPE "public"."notification_delivery_status" AS ENUM('pending', 'in_progress', 'success', 'failed');--> statement-breakpoint +CREATE TYPE "public"."notification_type" AS ENUM('webhook', 'email', 'slack');--> statement-breakpoint +CREATE TABLE "workspace_notification_delivery" ( + "id" text PRIMARY KEY NOT NULL, + "subscription_id" text NOT NULL, + "workflow_id" text NOT NULL, + "execution_id" text NOT NULL, + "status" "notification_delivery_status" DEFAULT 'pending' NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "last_attempt_at" timestamp, + "next_attempt_at" timestamp, + "response_status" integer, + "response_body" text, + "error_message" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workspace_notification_subscription" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text NOT NULL, + "notification_type" "notification_type" NOT NULL, + "workflow_ids" text[] DEFAULT '{}'::text[] NOT NULL, + "all_workflows" boolean DEFAULT false NOT NULL, + "level_filter" text[] DEFAULT ARRAY['info', 'error']::text[] NOT NULL, + "trigger_filter" text[] DEFAULT ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[] NOT NULL, + "include_final_output" boolean DEFAULT false NOT NULL, + "include_trace_spans" boolean DEFAULT false NOT NULL, + "include_rate_limits" boolean DEFAULT false NOT NULL, + "include_usage_data" boolean DEFAULT false NOT NULL, + "webhook_config" jsonb, + "email_recipients" text[], + "slack_config" jsonb, + "alert_config" jsonb, + "last_alert_at" timestamp, + "active" boolean DEFAULT true NOT NULL, + "created_by" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +INSERT INTO "workspace_notification_subscription" ( + "id", + "workspace_id", + "notification_type", + "workflow_ids", + "all_workflows", + "level_filter", + "trigger_filter", + "include_final_output", + "include_trace_spans", + "include_rate_limits", + "include_usage_data", + "webhook_config", + "active", + "created_by", + "created_at", + "updated_at" +) +SELECT + wlw.id, + w.workspace_id, + 'webhook'::"notification_type", + ARRAY[wlw.workflow_id], + false, + wlw.level_filter, + wlw.trigger_filter, + wlw.include_final_output, + wlw.include_trace_spans, + wlw.include_rate_limits, + wlw.include_usage_data, + jsonb_build_object('url', wlw.url, 'secret', wlw.secret), + wlw.active, + w.user_id, + wlw.created_at, + wlw.updated_at +FROM workflow_log_webhook wlw +JOIN workflow w ON w.id = wlw.workflow_id +WHERE w.workspace_id IS NOT NULL;--> statement-breakpoint +DROP TABLE "workflow_log_webhook_delivery" CASCADE;--> statement-breakpoint +DROP TABLE "workflow_log_webhook" CASCADE;--> statement-breakpoint +ALTER TABLE "workspace_notification_delivery" ADD CONSTRAINT "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."workspace_notification_subscription"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_notification_delivery" ADD CONSTRAINT "workspace_notification_delivery_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_notification_subscription" ADD CONSTRAINT "workspace_notification_subscription_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_notification_subscription" ADD CONSTRAINT "workspace_notification_subscription_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "workspace_notification_delivery_subscription_id_idx" ON "workspace_notification_delivery" USING btree ("subscription_id");--> statement-breakpoint +CREATE INDEX "workspace_notification_delivery_execution_id_idx" ON "workspace_notification_delivery" USING btree ("execution_id");--> statement-breakpoint +CREATE INDEX "workspace_notification_delivery_status_idx" ON "workspace_notification_delivery" USING btree ("status");--> statement-breakpoint +CREATE INDEX "workspace_notification_delivery_next_attempt_idx" ON "workspace_notification_delivery" USING btree ("next_attempt_at");--> statement-breakpoint +CREATE INDEX "workspace_notification_workspace_id_idx" ON "workspace_notification_subscription" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX "workspace_notification_active_idx" ON "workspace_notification_subscription" USING btree ("active");--> statement-breakpoint +CREATE INDEX "workspace_notification_type_idx" ON "workspace_notification_subscription" USING btree ("notification_type");--> statement-breakpoint +ALTER TABLE "settings" DROP COLUMN "auto_pan";--> statement-breakpoint +ALTER TABLE "settings" DROP COLUMN "console_expanded_by_default";--> statement-breakpoint +ALTER TABLE "settings" DROP COLUMN "show_floating_controls";--> statement-breakpoint +DROP TYPE "public"."webhook_delivery_status"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0116_snapshot.json b/packages/db/migrations/meta/0116_snapshot.json new file mode 100644 index 00000000000..d65f2876125 --- /dev/null +++ b/packages/db/migrations/meta/0116_snapshot.json @@ -0,0 +1,7755 @@ +{ + "id": "02f65f0f-58b9-4304-87cc-eb5c57410d29", + "prevId": "462d46ca-8c69-4c3e-b11d-373938063d38", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_namespace_unique": { + "name": "idempotency_key_namespace_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_namespace_idx": { + "name": "idempotency_key_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketplace": { + "name": "marketplace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "category": { + "name": "category", + "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": {}, + "foreignKeys": { + "marketplace_workflow_id_workflow_id_fk": { + "name": "marketplace_workflow_id_workflow_id_fk", + "tableFrom": "marketplace", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "marketplace_author_id_user_id_fk": { + "name": "marketplace_author_id_user_id_fk", + "tableFrom": "marketplace", + "tableTo": "user", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_rate_limits": { + "name": "user_rate_limits", + "schema": "", + "columns": { + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sync_api_requests": { + "name": "sync_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "async_api_requests": { + "name": "async_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_endpoint_requests": { + "name": "api_endpoint_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_rate_limited": { + "name": "is_rate_limited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_reset_at": { + "name": "rate_limit_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'10'" + }, + "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'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_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": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": ["block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_files_key_unique": { + "name": "workspace_files_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index be8e52f8abb..b2b514940ec 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -806,6 +806,13 @@ "when": 1764477997303, "tag": "0115_redundant_cerebro", "breakpoints": true + }, + { + "idx": 116, + "version": "7", + "when": 1764820826997, + "tag": "0116_flimsy_shape", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 340f06e01e8..4faff9d0bb8 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -493,19 +493,25 @@ export const webhook = pgTable( } ) -export const workflowLogWebhook = pgTable( - 'workflow_log_webhook', +export const notificationTypeEnum = pgEnum('notification_type', ['webhook', 'email', 'slack']) + +export const notificationDeliveryStatusEnum = pgEnum('notification_delivery_status', [ + 'pending', + 'in_progress', + 'success', + 'failed', +]) + +export const workspaceNotificationSubscription = pgTable( + 'workspace_notification_subscription', { id: text('id').primaryKey(), - workflowId: text('workflow_id') + workspaceId: text('workspace_id') .notNull() - .references(() => workflow.id, { onDelete: 'cascade' }), - url: text('url').notNull(), - secret: text('secret'), - includeFinalOutput: boolean('include_final_output').notNull().default(false), - includeTraceSpans: boolean('include_trace_spans').notNull().default(false), - includeRateLimits: boolean('include_rate_limits').notNull().default(false), - includeUsageData: boolean('include_usage_data').notNull().default(false), + .references(() => workspace.id, { onDelete: 'cascade' }), + notificationType: notificationTypeEnum('notification_type').notNull(), + workflowIds: text('workflow_ids').array().notNull().default(sql`'{}'::text[]`), + allWorkflows: boolean('all_workflows').notNull().default(false), levelFilter: text('level_filter') .array() .notNull() @@ -514,35 +520,46 @@ export const workflowLogWebhook = pgTable( .array() .notNull() .default(sql`ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]`), + includeFinalOutput: boolean('include_final_output').notNull().default(false), + includeTraceSpans: boolean('include_trace_spans').notNull().default(false), + includeRateLimits: boolean('include_rate_limits').notNull().default(false), + includeUsageData: boolean('include_usage_data').notNull().default(false), + + // Channel-specific configuration + webhookConfig: jsonb('webhook_config'), + emailRecipients: text('email_recipients').array(), + slackConfig: jsonb('slack_config'), + + // Alert rule configuration (if null, sends on every execution) + alertConfig: jsonb('alert_config'), + lastAlertAt: timestamp('last_alert_at'), + active: boolean('active').notNull().default(true), + createdBy: text('created_by') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ - workflowIdIdx: index('workflow_log_webhook_workflow_id_idx').on(table.workflowId), - activeIdx: index('workflow_log_webhook_active_idx').on(table.active), + workspaceIdIdx: index('workspace_notification_workspace_id_idx').on(table.workspaceId), + activeIdx: index('workspace_notification_active_idx').on(table.active), + typeIdx: index('workspace_notification_type_idx').on(table.notificationType), }) ) -export const webhookDeliveryStatusEnum = pgEnum('webhook_delivery_status', [ - 'pending', - 'in_progress', - 'success', - 'failed', -]) - -export const workflowLogWebhookDelivery = pgTable( - 'workflow_log_webhook_delivery', +export const workspaceNotificationDelivery = pgTable( + 'workspace_notification_delivery', { id: text('id').primaryKey(), subscriptionId: text('subscription_id') .notNull() - .references(() => workflowLogWebhook.id, { onDelete: 'cascade' }), + .references(() => workspaceNotificationSubscription.id, { onDelete: 'cascade' }), workflowId: text('workflow_id') .notNull() .references(() => workflow.id, { onDelete: 'cascade' }), executionId: text('execution_id').notNull(), - status: webhookDeliveryStatusEnum('status').notNull().default('pending'), + status: notificationDeliveryStatusEnum('status').notNull().default('pending'), attempts: integer('attempts').notNull().default(0), lastAttemptAt: timestamp('last_attempt_at'), nextAttemptAt: timestamp('next_attempt_at'), @@ -553,12 +570,14 @@ export const workflowLogWebhookDelivery = pgTable( updatedAt: timestamp('updated_at').notNull().defaultNow(), }, (table) => ({ - subscriptionIdIdx: index('workflow_log_webhook_delivery_subscription_id_idx').on( + subscriptionIdIdx: index('workspace_notification_delivery_subscription_id_idx').on( table.subscriptionId ), - executionIdIdx: index('workflow_log_webhook_delivery_execution_id_idx').on(table.executionId), - statusIdx: index('workflow_log_webhook_delivery_status_idx').on(table.status), - nextAttemptIdx: index('workflow_log_webhook_delivery_next_attempt_idx').on(table.nextAttemptAt), + executionIdIdx: index('workspace_notification_delivery_execution_id_idx').on(table.executionId), + statusIdx: index('workspace_notification_delivery_status_idx').on(table.status), + nextAttemptIdx: index('workspace_notification_delivery_next_attempt_idx').on( + table.nextAttemptAt + ), }) ) @@ -570,17 +589,16 @@ export const apiKey = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), // Only set for workspace keys - createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), // Who created the workspace key + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), name: text('name').notNull(), key: text('key').notNull().unique(), - type: text('type').notNull().default('personal'), // 'personal' or 'workspace' + type: text('type').notNull().default('personal'), lastUsed: timestamp('last_used'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), expiresAt: timestamp('expires_at'), }, (table) => ({ - // Ensure workspace keys have a workspace_id and personal keys don't workspaceTypeCheck: check( 'workspace_type_check', sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)`