Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
126a42c
feat(notification): slack, email, webhook notifications from logs
icecrasher321 Dec 2, 2025
5928b92
retain search params for filters to link in notification
icecrasher321 Dec 2, 2025
8e6c509
add alerting rules
icecrasher321 Dec 2, 2025
5d4bcdc
update selector
icecrasher321 Dec 2, 2025
3b8fad2
fix lint
icecrasher321 Dec 2, 2025
ed82ded
add limits on num of emails and notification triggers per workspace
icecrasher321 Dec 2, 2025
222bd4e
address greptile comments
icecrasher321 Dec 2, 2025
d0fdb86
add search to combobox
icecrasher321 Dec 2, 2025
4e14862
move notifications to react query
icecrasher321 Dec 2, 2025
0e58fae
fix lint
icecrasher321 Dec 2, 2025
a004934
fix email formatting
icecrasher321 Dec 2, 2025
6cd5707
add more alert types
icecrasher321 Dec 2, 2025
f0b525f
Merge branch 'staging' into feat/notifications-workflow-execs
icecrasher321 Dec 2, 2025
cfc1954
fix imports
icecrasher321 Dec 2, 2025
d63bb9e
fix test route
icecrasher321 Dec 2, 2025
cc5a165
Merge branch 'staging' into feat/notifications-workflow-execs
icecrasher321 Dec 4, 2025
30b0391
use emcn componentfor modal
icecrasher321 Dec 4, 2025
6d1ff0c
refactor: consolidate notification config fields into jsonb objects
icecrasher321 Dec 4, 2025
6c8019f
regen migration
icecrasher321 Dec 4, 2025
f09f2dc
fix delete notif modal ui
icecrasher321 Dec 5, 2025
909b349
make them multiselect dropdowns
icecrasher321 Dec 5, 2025
e35517f
update tag styling
icecrasher321 Dec 5, 2025
747e820
combobox font size with multiselect tags'
icecrasher321 Dec 5, 2025
64305ab
Merge staging into feat/notifications-workflow-execs
icecrasher321 Dec 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor: consolidate notification config fields into jsonb objects
  • Loading branch information
icecrasher321 committed Dec 4, 2025
commit 6d1ff0c6b8c1a4a8aa4a09d145519d83dfd128e0
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ const alertConfigSchema = z
)
.nullable()

const webhookConfigSchema = z.object({
url: z.string().url(),
secret: z.string().optional(),
})

const slackConfigSchema = z.object({
channelId: z.string(),
channelName: z.string(),
accountId: z.string(),
})

const updateNotificationSchema = z
.object({
workflowIds: z.array(z.string()).max(MAX_WORKFLOW_IDS).optional(),
Expand All @@ -72,11 +83,9 @@ const updateNotificationSchema = z
includeRateLimits: z.boolean().optional(),
includeUsageData: z.boolean().optional(),
alertConfig: alertConfigSchema.optional(),
webhookUrl: z.string().url().optional(),
webhookSecret: z.string().optional(),
webhookConfig: webhookConfigSchema.optional(),
emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(),
slackChannelId: z.string().optional(),
slackAccountId: z.string().optional(),
slackConfig: slackConfigSchema.optional(),
active: z.boolean().optional(),
})
.refine((data) => !(data.allWorkflows && data.workflowIds && data.workflowIds.length > 0), {
Expand Down Expand Up @@ -140,10 +149,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookUrl: subscription.webhookUrl,
webhookConfig: subscription.webhookConfig,
emailRecipients: subscription.emailRecipients,
slackChannelId: subscription.slackChannelId,
slackAccountId: subscription.slackAccountId,
slackConfig: subscription.slackConfig,
alertConfig: subscription.alertConfig,
active: subscription.active,
createdAt: subscription.createdAt,
Expand Down Expand Up @@ -217,19 +225,18 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
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
if (data.slackAccountId !== undefined) updateData.slackAccountId = data.slackAccountId
if (data.slackConfig !== undefined) updateData.slackConfig = data.slackConfig
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
// Handle webhookConfig with secret encryption
if (data.webhookConfig !== undefined) {
let webhookConfig = data.webhookConfig
if (webhookConfig?.secret) {
const { encrypted } = await encryptSecret(webhookConfig.secret)
webhookConfig = { ...webhookConfig, secret: encrypted }
}
updateData.webhookConfig = webhookConfig
}

const [subscription] = await db
Expand All @@ -255,10 +262,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookUrl: subscription.webhookUrl,
webhookConfig: subscription.webhookConfig,
emailRecipients: subscription.emailRecipients,
slackChannelId: subscription.slackChannelId,
slackAccountId: subscription.slackAccountId,
slackConfig: subscription.slackConfig,
alertConfig: subscription.alertConfig,
active: subscription.active,
createdAt: subscription.createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ const logger = createLogger('WorkspaceNotificationTestAPI')

type RouteParams = { params: Promise<{ id: string; notificationId: string }> }

interface WebhookConfig {
url: string
secret?: string
}

interface SlackConfig {
channelId: string
channelName: string
accountId: string
}

function generateSignature(secret: string, timestamp: number, body: string): string {
const signatureBase = `${timestamp}.${body}`
const hmac = createHmac('sha256', secret)
Expand Down Expand Up @@ -85,7 +96,8 @@ function buildTestPayload(subscription: typeof workspaceNotificationSubscription
}

async function testWebhook(subscription: typeof workspaceNotificationSubscription.$inferSelect) {
if (!subscription.webhookUrl) {
const webhookConfig = subscription.webhookConfig as WebhookConfig | null
if (!webhookConfig?.url) {
return { success: false, error: 'No webhook URL configured' }
}

Expand All @@ -101,8 +113,8 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
'Idempotency-Key': deliveryId,
}

if (subscription.webhookSecret) {
const { decrypted } = await decryptSecret(subscription.webhookSecret)
if (webhookConfig.secret) {
const { decrypted } = await decryptSecret(webhookConfig.secret)
const signature = generateSignature(decrypted, timestamp, body)
headers['sim-signature'] = `t=${timestamp},v1=${signature}`
}
Expand All @@ -111,7 +123,7 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
const timeoutId = setTimeout(() => controller.abort(), 10000)

try {
const response = await fetch(subscription.webhookUrl, {
const response = await fetch(webhookConfig.url, {
method: 'POST',
headers,
body,
Expand Down Expand Up @@ -176,14 +188,15 @@ async function testSlack(
subscription: typeof workspaceNotificationSubscription.$inferSelect,
userId: string
) {
if (!subscription.slackChannelId || !subscription.slackAccountId) {
const slackConfig = subscription.slackConfig as SlackConfig | null
if (!slackConfig?.channelId || !slackConfig?.accountId) {
return { success: false, error: 'No Slack channel or account configured' }
}

const [slackAccount] = await db
.select({ accessToken: account.accessToken })
.from(account)
.where(and(eq(account.id, subscription.slackAccountId), eq(account.userId, userId)))
.where(and(eq(account.id, slackConfig.accountId), eq(account.userId, userId)))
.limit(1)

if (!slackAccount?.accessToken) {
Expand All @@ -194,7 +207,7 @@ async function testSlack(
const data = (payload as Record<string, unknown>).data as Record<string, unknown>

const slackPayload = {
channel: subscription.slackChannelId,
channel: slackConfig.channelId,
blocks: [
{
type: 'header',
Expand Down
47 changes: 27 additions & 20 deletions apps/sim/app/api/workspaces/[id]/notifications/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ const alertConfigSchema = z
)
.nullable()

const webhookConfigSchema = z.object({
url: z.string().url(),
secret: z.string().optional(),
})

const slackConfigSchema = z.object({
channelId: z.string(),
channelName: z.string(),
accountId: z.string(),
})

const createNotificationSchema = z
.object({
notificationType: notificationTypeSchema,
Expand All @@ -75,18 +86,17 @@ const createNotificationSchema = z
includeRateLimits: z.boolean().default(false),
includeUsageData: z.boolean().default(false),
alertConfig: alertConfigSchema.optional(),
webhookUrl: z.string().url().optional(),
webhookSecret: z.string().optional(),
webhookConfig: webhookConfigSchema.optional(),
emailRecipients: z.array(z.string().email()).max(MAX_EMAIL_RECIPIENTS).optional(),
slackChannelId: z.string().optional(),
slackAccountId: z.string().optional(),
slackConfig: slackConfigSchema.optional(),
})
.refine(
(data) => {
if (data.notificationType === 'webhook') return !!data.webhookUrl
if (data.notificationType === 'webhook') return !!data.webhookConfig?.url
if (data.notificationType === 'email')
return !!data.emailRecipients && data.emailRecipients.length > 0
if (data.notificationType === 'slack') return !!data.slackChannelId && !!data.slackAccountId
if (data.notificationType === 'slack')
return !!data.slackConfig?.channelId && !!data.slackConfig?.accountId
return false
},
{ message: 'Missing required fields for notification type' }
Expand Down Expand Up @@ -130,10 +140,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
includeTraceSpans: workspaceNotificationSubscription.includeTraceSpans,
includeRateLimits: workspaceNotificationSubscription.includeRateLimits,
includeUsageData: workspaceNotificationSubscription.includeUsageData,
webhookUrl: workspaceNotificationSubscription.webhookUrl,
webhookConfig: workspaceNotificationSubscription.webhookConfig,
emailRecipients: workspaceNotificationSubscription.emailRecipients,
slackChannelId: workspaceNotificationSubscription.slackChannelId,
slackAccountId: workspaceNotificationSubscription.slackAccountId,
slackConfig: workspaceNotificationSubscription.slackConfig,
alertConfig: workspaceNotificationSubscription.alertConfig,
active: workspaceNotificationSubscription.active,
createdAt: workspaceNotificationSubscription.createdAt,
Expand Down Expand Up @@ -212,10 +221,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}

let encryptedSecret: string | null = null
if (data.webhookSecret) {
const { encrypted } = await encryptSecret(data.webhookSecret)
encryptedSecret = encrypted
// Encrypt webhook secret if provided
let webhookConfig = data.webhookConfig || null
if (webhookConfig?.secret) {
const { encrypted } = await encryptSecret(webhookConfig.secret)
webhookConfig = { ...webhookConfig, secret: encrypted }
}

const [subscription] = await db
Expand All @@ -233,11 +243,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
includeRateLimits: data.includeRateLimits,
includeUsageData: data.includeUsageData,
alertConfig: data.alertConfig || null,
webhookUrl: data.webhookUrl || null,
webhookSecret: encryptedSecret,
webhookConfig,
emailRecipients: data.emailRecipients || null,
slackChannelId: data.slackChannelId || null,
slackAccountId: data.slackAccountId || null,
slackConfig: data.slackConfig || null,
createdBy: session.user.id,
})
.returning()
Expand All @@ -260,10 +268,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookUrl: subscription.webhookUrl,
webhookConfig: subscription.webhookConfig,
emailRecipients: subscription.emailRecipients,
slackChannelId: subscription.slackChannelId,
slackAccountId: subscription.slackAccountId,
slackConfig: subscription.slackConfig,
alertConfig: subscription.alertConfig,
active: subscription.active,
createdAt: subscription.createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export function NotificationSettings({
webhookSecret: '',
emailRecipients: '',
slackChannelId: '',
slackChannelName: '',
slackAccountId: '',
useAlertRule: false,
alertRule: 'consecutive_failures' as AlertRule,
Expand Down Expand Up @@ -187,6 +188,7 @@ export function NotificationSettings({
webhookSecret: '',
emailRecipients: '',
slackChannelId: '',
slackChannelName: '',
slackAccountId: '',
useAlertRule: false,
alertRule: 'consecutive_failures',
Expand Down Expand Up @@ -365,8 +367,10 @@ export function NotificationSettings({
includeUsageData: formData.includeUsageData,
alertConfig,
...(activeTab === 'webhook' && {
webhookUrl: formData.webhookUrl,
webhookSecret: formData.webhookSecret || undefined,
webhookConfig: {
url: formData.webhookUrl,
secret: formData.webhookSecret || undefined,
},
}),
...(activeTab === 'email' && {
emailRecipients: formData.emailRecipients
Expand All @@ -375,8 +379,11 @@ export function NotificationSettings({
.filter(Boolean),
}),
...(activeTab === 'slack' && {
slackChannelId: formData.slackChannelId,
slackAccountId: formData.slackAccountId,
slackConfig: {
channelId: formData.slackChannelId,
channelName: formData.slackChannelName,
accountId: formData.slackAccountId,
},
}),
}

Expand Down Expand Up @@ -413,11 +420,12 @@ export function NotificationSettings({
includeTraceSpans: subscription.includeTraceSpans,
includeRateLimits: subscription.includeRateLimits,
includeUsageData: subscription.includeUsageData,
webhookUrl: subscription.webhookUrl || '',
webhookUrl: subscription.webhookConfig?.url || '',
webhookSecret: '',
emailRecipients: subscription.emailRecipients?.join(', ') || '',
slackChannelId: subscription.slackChannelId || '',
slackAccountId: subscription.slackAccountId || '',
slackChannelId: subscription.slackConfig?.channelId || '',
slackChannelName: subscription.slackConfig?.channelName || '',
slackAccountId: subscription.slackConfig?.accountId || '',
useAlertRule: !!subscription.alertConfig,
alertRule: subscription.alertConfig?.rule || 'consecutive_failures',
consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3,
Expand Down Expand Up @@ -477,10 +485,10 @@ export function NotificationSettings({
const renderSubscriptionItem = (subscription: NotificationSubscription) => {
const identifier =
subscription.notificationType === 'webhook'
? subscription.webhookUrl
? subscription.webhookConfig?.url
: subscription.notificationType === 'email'
? subscription.emailRecipients?.join(', ')
: `Channel: ${subscription.slackChannelId}`
: `#${subscription.slackConfig?.channelName || subscription.slackConfig?.channelId}`

return (
<div key={subscription.id} className='mb-4 flex flex-col gap-2'>
Expand Down Expand Up @@ -978,8 +986,12 @@ export function NotificationSettings({
<SlackChannelSelector
accountId={formData.slackAccountId}
value={formData.slackChannelId}
onChange={(channelId) => {
setFormData({ ...formData, slackChannelId: channelId })
onChange={(channelId, channelName) => {
setFormData({
...formData,
slackChannelId: channelId,
slackChannelName: channelName,
})
setFormErrors({ ...formErrors, slackChannelId: '' })
}}
disabled={!formData.slackAccountId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface SlackChannel {
interface SlackChannelSelectorProps {
accountId: string
value: string
onChange: (channelId: string) => void
onChange: (channelId: string, channelName: string) => void
disabled?: boolean
error?: string
}
Expand Down Expand Up @@ -87,12 +87,17 @@ export function SlackChannelSelector({
)
}

const handleChange = (channelId: string) => {
const channel = channels.find((c) => c.id === channelId)
onChange(channelId, channel?.name || '')
}

return (
<div className='space-y-1'>
<Combobox
options={options}
value={value}
onChange={onChange}
onChange={handleChange}
placeholder={
channels.length === 0 && !isLoading ? 'No channels available' : 'Select channel...'
}
Expand Down
Loading