From 126a42c480ff680b3a2e80702f5c0673a33d0d27 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 1 Dec 2025 16:35:09 -0800 Subject: [PATCH 01/21] feat(notification): slack, email, webhook notifications from logs --- apps/sim/app/api/auth/accounts/route.ts | 36 + .../[id]/log-webhook/[webhookId]/route.ts | 221 - .../api/workflows/[id]/log-webhook/route.ts | 248 - .../workflows/[id]/log-webhook/test/route.ts | 232 - .../notifications/[notificationId]/route.ts | 256 + .../[notificationId]/test/route.ts | 306 + .../workspaces/[id]/notifications/route.ts | 203 + .../logs/components/dashboard/controls.tsx | 80 +- .../components/notification-settings/index.ts | 2 + .../notification-settings.tsx | 882 ++ .../workflow-selector.tsx | 244 + .../app/workspace/[workspaceId]/logs/logs.tsx | 12 + .../webhook-settings/webhook-settings.tsx | 1198 --- apps/sim/background/logs-webhook-delivery.ts | 404 - .../workspace-notification-delivery.ts | 418 + apps/sim/hooks/use-slack-accounts.ts | 45 + apps/sim/lib/logs/events.ts | 140 +- .../db/migrations/0116_public_la_nuit.sql | 96 + .../db/migrations/meta/0116_snapshot.json | 7776 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 73 +- 21 files changed, 10467 insertions(+), 2412 deletions(-) create mode 100644 apps/sim/app/api/auth/accounts/route.ts delete mode 100644 apps/sim/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts delete mode 100644 apps/sim/app/api/workflows/[id]/log-webhook/route.ts delete mode 100644 apps/sim/app/api/workflows/[id]/log-webhook/test/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/notifications/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/notification-settings.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/workflow-selector.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx delete mode 100644 apps/sim/background/logs-webhook-delivery.ts create mode 100644 apps/sim/background/workspace-notification-delivery.ts create mode 100644 apps/sim/hooks/use-slack-accounts.ts create mode 100644 packages/db/migrations/0116_public_la_nuit.sql create mode 100644 packages/db/migrations/meta/0116_snapshot.json 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..5c1139cfb98 --- /dev/null +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -0,0 +1,36 @@ +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' + +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 { + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} 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 db2100fda17..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 { createLogger } from '@/lib/logs/console/logger' -import { encryptSecret } from '@/lib/utils' - -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 5287effc7b5..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 { createLogger } from '@/lib/logs/console/logger' -import { encryptSecret } from '@/lib/utils' - -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 fdd9be09d9d..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 { createLogger } from '@/lib/logs/console/logger' -import { decryptSecret } from '@/lib/utils' - -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..97203db98ad --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -0,0 +1,256 @@ +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 { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/permissions/utils' +import { encryptSecret } from '@/lib/utils' + +const logger = createLogger('WorkspaceNotificationAPI') + +const levelFilterSchema = z.array(z.enum(['info', 'error'])) +const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])) + +const updateNotificationSchema = z.object({ + workflowIds: z.array(z.string()).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(), + webhookUrl: z.string().url().optional(), + webhookSecret: z.string().optional(), + emailRecipients: z.array(z.string().email()).optional(), + slackChannelId: z.string().optional(), + slackAccountId: z.string().optional(), + active: z.boolean().optional(), +}) + +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, + webhookUrl: subscription.webhookUrl, + emailRecipients: subscription.emailRecipients, + slackChannelId: subscription.slackChannelId, + slackAccountId: subscription.slackAccountId, + 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.webhookUrl !== undefined) updateData.webhookUrl = data.webhookUrl + if (data.emailRecipients !== undefined) updateData.emailRecipients = data.emailRecipients + if (data.slackChannelId !== undefined) updateData.slackChannelId = data.slackChannelId + if (data.slackAccountId !== undefined) updateData.slackAccountId = data.slackAccountId + if (data.active !== undefined) updateData.active = data.active + + if (data.webhookSecret !== undefined) { + if (data.webhookSecret) { + const { encrypted } = await encryptSecret(data.webhookSecret) + updateData.webhookSecret = encrypted + } else { + updateData.webhookSecret = null + } + } + + 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, + webhookUrl: subscription.webhookUrl, + emailRecipients: subscription.emailRecipients, + slackChannelId: subscription.slackChannelId, + slackAccountId: subscription.slackAccountId, + 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..8861d3cf52b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/test/route.ts @@ -0,0 +1,306 @@ +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 { sendEmail } from '@/lib/email/mailer' +import { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/permissions/utils' +import { decryptSecret } from '@/lib/utils' + +const logger = createLogger('WorkspaceNotificationTestAPI') + +type RouteParams = { params: Promise<{ id: string; notificationId: 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) { + if (!subscription.webhookUrl) { + 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 (subscription.webhookSecret) { + const { decrypted } = await decryptSecret(subscription.webhookSecret) + 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(subscription.webhookUrl, { + 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 +) { + if (!subscription.slackChannelId || !subscription.slackAccountId) { + 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, subscription.slackAccountId), 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: subscription.slackChannelId, + 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/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts new file mode 100644 index 00000000000..adf2656d737 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -0,0 +1,203 @@ +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 { createLogger } from '@/lib/logs/console/logger' +import { getUserEntityPermissions } from '@/lib/permissions/utils' +import { encryptSecret } from '@/lib/utils' + +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 createNotificationSchema = z + .object({ + notificationType: notificationTypeSchema, + workflowIds: z.array(z.string()).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), + webhookUrl: z.string().url().optional(), + webhookSecret: z.string().optional(), + emailRecipients: z.array(z.string().email()).optional(), + slackChannelId: z.string().optional(), + slackAccountId: z.string().optional(), + }) + .refine( + (data) => { + if (data.notificationType === 'webhook') return !!data.webhookUrl + if (data.notificationType === 'email') + return !!data.emailRecipients && data.emailRecipients.length > 0 + if (data.notificationType === 'slack') return !!data.slackChannelId && !!data.slackAccountId + return false + }, + { message: 'Missing required fields for notification type' } + ) + +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, + webhookUrl: workspaceNotificationSubscription.webhookUrl, + emailRecipients: workspaceNotificationSubscription.emailRecipients, + slackChannelId: workspaceNotificationSubscription.slackChannelId, + slackAccountId: workspaceNotificationSubscription.slackAccountId, + 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 + + 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 } + ) + } + } + + let encryptedSecret: string | null = null + if (data.webhookSecret) { + const { encrypted } = await encryptSecret(data.webhookSecret) + encryptedSecret = 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, + webhookUrl: data.webhookUrl || null, + webhookSecret: encryptedSecret, + emailRecipients: data.emailRecipients || null, + slackChannelId: data.slackChannelId || null, + slackAccountId: data.slackAccountId || 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, + webhookUrl: subscription.webhookUrl, + emailRecipients: subscription.emailRecipients, + slackChannelId: subscription.slackChannelId, + slackAccountId: subscription.slackAccountId, + 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 57962e59f5e..b93ea39bbb5 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/utils' 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..f811c3be603 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/notification-settings.tsx @@ -0,0 +1,882 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + AlertCircle, + Bell, + Check, + Mail, + MessageSquare, + Pencil, + Play, + Plus, + Search, + Trash2, + Webhook, +} from 'lucide-react' +import { + Button as EmcnButton, + Modal, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, + Tooltip, +} from '@/components/emcn' +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Input, + Label, + Skeleton, + Switch, +} from '@/components/ui' +import { createLogger } from '@/lib/logs/console/logger' +import { cn } from '@/lib/utils' +import { useSlackAccounts } from '@/hooks/use-slack-accounts' +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' + +interface NotificationSubscription { + id: string + notificationType: NotificationType + workflowIds: string[] + allWorkflows: boolean + levelFilter: LogLevel[] + triggerFilter: TriggerType[] + includeFinalOutput: boolean + includeTraceSpans: boolean + includeRateLimits: boolean + includeUsageData: boolean + webhookUrl?: string | null + emailRecipients?: string[] | null + slackChannelId?: string | null + slackAccountId?: string | null + active: boolean + createdAt: string + updatedAt: string +} + +interface NotificationSettingsProps { + workspaceId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +const NOTIFICATION_TYPES: { type: NotificationType; label: string; icon: typeof Webhook }[] = [ + { type: 'webhook', label: 'Webhook', icon: Webhook }, + { type: 'email', label: 'Email', icon: Mail }, + { type: 'slack', label: 'Slack', icon: MessageSquare }, +] + +const LOG_LEVELS: LogLevel[] = ['info', 'error'] +const TRIGGER_TYPES: TriggerType[] = ['api', 'webhook', 'schedule', 'manual', 'chat'] + +export function NotificationSettings({ + workspaceId, + open, + onOpenChange, +}: NotificationSettingsProps) { + const [subscriptions, setSubscriptions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [activeTab, setActiveTab] = useState('webhook') + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [isTesting, setIsTesting] = useState(null) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + 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: '', + slackAccountId: '', + }) + + const [formErrors, setFormErrors] = useState>({}) + + const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts() + + const filteredSubscriptions = useMemo(() => { + return subscriptions + .filter((s) => s.notificationType === activeTab) + .filter((s) => { + if (!searchTerm) return true + const term = searchTerm.toLowerCase() + if (s.webhookUrl?.toLowerCase().includes(term)) return true + if (s.emailRecipients?.some((e) => e.toLowerCase().includes(term))) return true + return false + }) + }, [subscriptions, activeTab, searchTerm]) + + const loadSubscriptions = useCallback(async () => { + try { + setIsLoading(true) + const response = await fetch(`/api/workspaces/${workspaceId}/notifications`) + if (response.ok) { + const data = await response.json() + setSubscriptions(data.data || []) + } + } catch (error) { + logger.error('Failed to load notifications', { error }) + } finally { + setIsLoading(false) + } + }, [workspaceId]) + + useEffect(() => { + if (open) { + loadSubscriptions() + } + }, [open, loadSubscriptions]) + + 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: '', + slackAccountId: '', + }) + setFormErrors({}) + setEditingId(null) + }, []) + + const handleClose = useCallback(() => { + resetForm() + setShowForm(false) + setSearchTerm('') + 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 { + 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 = 'Enter a Slack channel ID' + } + } + + setFormErrors(errors) + return Object.keys(errors).length === 0 + } + + const handleSave = async () => { + if (!validateForm()) return + + setIsSaving(true) + try { + 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, + ...(activeTab === 'webhook' && { + webhookUrl: formData.webhookUrl, + webhookSecret: formData.webhookSecret || undefined, + }), + ...(activeTab === 'email' && { + emailRecipients: formData.emailRecipients + .split(',') + .map((e) => e.trim()) + .filter(Boolean), + }), + ...(activeTab === 'slack' && { + slackChannelId: formData.slackChannelId, + slackAccountId: formData.slackAccountId, + }), + } + + const url = editingId + ? `/api/workspaces/${workspaceId}/notifications/${editingId}` + : `/api/workspaces/${workspaceId}/notifications` + const method = editingId ? 'PUT' : 'POST' + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (response.ok) { + await loadSubscriptions() + resetForm() + setShowForm(false) + } else { + const error = await response.json() + setFormErrors({ general: error.error || 'Failed to save notification' }) + } + } catch (error) { + logger.error('Failed to save notification', { error }) + setFormErrors({ general: 'Failed to save notification' }) + } finally { + setIsSaving(false) + } + } + + 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.webhookUrl || '', + webhookSecret: '', + emailRecipients: subscription.emailRecipients?.join(', ') || '', + slackChannelId: subscription.slackChannelId || '', + slackAccountId: subscription.slackAccountId || '', + }) + setShowForm(true) + } + + const handleDelete = async () => { + if (!deletingId) return + + setIsDeleting(true) + try { + const response = await fetch(`/api/workspaces/${workspaceId}/notifications/${deletingId}`, { + method: 'DELETE', + }) + + if (response.ok) { + await loadSubscriptions() + } + } catch (error) { + logger.error('Failed to delete notification', { error }) + } finally { + setIsDeleting(false) + setShowDeleteDialog(false) + setDeletingId(null) + } + } + + const handleTest = async (id: string) => { + setIsTesting(id) + setTestStatus(null) + try { + const response = await fetch(`/api/workspaces/${workspaceId}/notifications/${id}/test`, { + method: 'POST', + }) + const data = await response.json() + setTestStatus({ + id, + success: data.data?.success ?? false, + message: + data.data?.error || (data.data?.success ? 'Test sent successfully' : 'Test failed'), + }) + } catch (error) { + setTestStatus({ id, success: false, message: 'Failed to send test' }) + } finally { + setIsTesting(null) + } + } + + const handleToggleActive = async (subscription: NotificationSubscription) => { + try { + await fetch(`/api/workspaces/${workspaceId}/notifications/${subscription.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ active: !subscription.active }), + }) + await loadSubscriptions() + } catch (error) { + logger.error('Failed to toggle notification', { error }) + } + } + + const renderSubscriptionItem = (subscription: NotificationSubscription) => { + const identifier = + subscription.notificationType === 'webhook' + ? subscription.webhookUrl + : subscription.notificationType === 'email' + ? subscription.emailRecipients?.join(', ') + : `Channel: ${subscription.slackChannelId}` + + 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} + + )} +
+
+ ) + } + + 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} + /> + + {activeTab === 'webhook' && ( + <> +
+ + { + setFormData({ ...formData, webhookUrl: e.target.value }) + setFormErrors({ ...formErrors, webhookUrl: '' }) + }} + className='h-9 rounded-[8px]' + /> + {formErrors.webhookUrl && ( +

{formErrors.webhookUrl}

+ )} +
+
+ + setFormData({ ...formData, webhookSecret: e.target.value })} + className='h-9 rounded-[8px]' + /> +

+ Used to sign webhook payloads with HMAC-SHA256 +

+
+ + )} + + {activeTab === 'email' && ( +
+ + { + setFormData({ ...formData, emailRecipients: e.target.value }) + setFormErrors({ ...formErrors, emailRecipients: '' }) + }} + className='h-9 rounded-[8px]' + /> +

Comma-separated list of email addresses

+ {formErrors.emailRecipients && ( +

{formErrors.emailRecipients}

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

No Slack accounts connected

+

+ Connect Slack in Settings → Credentials +

+
+ ) : ( + + )} + {formErrors.slackAccountId && ( +

{formErrors.slackAccountId}

+ )} +
+
+ + { + setFormData({ ...formData, slackChannelId: e.target.value }) + setFormErrors({ ...formErrors, slackChannelId: '' }) + }} + className='h-9 rounded-[8px]' + /> +

+ Find this in Slack channel details (starts with C, D, or G) +

+ {formErrors.slackChannelId && ( +

{formErrors.slackChannelId}

+ )} +
+ + )} + +
+ +
+ {LOG_LEVELS.map((level) => ( +
+
+ +

+ Receive notifications for {level} level logs +

+
+ { + const updated = checked + ? [...formData.levelFilter, level] + : formData.levelFilter.filter((l) => l !== level) + setFormData({ ...formData, levelFilter: updated }) + setFormErrors({ ...formErrors, levelFilter: '' }) + }} + /> +
+ ))} +
+ {formErrors.levelFilter && ( +

{formErrors.levelFilter}

+ )} +
+ +
+ +
+ {TRIGGER_TYPES.map((trigger) => ( +
+
+ +

+ Notify when workflow is triggered via {trigger} +

+
+ { + const updated = checked + ? [...formData.triggerFilter, trigger] + : formData.triggerFilter.filter((t) => t !== trigger) + setFormData({ ...formData, triggerFilter: updated }) + setFormErrors({ ...formErrors, triggerFilter: '' }) + }} + /> +
+ ))} +
+ {formErrors.triggerFilter && ( +

{formErrors.triggerFilter}

+ )} +
+ +
+ +
+ {[ + { + key: 'includeFinalOutput', + label: 'Final output', + desc: 'Include workflow execution results', + }, + { key: 'includeTraceSpans', label: 'Trace spans', desc: 'Detailed execution steps' }, + { key: 'includeRateLimits', label: 'Rate limits', desc: 'Workflow execution limits' }, + { + key: 'includeUsageData', + label: 'Usage data', + desc: 'Billing period cost and limits', + }, + ].map(({ key, label, desc }) => ( +
+
+ +

{desc}

+
+ setFormData({ ...formData, [key]: checked })} + /> +
+ ))} +
+
+
+
+ ) + + return ( + + + + + + Notification Settings + + + +
+ {!showForm && ( +
+
+ {NOTIFICATION_TYPES.map(({ type, label, icon: Icon }) => ( + + ))} +
+
+ + setSearchTerm(e.target.value)} + className='flex-1 border-0 bg-transparent px-0 text-sm placeholder:text-muted-foreground focus-visible:ring-0' + /> +
+
+ )} + +
+
+ {showForm ? ( + renderForm() + ) : isLoading ? ( +
+ {[1, 2].map((i) => ( +
+ + +
+ ))} +
+ ) : filteredSubscriptions.length === 0 ? ( +
+ {searchTerm + ? `No notifications found matching "${searchTerm}"` + : `No ${activeTab} notifications configured`} +
+ ) : ( +
{filteredSubscriptions.map(renderSubscriptionItem)}
+ )} +
+
+
+ +
+
+ {showForm ? ( + <> + + + + ) : isLoading ? ( + <> + +
+ + ) : ( + <> + +
+ + )} +
+
+ + + + + + Delete notification? + + This will permanently remove the notification and stop all deliveries.{' '} + This action cannot be undone. + + + + setShowDeleteDialog(false)} + > + Cancel + + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + +
+ ) +} 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..389f12a7288 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/workflow-selector.tsx @@ -0,0 +1,244 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Check, ChevronDown, Search, X } from 'lucide-react' +import { Button, Input, Label, Skeleton } from '@/components/ui' +import { cn } from '@/lib/utils' + +interface Workflow { + id: string + name: string +} + +interface WorkflowSelectorProps { + workspaceId: string + selectedIds: string[] + allWorkflows: boolean + onChange: (ids: string[], allWorkflows: boolean) => void + error?: string +} + +export function WorkflowSelector({ + workspaceId, + selectedIds, + allWorkflows, + onChange, + error, +}: WorkflowSelectorProps) { + const [workflows, setWorkflows] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState('') + + const loadWorkflows = useCallback(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) + } + }, [workspaceId]) + + useEffect(() => { + loadWorkflows() + }, [loadWorkflows]) + + const filteredWorkflows = useMemo(() => { + if (!search) return workflows + const term = search.toLowerCase() + return workflows.filter((w) => w.name.toLowerCase().includes(term)) + }, [workflows, search]) + + const selectedWorkflows = useMemo(() => { + return workflows.filter((w) => selectedIds.includes(w.id)) + }, [workflows, selectedIds]) + + const handleToggleWorkflow = (id: string) => { + if (selectedIds.includes(id)) { + onChange( + selectedIds.filter((i) => i !== id), + false + ) + } else { + onChange([...selectedIds, id], false) + } + } + + const handleToggleAll = () => { + if (allWorkflows) { + onChange([], false) + } else { + onChange([], true) + } + } + + const handleRemove = (id: string) => { + onChange( + selectedIds.filter((i) => i !== id), + false + ) + } + + if (isLoading) { + return ( +
+ + +
+ ) + } + + return ( +
+ +
+ + + )) + ) : ( + Select workflows... + )} + {!allWorkflows && selectedWorkflows.length > 3 && ( + + +{selectedWorkflows.length - 3} more + + )} +
+ + + + {isOpen && ( +
+
+ + setSearch(e.target.value)} + className='h-8 border-0 bg-transparent px-0 text-sm focus-visible:ring-0' + /> +
+ +
+ + +
+ + {filteredWorkflows.length === 0 ? ( +
+ {search ? 'No workflows found' : 'No workflows in workspace'} +
+ ) : ( + filteredWorkflows.map((workflow) => ( + + )) + )} +
+ +
+ +
+
+ )} +
+ {error &&

{error}

} +

+ Select which workflows should trigger this notification +

+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 0738cd61532..8ed561af2d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -8,10 +8,12 @@ import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' import { cn } from '@/lib/utils' 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' @@ -85,6 +87,8 @@ export default function Logs() { const [isLive, setIsLive] = useState(false) const isSearchOpenRef = useRef(false) + const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) + const userPermissions = useUserPermissionsContext() const logFilters = useMemo( () => ({ @@ -381,6 +385,8 @@ export default function Logs() { } showExport={true} onExport={handleExport} + canConfigureNotifications={userPermissions.canEdit} + onConfigureNotifications={() => setIsNotificationSettingsOpen(true)} /> {/* Table container */} @@ -599,6 +605,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 81c44cfd635..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/webhook-settings/webhook-settings.tsx +++ /dev/null @@ -1,1198 +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, - ModalContent, - ModalDescription, - ModalFooter, - ModalHeader, - ModalTitle, - Tooltip, -} from '@/components/emcn' -import { - Button, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - Input, - Label, - Skeleton, - Switch, -} from '@/components/ui' -import { createLogger } from '@/lib/logs/console/logger' -import { cn, generatePassword } from '@/lib/utils' -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 9ad0df2d916..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 { createLogger } from '@/lib/logs/console/logger' -import type { WorkflowExecutionLog } from '@/lib/logs/types' -import { decryptSecret } from '@/lib/utils' - -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..e79d73f89b6 --- /dev/null +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -0,0 +1,418 @@ +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 { sendEmail } from '@/lib/email/mailer' +import { createLogger } from '@/lib/logs/console/logger' +import type { WorkflowExecutionLog } from '@/lib/logs/types' +import { decryptSecret } from '@/lib/utils' + +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 }) + .from(workflowTable) + .where(eq(workflowTable.id, log.workflowId)) + .limit(1) + + const timestamp = Date.now() + const executionData = (log.executionData || {}) as Record + + 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: executionData.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 && executionData.rateLimits) { + payload.data.rateLimits = executionData.rateLimits as Record + } + + if (subscription.includeUsageData && executionData.usage) { + payload.data.usage = executionData.usage as Record + } + + return payload +} + +async function deliverWebhook( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + payload: NotificationPayload +): Promise<{ success: boolean; status?: number; error?: string }> { + if (!subscription.webhookUrl) { + 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 (subscription.webhookSecret) { + const { decrypted } = await decryptSecret(subscription.webhookSecret) + 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(subscription.webhookUrl, { + 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, + } + } +} + +async function deliverEmail( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + payload: NotificationPayload +): Promise<{ success: boolean; error?: string }> { + if (!subscription.emailRecipients || subscription.emailRecipients.length === 0) { + return { success: false, error: 'No email recipients configured' } + } + + const statusEmoji = payload.data.status === 'success' ? '✅' : '❌' + const statusText = payload.data.status === 'success' ? 'Success' : 'Error' + + const result = await sendEmail({ + to: subscription.emailRecipients, + subject: `${statusEmoji} Workflow Execution: ${payload.data.workflowName}`, + html: ` +
+

Workflow Execution ${statusText}

+ + + + + + + + + + + + + + + + + + + + + +
Workflow${payload.data.workflowName}
Status${statusText}
Trigger${payload.data.trigger}
Duration${payload.data.totalDurationMs}ms
Execution ID${payload.data.executionId}
+

+ This notification was sent from Sim Studio workspace notifications. +

+
+ `, + text: `Workflow Execution ${statusText}\n\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${payload.data.totalDurationMs}ms\nExecution ID: ${payload.data.executionId}`, + emailType: 'notifications', + }) + + return { success: result.success, error: result.success ? undefined : result.message } +} + +async function deliverSlack( + subscription: typeof workspaceNotificationSubscription.$inferSelect, + payload: NotificationPayload +): Promise<{ success: boolean; error?: string }> { + if (!subscription.slackChannelId || !subscription.slackAccountId) { + 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, subscription.slackAccountId)) + .limit(1) + + if (!slackAccount?.accessToken) { + return { success: false, error: 'Slack account not found or not connected' } + } + + const statusEmoji = payload.data.status === 'success' ? ':white_check_mark:' : ':x:' + const statusColor = payload.data.status === 'success' ? '#22c55e' : '#ef4444' + + const slackPayload = { + channel: subscription.slackChannelId, + attachments: [ + { + color: statusColor, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `${statusEmoji} *Workflow Execution: ${payload.data.workflowName}*`, + }, + }, + { + type: 'section', + fields: [ + { type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` }, + { type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` }, + { type: 'mrkdwn', text: `*Duration:*\n${payload.data.totalDurationMs}ms` }, + { + type: 'mrkdwn', + text: `*Cost:*\n${payload.data.cost?.total ? `$${(payload.data.cost.total as number).toFixed(4)}` : 'N/A'}`, + }, + ], + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Execution ID: \`${payload.data.executionId}\``, + }, + ], + }, + ], + }, + ], + text: `${payload.data.status === 'success' ? '✅' : '❌'} Workflow ${payload.data.workflowName}: ${payload.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.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 +} + +export async function executeNotificationDelivery(params: NotificationDeliveryParams) { + const { deliveryId, subscriptionId, notificationType, log } = 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) + break + case 'slack': + result = await deliverSlack(subscription, payload) + 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/hooks/use-slack-accounts.ts b/apps/sim/hooks/use-slack-accounts.ts new file mode 100644 index 00000000000..64511b1930e --- /dev/null +++ b/apps/sim/hooks/use-slack-accounts.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react' + +interface SlackAccount { + id: string + accountId: string + providerId: string +} + +interface UseSlackAccountsResult { + accounts: SlackAccount[] + isLoading: boolean + error: string | null + refetch: () => Promise +} + +export function useSlackAccounts(): UseSlackAccountsResult { + const [accounts, setAccounts] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchAccounts = 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 { + 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..f39c47d96e0 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -1,28 +1,88 @@ 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/env' import { createLogger } from '@/lib/logs/console/logger' import type { WorkflowExecutionLog } from '@/lib/logs/types' -import { logsWebhookDelivery } from '@/background/logs-webhook-delivery' +import { + executeNotificationDelivery, + workspaceNotificationDeliveryTask, +} from '@/background/workspace-notification-delivery' const logger = createLogger('LogsEventEmitter') +function prepareLogData( + log: WorkflowExecutionLog, + subscription: { + includeFinalOutput: boolean + includeTraceSpans: boolean + includeRateLimits: boolean + includeUsageData: boolean + } +) { + const preparedLog = { ...log, executionData: {} } + + 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 + } + + if (subscription.includeRateLimits) { + webhookData.includeRateLimits = true + } + + if (subscription.includeUsageData) { + webhookData.includeUsageData = true + } + + 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 +90,13 @@ 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 deliveryId = uuidv4() - await db.insert(workflowLogWebhookDelivery).values({ + await db.insert(workspaceNotificationDelivery).values({ id: deliveryId, subscriptionId: subscription.id, workflowId: log.workflowId, @@ -51,45 +106,28 @@ 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 = {} - - 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 - } + const notificationLog = prepareLogData(log, subscription) - 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, + } + + 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/packages/db/migrations/0116_public_la_nuit.sql b/packages/db/migrations/0116_public_la_nuit.sql new file mode 100644 index 00000000000..26030b7fa7f --- /dev/null +++ b/packages/db/migrations/0116_public_la_nuit.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_url" text, + "webhook_secret" text, + "email_recipients" text[], + "slack_channel_id" text, + "slack_account_id" text, + "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_url", + "webhook_secret", + "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, + wlw.url, + 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 +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..e5b24676dd1 --- /dev/null +++ b/packages/db/migrations/meta/0116_snapshot.json @@ -0,0 +1,7776 @@ +{ + "id": "325e3f5e-204b-4db5-b4eb-8388e879de84", + "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 + }, + "auto_pan": { + "name": "auto_pan", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "console_expanded_by_default": { + "name": "console_expanded_by_default", + "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_floating_controls": { + "name": "show_floating_controls", + "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_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_account_id": { + "name": "slack_account_id", + "type": "text", + "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..3b599f84c58 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": 1764634806296, + "tag": "0116_public_la_nuit", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5570e2af2bf..9b6431470fc 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -496,19 +496,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() @@ -517,35 +523,41 @@ 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), + webhookUrl: text('webhook_url'), + webhookSecret: text('webhook_secret'), + emailRecipients: text('email_recipients').array(), + slackChannelId: text('slack_channel_id'), + slackAccountId: text('slack_account_id'), 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'), @@ -556,12 +568,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 + ), }) ) @@ -573,17 +587,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)` From 5928b9241af0330a7a548f3606c43f550d88923d Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 1 Dec 2025 17:11:33 -0800 Subject: [PATCH 02/21] retain search params for filters to link in notification --- .../logs/components/search/search.tsx | 13 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 21 +- .../workspace-notification-delivery.ts | 197 ++++++++++++++---- apps/sim/stores/logs/filters/store.ts | 4 +- 4 files changed, 182 insertions(+), 53 deletions(-) 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 25b37bcf558..fc2be20b9e0 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 8ed561af2d2..cb687774f02 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -60,7 +60,6 @@ export default function Logs() { level, workflowIds, folderIds, - searchQuery: storeSearchQuery, setSearchQuery: setStoreSearchQuery, triggers, viewMode, @@ -79,9 +78,17 @@ 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([]) @@ -115,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() @@ -170,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) @@ -253,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) diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index e79d73f89b6..972d413d091 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -155,6 +155,35 @@ async function deliverWebhook( } } +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 { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai' + return `${baseUrl}/workspace/${workspaceId}/logs?search=${encodeURIComponent(executionId)}` +} + +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 @@ -165,6 +194,34 @@ async function deliverEmail( const statusEmoji = payload.data.status === 'success' ? '✅' : '❌' const statusText = payload.data.status === 'success' ? 'Success' : 'Error' + const logUrl = buildLogUrl(subscription.workspaceId, payload.data.executionId) + + 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, @@ -187,19 +244,21 @@ async function deliverEmail( Duration - ${payload.data.totalDurationMs}ms + ${formatDuration(payload.data.totalDurationMs)} - Execution ID - ${payload.data.executionId} + Cost + ${formatCost(payload.data.cost)} -

- This notification was sent from Sim Studio workspace notifications. + View Execution Log → + ${includedDataHtml} +

+ This notification was sent from Sim Studio. View log

`, - text: `Workflow Execution ${statusText}\n\nWorkflow: ${payload.data.workflowName}\nStatus: ${statusText}\nTrigger: ${payload.data.trigger}\nDuration: ${payload.data.totalDurationMs}ms\nExecution ID: ${payload.data.executionId}`, + text: `Workflow Execution ${statusText}\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', }) @@ -226,44 +285,100 @@ async function deliverSlack( const statusEmoji = payload.data.status === 'success' ? ':white_check_mark:' : ':x:' const statusColor = payload.data.status === 'success' ? '#22c55e' : '#ef4444' + const logUrl = buildLogUrl(subscription.workspaceId, payload.data.executionId) + + const blocks: Array> = [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `${statusEmoji} *Workflow Execution: ${payload.data.workflowName}*`, + }, + }, + { + 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 + .map((span: any) => { + 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 slackPayload = { channel: subscription.slackChannelId, - attachments: [ - { - color: statusColor, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `${statusEmoji} *Workflow Execution: ${payload.data.workflowName}*`, - }, - }, - { - type: 'section', - fields: [ - { type: 'mrkdwn', text: `*Status:*\n${payload.data.status}` }, - { type: 'mrkdwn', text: `*Trigger:*\n${payload.data.trigger}` }, - { type: 'mrkdwn', text: `*Duration:*\n${payload.data.totalDurationMs}ms` }, - { - type: 'mrkdwn', - text: `*Cost:*\n${payload.data.cost?.total ? `$${(payload.data.cost.total as number).toFixed(4)}` : 'N/A'}`, - }, - ], - }, - { - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: `Execution ID: \`${payload.data.executionId}\``, - }, - ], - }, - ], - }, - ], + attachments: [{ color: statusColor, blocks }], text: `${payload.data.status === 'success' ? '✅' : '❌'} Workflow ${payload.data.workflowName}: ${payload.data.status}`, } 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: () => { From 8e6c509abebe797de29a4a526474999919dd88a4 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 1 Dec 2025 19:05:22 -0800 Subject: [PATCH 03/21] add alerting rules --- .../notifications/[notificationId]/route.ts | 23 ++ .../workspaces/[id]/notifications/route.ts | 23 ++ .../notification-settings.tsx | 205 +++++++++++++++--- .../workflow-selector.tsx | 36 +-- .../workspace-notification-delivery.ts | 55 ++++- apps/sim/lib/logs/events.ts | 143 +++++++++++- ...lic_la_nuit.sql => 0116_huge_ma_gnuci.sql} | 5 +- .../db/migrations/meta/0116_snapshot.json | 14 +- packages/db/migrations/meta/_journal.json | 4 +- packages/db/schema.ts | 10 + 10 files changed, 450 insertions(+), 68 deletions(-) rename packages/db/migrations/{0116_public_la_nuit.sql => 0116_huge_ma_gnuci.sql} (97%) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 97203db98ad..688f6dd702f 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -13,6 +13,25 @@ const logger = createLogger('WorkspaceNotificationAPI') const levelFilterSchema = z.array(z.enum(['info', 'error'])) const triggerFilterSchema = z.array(z.enum(['api', 'webhook', 'schedule', 'manual', 'chat'])) +const alertConfigSchema = z + .object({ + rule: z.enum(['consecutive_failures', 'failure_rate']), + 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(), + }) + .refine( + (data) => { + if (data.rule === 'consecutive_failures') return data.consecutiveFailures !== undefined + if (data.rule === 'failure_rate') { + return data.failureRatePercent !== undefined && data.windowHours !== undefined + } + return false + }, + { message: 'Missing required fields for alert rule' } + ) + .nullable() + const updateNotificationSchema = z.object({ workflowIds: z.array(z.string()).optional(), allWorkflows: z.boolean().optional(), @@ -22,6 +41,7 @@ const updateNotificationSchema = z.object({ includeTraceSpans: z.boolean().optional(), includeRateLimits: z.boolean().optional(), includeUsageData: z.boolean().optional(), + alertConfig: alertConfigSchema.optional(), webhookUrl: z.string().url().optional(), webhookSecret: z.string().optional(), emailRecipients: z.array(z.string().email()).optional(), @@ -91,6 +111,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { emailRecipients: subscription.emailRecipients, slackChannelId: subscription.slackChannelId, slackAccountId: subscription.slackAccountId, + alertConfig: subscription.alertConfig, active: subscription.active, createdAt: subscription.createdAt, updatedAt: subscription.updatedAt, @@ -162,6 +183,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { 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.webhookUrl !== undefined) updateData.webhookUrl = data.webhookUrl if (data.emailRecipients !== undefined) updateData.emailRecipients = data.emailRecipients if (data.slackChannelId !== undefined) updateData.slackChannelId = data.slackChannelId @@ -204,6 +226,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { emailRecipients: subscription.emailRecipients, slackChannelId: subscription.slackChannelId, slackAccountId: subscription.slackAccountId, + alertConfig: subscription.alertConfig, active: subscription.active, createdAt: subscription.createdAt, updatedAt: subscription.updatedAt, diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index adf2656d737..de214415dab 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -15,6 +15,25 @@ 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 alertConfigSchema = z + .object({ + rule: z.enum(['consecutive_failures', 'failure_rate']), + 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(), + }) + .refine( + (data) => { + if (data.rule === 'consecutive_failures') return data.consecutiveFailures !== undefined + if (data.rule === 'failure_rate') { + return data.failureRatePercent !== undefined && data.windowHours !== undefined + } + return false + }, + { message: 'Missing required fields for alert rule' } + ) + .nullable() + const createNotificationSchema = z .object({ notificationType: notificationTypeSchema, @@ -26,6 +45,7 @@ const createNotificationSchema = z includeTraceSpans: z.boolean().default(false), includeRateLimits: z.boolean().default(false), includeUsageData: z.boolean().default(false), + alertConfig: alertConfigSchema.optional(), webhookUrl: z.string().url().optional(), webhookSecret: z.string().optional(), emailRecipients: z.array(z.string().email()).optional(), @@ -82,6 +102,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ emailRecipients: workspaceNotificationSubscription.emailRecipients, slackChannelId: workspaceNotificationSubscription.slackChannelId, slackAccountId: workspaceNotificationSubscription.slackAccountId, + alertConfig: workspaceNotificationSubscription.alertConfig, active: workspaceNotificationSubscription.active, createdAt: workspaceNotificationSubscription.createdAt, updatedAt: workspaceNotificationSubscription.updatedAt, @@ -160,6 +181,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ includeTraceSpans: data.includeTraceSpans, includeRateLimits: data.includeRateLimits, includeUsageData: data.includeUsageData, + alertConfig: data.alertConfig || null, webhookUrl: data.webhookUrl || null, webhookSecret: encryptedSecret, emailRecipients: data.emailRecipients || null, @@ -191,6 +213,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ emailRecipients: subscription.emailRecipients, slackChannelId: subscription.slackChannelId, slackAccountId: subscription.slackAccountId, + alertConfig: subscription.alertConfig, active: subscription.active, createdAt: subscription.createdAt, updatedAt: subscription.updatedAt, 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 index f811c3be603..f9bf6d1ac5f 100644 --- 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 @@ -10,7 +10,6 @@ import { Pencil, Play, Plus, - Search, Trash2, Webhook, } from 'lucide-react' @@ -45,6 +44,14 @@ 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' + +interface AlertConfig { + rule: AlertRule + consecutiveFailures?: number + failureRatePercent?: number + windowHours?: number +} interface NotificationSubscription { id: string @@ -61,6 +68,7 @@ interface NotificationSubscription { emailRecipients?: string[] | null slackChannelId?: string | null slackAccountId?: string | null + alertConfig?: AlertConfig | null active: boolean createdAt: string updatedAt: string @@ -96,7 +104,6 @@ export function NotificationSettings({ const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [deletingId, setDeletingId] = useState(null) const [isDeleting, setIsDeleting] = useState(false) - const [searchTerm, setSearchTerm] = useState('') const [testStatus, setTestStatus] = useState<{ id: string success: boolean @@ -117,6 +124,11 @@ export function NotificationSettings({ emailRecipients: '', slackChannelId: '', slackAccountId: '', + useAlertRule: false, + alertRule: 'consecutive_failures' as AlertRule, + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, }) const [formErrors, setFormErrors] = useState>({}) @@ -124,16 +136,8 @@ export function NotificationSettings({ const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts() const filteredSubscriptions = useMemo(() => { - return subscriptions - .filter((s) => s.notificationType === activeTab) - .filter((s) => { - if (!searchTerm) return true - const term = searchTerm.toLowerCase() - if (s.webhookUrl?.toLowerCase().includes(term)) return true - if (s.emailRecipients?.some((e) => e.toLowerCase().includes(term))) return true - return false - }) - }, [subscriptions, activeTab, searchTerm]) + return subscriptions.filter((s) => s.notificationType === activeTab) + }, [subscriptions, activeTab]) const loadSubscriptions = useCallback(async () => { try { @@ -171,6 +175,11 @@ export function NotificationSettings({ emailRecipients: '', slackChannelId: '', slackAccountId: '', + useAlertRule: false, + alertRule: 'consecutive_failures', + consecutiveFailures: 3, + failureRatePercent: 50, + windowHours: 24, }) setFormErrors({}) setEditingId(null) @@ -179,7 +188,6 @@ export function NotificationSettings({ const handleClose = useCallback(() => { resetForm() setShowForm(false) - setSearchTerm('') setTestStatus(null) onOpenChange(false) }, [onOpenChange, resetForm]) @@ -239,6 +247,21 @@ export function NotificationSettings({ } } + if (formData.useAlertRule) { + if (formData.alertRule === 'consecutive_failures') { + if (formData.consecutiveFailures < 1 || formData.consecutiveFailures > 100) { + errors.consecutiveFailures = 'Must be between 1 and 100' + } + } else if (formData.alertRule === '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' + } + } + } + setFormErrors(errors) return Object.keys(errors).length === 0 } @@ -248,6 +271,19 @@ export function NotificationSettings({ setIsSaving(true) try { + const alertConfig: AlertConfig | null = formData.useAlertRule + ? { + rule: formData.alertRule, + ...(formData.alertRule === 'consecutive_failures' && { + consecutiveFailures: formData.consecutiveFailures, + }), + ...(formData.alertRule === 'failure_rate' && { + failureRatePercent: formData.failureRatePercent, + windowHours: formData.windowHours, + }), + } + : null + const payload = { notificationType: activeTab, workflowIds: formData.workflowIds, @@ -258,6 +294,7 @@ export function NotificationSettings({ includeTraceSpans: formData.includeTraceSpans, includeRateLimits: formData.includeRateLimits, includeUsageData: formData.includeUsageData, + alertConfig, ...(activeTab === 'webhook' && { webhookUrl: formData.webhookUrl, webhookSecret: formData.webhookSecret || undefined, @@ -318,6 +355,11 @@ export function NotificationSettings({ emailRecipients: subscription.emailRecipients?.join(', ') || '', slackChannelId: subscription.slackChannelId || '', slackAccountId: subscription.slackAccountId || '', + useAlertRule: !!subscription.alertConfig, + alertRule: subscription.alertConfig?.rule || 'consecutive_failures', + consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3, + failureRatePercent: subscription.alertConfig?.failureRatePercent || 50, + windowHours: subscription.alertConfig?.windowHours || 24, }) setShowForm(true) } @@ -487,6 +529,16 @@ export function NotificationSettings({ +{subscription.triggerFilter.length - 3} )} + {subscription.alertConfig && ( + <> + + + {subscription.alertConfig.rule === 'consecutive_failures' + ? `${subscription.alertConfig.consecutiveFailures} consecutive failures` + : `${subscription.alertConfig.failureRatePercent}% failure rate in ${subscription.alertConfig.windowHours}h`} + + + )} ) @@ -524,6 +576,115 @@ export function NotificationSettings({ error={formErrors.workflows} /> +
+
+
+ +

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

+
+ setFormData({ ...formData, useAlertRule: checked })} + /> +
+ + {formData.useAlertRule && ( +
+
+ + +
+ + {formData.alertRule === 'consecutive_failures' && ( +
+ + + setFormData({ + ...formData, + consecutiveFailures: Number.parseInt(e.target.value) || 1, + }) + } + className='h-9 w-32 rounded-[8px]' + /> +

+ Alert after this many consecutive failed executions +

+ {formErrors.consecutiveFailures && ( +

{formErrors.consecutiveFailures}

+ )} +
+ )} + + {formData.alertRule === 'failure_rate' && ( + <> +
+ + + setFormData({ + ...formData, + failureRatePercent: Number.parseInt(e.target.value) || 1, + }) + } + className='h-9 w-32 rounded-[8px]' + /> +

+ Alert when failure rate exceeds this percentage +

+ {formErrors.failureRatePercent && ( +

{formErrors.failureRatePercent}

+ )} +
+
+ + + setFormData({ + ...formData, + windowHours: Number.parseInt(e.target.value) || 1, + }) + } + className='h-9 w-32 rounded-[8px]' + /> +

+ Calculate failure rate over this sliding window (max 168 hours / 7 days) +

+ {formErrors.windowHours && ( +

{formErrors.windowHours}

+ )} +
+ + )} +
+ )} +
+ {activeTab === 'webhook' && ( <>
@@ -743,10 +904,7 @@ export function NotificationSettings({ {NOTIFICATION_TYPES.map(({ type, label, icon: Icon }) => ( ))}
-
- - setSearchTerm(e.target.value)} - className='flex-1 border-0 bg-transparent px-0 text-sm placeholder:text-muted-foreground focus-visible:ring-0' - /> -
)} @@ -786,9 +935,7 @@ export function NotificationSettings({ ) : filteredSubscriptions.length === 0 ? (
- {searchTerm - ? `No notifications found matching "${searchTerm}"` - : `No ${activeTab} notifications configured`} + No {activeTab} notifications configured
) : (
{filteredSubscriptions.map(renderSubscriptionItem)}
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 index 389f12a7288..5c209da1af0 100644 --- 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 @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, Search, X } from 'lucide-react' import { Button, Input, Label, Skeleton } from '@/components/ui' import { cn } from '@/lib/utils' @@ -29,6 +29,23 @@ export function WorkflowSelector({ const [isLoading, setIsLoading] = useState(true) const [isOpen, setIsOpen] = useState(false) const [search, setSearch] = useState('') + const containerRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) const loadWorkflows = useCallback(async () => { try { @@ -97,7 +114,7 @@ export function WorkflowSelector({ return (
-
+
-
)}
diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index 972d413d091..bbe1b0939e8 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -9,10 +9,13 @@ import { 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 { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' import type { WorkflowExecutionLog } from '@/lib/logs/types' import { decryptSecret } from '@/lib/utils' +import { RateLimiter } from '@/services/queue' const logger = createLogger('WorkspaceNotificationDelivery') @@ -58,13 +61,14 @@ async function buildPayload( subscription: typeof workspaceNotificationSubscription.$inferSelect ): Promise { const workflowData = await db - .select({ name: workflowTable.name }) + .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()}`, @@ -80,7 +84,7 @@ async function buildPayload( startedAt: log.startedAt, endedAt: log.endedAt, totalDurationMs: log.totalDurationMs, - cost: executionData.cost as Record, + cost: log.cost as Record, }, } @@ -92,12 +96,51 @@ async function buildPayload( payload.data.traceSpans = executionData.traceSpans as unknown[] } - if (subscription.includeRateLimits && executionData.rateLimits) { - payload.data.rateLimits = executionData.rateLimits as Record + 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 && executionData.usage) { - payload.data.usage = executionData.usage as Record + 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 diff --git a/apps/sim/lib/logs/events.ts b/apps/sim/lib/logs/events.ts index f39c47d96e0..96dc4173f87 100644 --- a/apps/sim/lib/logs/events.ts +++ b/apps/sim/lib/logs/events.ts @@ -1,10 +1,11 @@ import { db } from '@sim/db' import { workflow, + workflowExecutionLogs, workspaceNotificationDelivery, workspaceNotificationSubscription, } from '@sim/db/schema' -import { and, eq, or, sql } from 'drizzle-orm' +import { and, desc, eq, gte, or, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import { env, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' @@ -16,16 +17,122 @@ import { const logger = createLogger('LogsEventEmitter') +/** Cooldown period between alerts for the same subscription (in hours) */ +const ALERT_COOLDOWN_HOURS = 1 + +/** Minimum executions required before failure rate alert can trigger */ +const MIN_EXECUTIONS_FOR_RATE_ALERT = 5 + +interface AlertConfig { + rule: 'consecutive_failures' | 'failure_rate' + consecutiveFailures?: number + failureRatePercent?: number + windowHours?: number +} + +/** + * Checks if a subscription is within its cooldown period + */ +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 +} + +/** + * Checks if consecutive failures threshold is met for a workflow + */ +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') +} + +/** + * Checks if failure rate threshold is met for a workflow within the time window. + * Only triggers after the workflow has data spanning the full window period. + */ +async function checkFailureRate( + workflowId: string, + ratePercent: number, + windowHours: number +): Promise { + const windowStart = new Date(Date.now() - windowHours * 60 * 60 * 1000) + + const oldestLog = await db + .select({ createdAt: workflowExecutionLogs.createdAt }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.workflowId, workflowId)) + .orderBy(workflowExecutionLogs.createdAt) + .limit(1) + + if (!oldestLog[0] || oldestLog[0].createdAt > windowStart) { + return false + } + + const logs = await db + .select({ level: workflowExecutionLogs.level }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.workflowId, workflowId), + gte(workflowExecutionLogs.createdAt, windowStart) + ) + ) + + if (logs.length < MIN_EXECUTIONS_FOR_RATE_ALERT) return false + + const errorCount = logs.filter((log) => log.level === 'error').length + const actualRate = (errorCount / logs.length) * 100 + + return actualRate >= ratePercent +} + +/** + * Evaluates if an alert should be triggered based on the subscription's alert config + */ +async function shouldTriggerAlert( + subscription: { alertConfig: unknown; lastAlertAt: Date | null }, + workflowId: string +): Promise { + const alertConfig = subscription.alertConfig as AlertConfig | null + if (!alertConfig) return false + + if (isInCooldown(subscription.lastAlertAt)) { + logger.debug(`Subscription in cooldown, skipping alert check`) + return false + } + + if (alertConfig.rule === 'consecutive_failures' && alertConfig.consecutiveFailures) { + return checkConsecutiveFailures(workflowId, alertConfig.consecutiveFailures) + } + + if ( + alertConfig.rule === 'failure_rate' && + alertConfig.failureRatePercent && + alertConfig.windowHours + ) { + return checkFailureRate(workflowId, alertConfig.failureRatePercent, alertConfig.windowHours) + } + + return false +} + function prepareLogData( log: WorkflowExecutionLog, subscription: { includeFinalOutput: boolean includeTraceSpans: boolean - includeRateLimits: boolean - includeUsageData: boolean } ) { - const preparedLog = { ...log, executionData: {} } + const preparedLog = { ...log, executionData: {} as Record } if (log.executionData) { const data = log.executionData as Record @@ -39,14 +146,6 @@ function prepareLogData( webhookData.traceSpans = data.traceSpans } - if (subscription.includeRateLimits) { - webhookData.includeRateLimits = true - } - - if (subscription.includeUsageData) { - webhookData.includeUsageData = true - } - preparedLog.executionData = webhookData } @@ -94,6 +193,26 @@ export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): continue } + const hasAlertConfig = !!subscription.alertConfig + + if (hasAlertConfig) { + const shouldAlert = await shouldTriggerAlert(subscription, log.workflowId) + 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: subscription.alertConfig, + }) + } + const deliveryId = uuidv4() await db.insert(workspaceNotificationDelivery).values({ diff --git a/packages/db/migrations/0116_public_la_nuit.sql b/packages/db/migrations/0116_huge_ma_gnuci.sql similarity index 97% rename from packages/db/migrations/0116_public_la_nuit.sql rename to packages/db/migrations/0116_huge_ma_gnuci.sql index 26030b7fa7f..cc52c7cc350 100644 --- a/packages/db/migrations/0116_public_la_nuit.sql +++ b/packages/db/migrations/0116_huge_ma_gnuci.sql @@ -33,6 +33,8 @@ CREATE TABLE "workspace_notification_subscription" ( "email_recipients" text[], "slack_channel_id" text, "slack_account_id" text, + "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, @@ -78,8 +80,7 @@ SELECT 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 +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 diff --git a/packages/db/migrations/meta/0116_snapshot.json b/packages/db/migrations/meta/0116_snapshot.json index e5b24676dd1..c78340f82fc 100644 --- a/packages/db/migrations/meta/0116_snapshot.json +++ b/packages/db/migrations/meta/0116_snapshot.json @@ -1,5 +1,5 @@ { - "id": "325e3f5e-204b-4db5-b4eb-8388e879de84", + "id": "77614909-122b-4458-be99-c60683c9b478", "prevId": "462d46ca-8c69-4c3e-b11d-373938063d38", "version": "7", "dialect": "postgresql", @@ -7629,6 +7629,18 @@ "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", diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 3b599f84c58..cd7aad73d80 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -810,8 +810,8 @@ { "idx": 116, "version": "7", - "when": 1764634806296, - "tag": "0116_public_la_nuit", + "when": 1764641073738, + "tag": "0116_huge_ma_gnuci", "breakpoints": true } ] diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 9b6431470fc..7d5e901d46d 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -532,6 +532,16 @@ export const workspaceNotificationSubscription = pgTable( emailRecipients: text('email_recipients').array(), slackChannelId: text('slack_channel_id'), slackAccountId: text('slack_account_id'), + + // Alert rule configuration (if null, sends on every execution) + alertConfig: jsonb('alert_config').$type<{ + rule: 'consecutive_failures' | 'failure_rate' + consecutiveFailures?: number + failureRatePercent?: number + windowHours?: number + }>(), + lastAlertAt: timestamp('last_alert_at'), + active: boolean('active').notNull().default(true), createdBy: text('created_by') .notNull() From 5d4bcdc721ddaf101841b49c6b5b805cd8d9b426 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 1 Dec 2025 19:21:46 -0800 Subject: [PATCH 04/21] update selector --- apps/docs/content/docs/en/execution/api.mdx | 59 ++- .../content/docs/en/execution/logging.mdx | 2 +- .../workflow-selector.tsx | 350 +++++++++--------- 3 files changed, 218 insertions(+), 193 deletions(-) diff --git a/apps/docs/content/docs/en/execution/api.mdx b/apps/docs/content/docs/en/execution/api.mdx index ba378a8bc74..a31c2b2cd6a 100644 --- a/apps/docs/content/docs/en/execution/api.mdx +++ b/apps/docs/content/docs/en/execution/api.mdx @@ -240,32 +240,57 @@ 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 + +Both 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 +341,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/workspace/[workspaceId]/logs/components/notification-settings/workflow-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/workflow-selector.tsx index 5c209da1af0..da2c7e5dfc6 100644 --- 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 @@ -1,15 +1,11 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Check, ChevronDown, Search, X } from 'lucide-react' -import { Button, Input, Label, Skeleton } from '@/components/ui' +import { Check, ChevronDown, X } from 'lucide-react' +import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '@/components/emcn' +import { Label, Skeleton } from '@/components/ui' import { cn } from '@/lib/utils' -interface Workflow { - id: string - name: string -} - interface WorkflowSelectorProps { workspaceId: string selectedIds: string[] @@ -25,46 +21,33 @@ export function WorkflowSelector({ onChange, error, }: WorkflowSelectorProps) { - const [workflows, setWorkflows] = useState([]) + const [workflows, setWorkflows] = useState>([]) const [isLoading, setIsLoading] = useState(true) - const [isOpen, setIsOpen] = useState(false) + const [open, setOpen] = useState(false) const [search, setSearch] = useState('') - const containerRef = useRef(null) + const inputRef = useRef(null) useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) + 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) } } - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside) - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, [isOpen]) - - const loadWorkflows = useCallback(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]) useEffect(() => { - loadWorkflows() - }, [loadWorkflows]) + if (!open) setSearch('') + }, [open]) const filteredWorkflows = useMemo(() => { if (!search) return workflows @@ -76,169 +59,186 @@ export function WorkflowSelector({ return workflows.filter((w) => selectedIds.includes(w.id)) }, [workflows, selectedIds]) - const handleToggleWorkflow = (id: string) => { - if (selectedIds.includes(id)) { + const handleSelect = useCallback( + (id: string) => { + if (selectedIds.includes(id)) { + onChange( + selectedIds.filter((i) => i !== id), + false + ) + } else { + onChange([...selectedIds, id], false) + } + }, + [selectedIds, onChange] + ) + + const handleSelectAll = useCallback(() => { + onChange([], !allWorkflows) + }, [allWorkflows, onChange]) + + const handleRemove = useCallback( + (e: React.MouseEvent, id: string) => { + e.preventDefault() + e.stopPropagation() onChange( selectedIds.filter((i) => i !== id), false ) - } else { - onChange([...selectedIds, id], false) - } - } - - const handleToggleAll = () => { - if (allWorkflows) { - onChange([], false) - } else { - onChange([], true) - } - } - - const handleRemove = (id: string) => { - onChange( - selectedIds.filter((i) => i !== id), - false - ) - } + }, + [selectedIds, onChange] + ) if (isLoading) { return (
- +
) } + const hasSelection = allWorkflows || selectedWorkflows.length > 0 + return (
-
- - - )) - ) : ( - Select workflows... + + +
setOpen(true)} + className={cn( + 'relative flex w-full cursor-text rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]', + error && 'border-red-400' )} - {!allWorkflows && selectedWorkflows.length > 3 && ( - - +{selectedWorkflows.length - 3} more - + > + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setOpen(false) + inputRef.current?.blur() + } + }} + className={cn( + 'flex-1 bg-transparent px-[8px] py-[6px] font-sans font-medium text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none', + hasSelection && !open && 'text-transparent' + )} + /> + {hasSelection && !open && ( +
+ {allWorkflows ? ( + + All Workflows + + ) : ( + <> + {selectedWorkflows.slice(0, 2).map((w) => ( + + {w.name} + + + ))} + {selectedWorkflows.length > 2 && ( + + +{selectedWorkflows.length - 2} + + )} + + )} +
)} -
- - - - {isOpen && ( -
-
- - setSearch(e.target.value)} - className='h-8 border-0 bg-transparent px-0 text-sm focus-visible:ring-0' +
{ + e.stopPropagation() + setOpen((prev) => !prev) + }} + > +
+
+ + + { + e.preventDefault() + inputRef.current?.focus() + }} + > + +
{ + e.preventDefault() + handleSelectAll() + }} + className={cn( + 'relative flex cursor-pointer select-none items-center rounded-[4px] px-[8px] py-[6px] font-sans text-sm hover:bg-[var(--surface-11)]', + allWorkflows && 'bg-[var(--surface-11)]' + )} + > + All Workflows + Includes future + {allWorkflows && } +
-
- - -
- - {filteredWorkflows.length === 0 ? ( -
- {search ? 'No workflows found' : 'No workflows in workspace'} -
- ) : ( - filteredWorkflows.map((workflow) => ( - - )) - )} -
-
- )} -
+ + {workflow.name} + + {isSelected && } +
+ ) + }) + )} + + + {error &&

{error}

}

Select which workflows should trigger this notification From 3b8fad23a3504efec1eb1f761ec975a69edd2350 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 1 Dec 2025 19:22:59 -0800 Subject: [PATCH 05/21] fix lint --- .../components/notification-settings/workflow-selector.tsx | 6 +++--- apps/sim/background/workspace-notification-delivery.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index da2c7e5dfc6..1adb23da46f 100644 --- 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 @@ -108,7 +108,7 @@ export function WorkflowSelector({

setOpen(true)} className={cn( - 'relative flex w-full cursor-text rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]', + 'relative flex w-full cursor-text rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]', error && 'border-red-400' )} > @@ -125,12 +125,12 @@ export function WorkflowSelector({ } }} className={cn( - 'flex-1 bg-transparent px-[8px] py-[6px] font-sans font-medium text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] outline-none', + 'flex-1 bg-transparent px-[8px] py-[6px] font-medium font-sans text-[var(--text-primary)] text-sm outline-none placeholder:text-[var(--text-muted)]', hasSelection && !open && 'text-transparent' )} /> {hasSelection && !open && ( -
+
{allWorkflows ? ( All Workflows diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index bbe1b0939e8..45969758a33 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -362,7 +362,7 @@ async function deliverSlack( if (payload.data.finalOutput) { const outputStr = JSON.stringify(payload.data.finalOutput, null, 2) - const truncated = outputStr.length > 2900 ? outputStr.slice(0, 2900) + '...' : outputStr + const truncated = outputStr.length > 2900 ? `${outputStr.slice(0, 2900)}...` : outputStr blocks.push({ type: 'section', text: { From ed82ded4c894cab5b1c4ff416971ec369fc1b2da Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 1 Dec 2025 19:24:25 -0800 Subject: [PATCH 06/21] add limits on num of emails and notification triggers per workspace --- .../workspaces/[id]/notifications/route.ts | 27 ++++++++++++++++++- .../notification-settings.tsx | 6 ++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index de214415dab..24c3fdacf07 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -11,6 +11,12 @@ import { encryptSecret } from '@/lib/utils' const logger = createLogger('WorkspaceNotificationsAPI') +/** Maximum email recipients per notification */ +const MAX_EMAIL_RECIPIENTS = 10 + +/** Maximum notifications per type per workspace */ +const MAX_NOTIFICATIONS_PER_TYPE = 10 + 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'])) @@ -48,7 +54,7 @@ const createNotificationSchema = z alertConfig: alertConfigSchema.optional(), webhookUrl: z.string().url().optional(), webhookSecret: z.string().optional(), - emailRecipients: z.array(z.string().email()).optional(), + emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(), slackChannelId: z.string().optional(), slackAccountId: z.string().optional(), }) @@ -144,6 +150,25 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ 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 }) 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 index f9bf6d1ac5f..4042852e9e7 100644 --- 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 @@ -229,6 +229,8 @@ export function NotificationSettings({ .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)) @@ -732,7 +734,9 @@ export function NotificationSettings({ }} className='h-9 rounded-[8px]' /> -

Comma-separated list of email addresses

+

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

{formErrors.emailRecipients && (

{formErrors.emailRecipients}

)} From 222bd4ebd546c8d9236f48f1124f7cb3bb96c7fc Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 2 Dec 2025 09:50:56 -0800 Subject: [PATCH 07/21] address greptile comments --- apps/sim/app/api/auth/accounts/route.ts | 6 ++- .../notifications/[notificationId]/route.ts | 39 +++++++++++-------- .../[id]/notifications/constants.ts | 8 ++++ .../workspaces/[id]/notifications/route.ts | 12 +++--- .../workspace-notification-delivery.ts | 6 +-- apps/sim/hooks/use-slack-accounts.ts | 12 ++++-- 6 files changed, 52 insertions(+), 31 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/notifications/constants.ts diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index 5c1139cfb98..418a04c0271 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -3,6 +3,9 @@ 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 { @@ -30,7 +33,8 @@ export async function GET(request: NextRequest) { .where(and(...whereConditions)) return NextResponse.json({ accounts }) - } catch { + } 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/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 688f6dd702f..dbdcf36bdea 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -7,6 +7,7 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { encryptSecret } from '@/lib/utils' +import { MAX_EMAIL_RECIPIENTS, MAX_WORKFLOW_IDS } from '../constants' const logger = createLogger('WorkspaceNotificationAPI') @@ -32,23 +33,27 @@ const alertConfigSchema = z ) .nullable() -const updateNotificationSchema = z.object({ - workflowIds: z.array(z.string()).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(), - webhookUrl: z.string().url().optional(), - webhookSecret: z.string().optional(), - emailRecipients: z.array(z.string().email()).optional(), - slackChannelId: z.string().optional(), - slackAccountId: z.string().optional(), - active: z.boolean().optional(), -}) +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(), + webhookUrl: z.string().url().optional(), + webhookSecret: z.string().optional(), + emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(), + slackChannelId: z.string().optional(), + slackAccountId: z.string().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 }> } 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 index 24c3fdacf07..c11320b1f14 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -8,15 +8,10 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { encryptSecret } from '@/lib/utils' +import { MAX_EMAIL_RECIPIENTS, MAX_NOTIFICATIONS_PER_TYPE, MAX_WORKFLOW_IDS } from './constants' const logger = createLogger('WorkspaceNotificationsAPI') -/** Maximum email recipients per notification */ -const MAX_EMAIL_RECIPIENTS = 10 - -/** Maximum notifications per type per workspace */ -const MAX_NOTIFICATIONS_PER_TYPE = 10 - 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'])) @@ -43,7 +38,7 @@ const alertConfigSchema = z const createNotificationSchema = z .object({ notificationType: notificationTypeSchema, - workflowIds: z.array(z.string()).default([]), + 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']), @@ -68,6 +63,9 @@ const createNotificationSchema = z }, { 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, diff --git a/apps/sim/background/workspace-notification-delivery.ts b/apps/sim/background/workspace-notification-delivery.ts index 45969758a33..be8f375c0ca 100644 --- a/apps/sim/background/workspace-notification-delivery.ts +++ b/apps/sim/background/workspace-notification-delivery.ts @@ -13,7 +13,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' -import type { WorkflowExecutionLog } from '@/lib/logs/types' +import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types' import { decryptSecret } from '@/lib/utils' import { RateLimiter } from '@/services/queue' @@ -377,8 +377,8 @@ async function deliverSlack( Array.isArray(payload.data.traceSpans) && payload.data.traceSpans.length > 0 ) { - const spansSummary = payload.data.traceSpans - .map((span: any) => { + const spansSummary = (payload.data.traceSpans as TraceSpan[]) + .map((span) => { const status = span.status === 'success' ? '✓' : '✗' return `${status} ${span.name || 'Unknown'} (${formatDuration(span.duration || 0)})` }) diff --git a/apps/sim/hooks/use-slack-accounts.ts b/apps/sim/hooks/use-slack-accounts.ts index 64511b1930e..4bb82543c52 100644 --- a/apps/sim/hooks/use-slack-accounts.ts +++ b/apps/sim/hooks/use-slack-accounts.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' interface SlackAccount { id: string @@ -13,12 +13,16 @@ interface UseSlackAccountsResult { 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 = async () => { + const fetchAccounts = useCallback(async () => { try { setIsLoading(true) setError(null) @@ -27,6 +31,8 @@ export function useSlackAccounts(): UseSlackAccountsResult { 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 { @@ -35,7 +41,7 @@ export function useSlackAccounts(): UseSlackAccountsResult { } finally { setIsLoading(false) } - } + }, []) useEffect(() => { fetchAccounts() From d0fdb86805d2f7127feb125a8d855d88e03262dd Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 2 Dec 2025 12:37:58 -0800 Subject: [PATCH 08/21] add search to combobox --- .../notification-settings.tsx | 70 ++-- .../slack-channel-selector.tsx | 111 +++++++ .../workflow-selector.tsx | 303 +++++++----------- .../emcn/components/combobox/combobox.tsx | 78 ++++- 4 files changed, 338 insertions(+), 224 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/slack-channel-selector.tsx 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 index 4042852e9e7..039a823fa32 100644 --- 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 @@ -36,7 +36,9 @@ import { } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' +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') @@ -133,7 +135,12 @@ export function NotificationSettings({ const [formErrors, setFormErrors] = useState>({}) - const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts() + const { + accounts: slackAccounts, + isLoading: isLoadingSlackAccounts, + refetch: refetchSlackAccounts, + } = useSlackAccounts() + const connectSlack = useConnectOAuthService() const filteredSubscriptions = useMemo(() => { return subscriptions.filter((s) => s.notificationType === activeTab) @@ -245,7 +252,7 @@ export function NotificationSettings({ errors.slackAccountId = 'Select a Slack account' } if (!formData.slackChannelId) { - errors.slackChannelId = 'Enter a Slack channel ID' + errors.slackChannelId = 'Select a Slack channel' } } @@ -752,16 +759,31 @@ export function NotificationSettings({ ) : slackAccounts.length === 0 ? (

No Slack accounts connected

-

- Connect Slack in Settings → Credentials -

+
) : ( { - setFormData({ ...formData, slackChannelId: e.target.value }) - setFormErrors({ ...formErrors, slackChannelId: '' }) - }} - className='h-9 rounded-[8px]' - /> -

- Find this in Slack channel details (starts with C, D, or G) -

- {formErrors.slackChannelId && ( -

{formErrors.slackChannelId}

- )} -
+ {slackAccounts.length > 0 && ( +
+ + { + setFormData({ ...formData, slackChannelId: channelId }) + setFormErrors({ ...formErrors, slackChannelId: '' }) + }} + disabled={!formData.slackAccountId} + error={formErrors.slackChannelId} + /> +
+ )} )} 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..ca6e268f034 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/notification-settings/slack-channel-selector.tsx @@ -0,0 +1,111 @@ +'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) => 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

+
+ ) + } + + 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 index 1adb23da46f..3911fef93c7 100644 --- 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 @@ -1,10 +1,9 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Check, ChevronDown, X } from 'lucide-react' -import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '@/components/emcn' +import { useEffect, useMemo, useState } from 'react' +import { Layers, X } from 'lucide-react' +import { Combobox, type ComboboxOption } from '@/components/emcn' import { Label, Skeleton } from '@/components/ui' -import { cn } from '@/lib/utils' interface WorkflowSelectorProps { workspaceId: string @@ -14,6 +13,11 @@ interface WorkflowSelectorProps { error?: string } +const ALL_WORKFLOWS_VALUE = '__all_workflows__' + +/** + * Multi-select workflow selector with "All Workflows" option. + */ export function WorkflowSelector({ workspaceId, selectedIds, @@ -23,9 +27,6 @@ export function WorkflowSelector({ }: WorkflowSelectorProps) { const [workflows, setWorkflows] = useState>([]) const [isLoading, setIsLoading] = useState(true) - const [open, setOpen] = useState(false) - const [search, setSearch] = useState('') - const inputRef = useRef(null) useEffect(() => { const load = async () => { @@ -45,49 +46,117 @@ export function WorkflowSelector({ load() }, [workspaceId]) - useEffect(() => { - if (!open) setSearch('') - }, [open]) + 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 filteredWorkflows = useMemo(() => { - if (!search) return workflows - const term = search.toLowerCase() - return workflows.filter((w) => w.name.toLowerCase().includes(term)) - }, [workflows, search]) + 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]) - const handleSelect = useCallback( - (id: string) => { - if (selectedIds.includes(id)) { - onChange( - selectedIds.filter((i) => i !== id), - false - ) - } else { - onChange([...selectedIds, id], false) - } - }, - [selectedIds, onChange] - ) + // Render overlay content showing selected items as tags + const overlayContent = useMemo(() => { + if (allWorkflows) { + return ( +
+ + + All Workflows + + +
+ ) + } - const handleSelectAll = useCallback(() => { - onChange([], !allWorkflows) - }, [allWorkflows, onChange]) + if (selectedWorkflows.length === 0) { + return null + } - const handleRemove = useCallback( - (e: React.MouseEvent, id: string) => { - e.preventDefault() - e.stopPropagation() - onChange( - selectedIds.filter((i) => i !== id), - false - ) - }, - [selectedIds, onChange] - ) + return ( +
+ {selectedWorkflows.slice(0, 2).map((w) => ( + + {w.name} + + + ))} + {selectedWorkflows.length > 2 && ( + + +{selectedWorkflows.length - 2} + + )} +
+ ) + }, [allWorkflows, selectedWorkflows, selectedIds]) if (isLoading) { return ( @@ -98,148 +167,20 @@ export function WorkflowSelector({ ) } - const hasSelection = allWorkflows || selectedWorkflows.length > 0 - return (
- - -
setOpen(true)} - className={cn( - 'relative flex w-full cursor-text rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]', - error && 'border-red-400' - )} - > - setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setOpen(false) - inputRef.current?.blur() - } - }} - className={cn( - 'flex-1 bg-transparent px-[8px] py-[6px] font-medium font-sans text-[var(--text-primary)] text-sm outline-none placeholder:text-[var(--text-muted)]', - hasSelection && !open && 'text-transparent' - )} - /> - {hasSelection && !open && ( -
- {allWorkflows ? ( - - All Workflows - - ) : ( - <> - {selectedWorkflows.slice(0, 2).map((w) => ( - - {w.name} - - - ))} - {selectedWorkflows.length > 2 && ( - - +{selectedWorkflows.length - 2} - - )} - - )} -
- )} -
{ - e.stopPropagation() - setOpen((prev) => !prev) - }} - > - -
-
-
- - { - e.preventDefault() - inputRef.current?.focus() - }} - > - -
{ - e.preventDefault() - handleSelectAll() - }} - className={cn( - 'relative flex cursor-pointer select-none items-center rounded-[4px] px-[8px] py-[6px] font-sans text-sm hover:bg-[var(--surface-11)]', - allWorkflows && 'bg-[var(--surface-11)]' - )} - > - All Workflows - Includes future - {allWorkflows && } -
- -
- - {filteredWorkflows.length === 0 ? ( -
- {search ? 'No workflows found' : 'No workflows in workspace'} -
- ) : ( - filteredWorkflows.map((workflow) => { - const isSelected = selectedIds.includes(workflow.id) || allWorkflows - return ( -
{ - e.preventDefault() - if (!allWorkflows) handleSelect(workflow.id) - }} - className={cn( - 'relative flex cursor-pointer select-none items-center rounded-[4px] px-[8px] py-[6px] font-sans text-sm hover:bg-[var(--surface-11)]', - isSelected && !allWorkflows && 'bg-[var(--surface-11)]', - allWorkflows && 'cursor-not-allowed opacity-50' - )} - > - - {workflow.name} - - {isSelected && } -
- ) - }) - )} - - - - {error &&

{error}

} +

Select which workflows should trigger this notification

diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index af21d087f41..3313463e770 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 { Check, ChevronDown, Loader2 } from 'lucide-react' +import { Check, ChevronDown, Loader2, Search } from 'lucide-react' import { cn } from '@/lib/utils' import { Input } from '../input/input' import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover' @@ -81,6 +81,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 } /** @@ -110,12 +114,16 @@ const Combobox = forwardRef( isLoading = false, error = null, onOpenChange, + searchable = false, + searchPlaceholder = 'Search...', ...props }, ref ) => { 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) @@ -129,26 +137,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 @@ -334,6 +354,7 @@ const Combobox = forwardRef( open={open} onOpenChange={(next) => { setOpen(next) + if (!next) setSearchQuery('') onOpenChange?.(next) }} > @@ -435,6 +456,9 @@ const Combobox = forwardRef( className='w-[var(--radix-popover-trigger-width)] rounded-[4px] p-0' 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, @@ -446,6 +470,24 @@ const Combobox = forwardRef( } }} > + {searchable && ( +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setOpen(false) + setSearchQuery('') + } + }} + /> +
+ )} { @@ -480,7 +522,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'}
) : ( filteredOptions.map((option, index) => { From 4e148628581939e737bbeddf0a152887bf9ee929 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 2 Dec 2025 12:46:36 -0800 Subject: [PATCH 09/21] move notifications to react query --- .../notification-settings.tsx | 248 ++++++---------- apps/sim/hooks/queries/notifications.ts | 276 ++++++++++++++++++ 2 files changed, 369 insertions(+), 155 deletions(-) create mode 100644 apps/sim/hooks/queries/notifications.ts 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 index 039a823fa32..e0c72b4ae91 100644 --- 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 @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { AlertCircle, Bell, @@ -36,6 +36,15 @@ import { } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' +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' @@ -48,34 +57,6 @@ type LogLevel = 'info' | 'error' type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' type AlertRule = 'consecutive_failures' | 'failure_rate' -interface AlertConfig { - rule: AlertRule - consecutiveFailures?: number - failureRatePercent?: number - windowHours?: number -} - -interface NotificationSubscription { - id: string - notificationType: NotificationType - workflowIds: string[] - allWorkflows: boolean - levelFilter: LogLevel[] - triggerFilter: TriggerType[] - includeFinalOutput: boolean - includeTraceSpans: boolean - includeRateLimits: boolean - includeUsageData: boolean - webhookUrl?: string | null - emailRecipients?: string[] | null - slackChannelId?: string | null - slackAccountId?: string | null - alertConfig?: AlertConfig | null - active: boolean - createdAt: string - updatedAt: string -} - interface NotificationSettingsProps { workspaceId: string open: boolean @@ -96,16 +77,11 @@ export function NotificationSettings({ open, onOpenChange, }: NotificationSettingsProps) { - const [subscriptions, setSubscriptions] = useState([]) - const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('webhook') const [showForm, setShowForm] = useState(false) const [editingId, setEditingId] = useState(null) - const [isSaving, setIsSaving] = useState(false) - const [isTesting, setIsTesting] = useState(null) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [deletingId, setDeletingId] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) const [testStatus, setTestStatus] = useState<{ id: string success: boolean @@ -135,38 +111,21 @@ export function NotificationSettings({ const [formErrors, setFormErrors] = useState>({}) - const { - accounts: slackAccounts, - isLoading: isLoadingSlackAccounts, - refetch: refetchSlackAccounts, - } = useSlackAccounts() + // React Query hooks + 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 loadSubscriptions = useCallback(async () => { - try { - setIsLoading(true) - const response = await fetch(`/api/workspaces/${workspaceId}/notifications`) - if (response.ok) { - const data = await response.json() - setSubscriptions(data.data || []) - } - } catch (error) { - logger.error('Failed to load notifications', { error }) - } finally { - setIsLoading(false) - } - }, [workspaceId]) - - useEffect(() => { - if (open) { - loadSubscriptions() - } - }, [open, loadSubscriptions]) - const resetForm = useCallback(() => { setFormData({ workflowIds: [], @@ -278,72 +237,64 @@ export function NotificationSettings({ const handleSave = async () => { if (!validateForm()) return - setIsSaving(true) - try { - const alertConfig: AlertConfig | null = formData.useAlertRule - ? { - rule: formData.alertRule, - ...(formData.alertRule === 'consecutive_failures' && { - consecutiveFailures: formData.consecutiveFailures, - }), - ...(formData.alertRule === 'failure_rate' && { - failureRatePercent: formData.failureRatePercent, - 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' && { - webhookUrl: formData.webhookUrl, - webhookSecret: formData.webhookSecret || undefined, - }), - ...(activeTab === 'email' && { - emailRecipients: formData.emailRecipients - .split(',') - .map((e) => e.trim()) - .filter(Boolean), - }), - ...(activeTab === 'slack' && { - slackChannelId: formData.slackChannelId, - slackAccountId: formData.slackAccountId, - }), - } - - const url = editingId - ? `/api/workspaces/${workspaceId}/notifications/${editingId}` - : `/api/workspaces/${workspaceId}/notifications` - const method = editingId ? 'PUT' : 'POST' - - const response = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) + const alertConfig = formData.useAlertRule + ? { + rule: formData.alertRule, + ...(formData.alertRule === 'consecutive_failures' && { + consecutiveFailures: formData.consecutiveFailures, + }), + ...(formData.alertRule === 'failure_rate' && { + failureRatePercent: formData.failureRatePercent, + 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' && { + webhookUrl: formData.webhookUrl, + webhookSecret: formData.webhookSecret || undefined, + }), + ...(activeTab === 'email' && { + emailRecipients: formData.emailRecipients + .split(',') + .map((e) => e.trim()) + .filter(Boolean), + }), + ...(activeTab === 'slack' && { + slackChannelId: formData.slackChannelId, + slackAccountId: formData.slackAccountId, + }), + } - if (response.ok) { - await loadSubscriptions() - resetForm() - setShowForm(false) + try { + if (editingId) { + await updateNotification.mutateAsync({ + workspaceId, + notificationId: editingId, + data: payload, + }) } else { - const error = await response.json() - setFormErrors({ general: error.error || 'Failed to save notification' }) + await createNotification.mutateAsync({ + workspaceId, + data: payload, + }) } + resetForm() + setShowForm(false) } catch (error) { - logger.error('Failed to save notification', { error }) - setFormErrors({ general: 'Failed to save notification' }) - } finally { - setIsSaving(false) + const message = error instanceof Error ? error.message : 'Failed to save notification' + setFormErrors({ general: message }) } } @@ -376,56 +327,43 @@ export function NotificationSettings({ const handleDelete = async () => { if (!deletingId) return - setIsDeleting(true) try { - const response = await fetch(`/api/workspaces/${workspaceId}/notifications/${deletingId}`, { - method: 'DELETE', + await deleteNotification.mutateAsync({ + workspaceId, + notificationId: deletingId, }) - - if (response.ok) { - await loadSubscriptions() - } } catch (error) { logger.error('Failed to delete notification', { error }) } finally { - setIsDeleting(false) setShowDeleteDialog(false) setDeletingId(null) } } const handleTest = async (id: string) => { - setIsTesting(id) setTestStatus(null) try { - const response = await fetch(`/api/workspaces/${workspaceId}/notifications/${id}/test`, { - method: 'POST', + const result = await testNotification.mutateAsync({ + workspaceId, + notificationId: id, }) - const data = await response.json() setTestStatus({ id, - success: data.data?.success ?? false, + success: result.data?.success ?? false, message: - data.data?.error || (data.data?.success ? 'Test sent successfully' : 'Test failed'), + result.data?.error || (result.data?.success ? 'Test sent successfully' : 'Test failed'), }) } catch (error) { setTestStatus({ id, success: false, message: 'Failed to send test' }) - } finally { - setIsTesting(null) } } - const handleToggleActive = async (subscription: NotificationSubscription) => { - try { - await fetch(`/api/workspaces/${workspaceId}/notifications/${subscription.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ active: !subscription.active }), - }) - await loadSubscriptions() - } catch (error) { - logger.error('Failed to toggle notification', { error }) - } + const handleToggleActive = (subscription: NotificationSubscription) => { + toggleActive.mutate({ + workspaceId, + notificationId: subscription.id, + active: !subscription.active, + }) } const renderSubscriptionItem = (subscription: NotificationSubscription) => { @@ -473,7 +411,7 @@ export function NotificationSettings({ variant='ghost' size='icon' onClick={() => handleTest(subscription.id)} - disabled={isTesting === subscription.id} + disabled={testNotification.isPending} className='h-8 w-8' > @@ -982,10 +920,10 @@ export function NotificationSettings({