-
Actions Taken
+
Actions Taken
{issue.actions_taken.map((action, i) => (
{action.label}
- · {new Date(action.taken_at).toLocaleDateString()}
+
+ · {new Date(action.taken_at).toLocaleDateString()}
+
))}
diff --git a/apps/studio/components/interfaces/Advisors/Issues/IssueDetail.tsx b/apps/studio/components/interfaces/Advisors/Issues/IssueDetail.tsx
index cb4fbcc7f08..ae2992aa42e 100644
--- a/apps/studio/components/interfaces/Advisors/Issues/IssueDetail.tsx
+++ b/apps/studio/components/interfaces/Advisors/Issues/IssueDetail.tsx
@@ -1,9 +1,12 @@
import { useParams } from 'common'
+import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
+import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
import { useAdvisorIssueDetailQuery, useUpdateIssueMutation } from 'data/advisors/issues-query'
import { useAdvisorRulesQuery } from 'data/advisors/rules-query'
+import type { AdvisorAlert, AdvisorRule, AdvisorSeverity, IssueStatus } from 'data/advisors/types'
import { executeSql } from 'data/sql/execute-sql-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import type { AdvisorAlert, AdvisorSeverity, IssueStatus } from 'data/advisors/types'
+import { useTrack } from 'lib/telemetry/track'
import {
AlertOctagon,
AlertTriangle,
@@ -13,24 +16,47 @@ import {
Eye,
EyeOff,
Info,
- Play,
RefreshCw,
} from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
+import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
+import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { Badge, Button, Card, CardContent } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
+
import { IssueActions } from './IssueActions'
+import { createIssueSummaryPrompt } from './IssueDetail.utils'
const severityConfig: Record<
AdvisorSeverity,
- { icon: typeof AlertOctagon; color: string; bg: string; badgeVariant: 'destructive' | 'warning' | 'default' }
+ {
+ icon: typeof AlertOctagon
+ color: string
+ bg: string
+ badgeVariant: 'destructive' | 'warning' | 'default'
+ }
> = {
- critical: { icon: AlertOctagon, color: 'text-destructive-600', bg: 'bg-destructive-200', badgeVariant: 'destructive' },
- warning: { icon: AlertTriangle, color: 'text-warning-600', bg: 'bg-warning-200', badgeVariant: 'warning' },
- info: { icon: Info, color: 'text-foreground-lighter', bg: 'bg-surface-200', badgeVariant: 'default' },
+ critical: {
+ icon: AlertOctagon,
+ color: 'text-destructive-600',
+ bg: 'bg-destructive-200',
+ badgeVariant: 'destructive',
+ },
+ warning: {
+ icon: AlertTriangle,
+ color: 'text-warning-600',
+ bg: 'bg-warning-200',
+ badgeVariant: 'warning',
+ },
+ info: {
+ icon: Info,
+ color: 'text-foreground-lighter',
+ bg: 'bg-surface-200',
+ badgeVariant: 'default',
+ },
}
function AlertTimelineItem({ alert }: { alert: AdvisorAlert }) {
@@ -43,7 +69,7 @@ function AlertTimelineItem({ alert }: { alert: AdvisorAlert }) {
-
{alert.title}
+
{alert.title}
{alert.description && (
{alert.description}
@@ -98,16 +124,16 @@ function RerunCheckButton({ ruleId, projectRef }: { ruleId: string | null; proje
return (
-
+
-
Verify Fix
+
Verify Fix
Re-run the rule's SQL check to confirm the issue is resolved.
}
@@ -118,13 +144,13 @@ function RerunCheckButton({ ruleId, projectRef }: { ruleId: string | null; proje
{result === 'pass' && (
- Passed — no issues found
+ Passed — no issues found
)}
{result === 'fail' && (
-
Still detected — issue persists
+
Still detected — issue persists
)}
@@ -144,15 +170,15 @@ function ResolutionSteps({ isActive }: { isActive: boolean }) {
return (
- Resolution Steps
+ Resolution Steps
{steps.map((step, i) => (
-
+
{step.num}
-
{step.label}
+
{step.label}
{step.description}
@@ -166,7 +192,11 @@ function ResolutionSteps({ isActive }: { isActive: boolean }) {
export function IssueDetail() {
const { ref: projectRef, id: issueId } = useParams()
const { data: issue, isLoading } = useAdvisorIssueDetailQuery(projectRef, issueId)
+ const { data: rules } = useAdvisorRulesQuery(projectRef)
const updateMutation = useUpdateIssueMutation(projectRef)
+ const track = useTrack()
+ const snap = useAiAssistantStateSnapshot()
+ const { openSidebar } = useSidebarManagerSnapshot()
const handleStatusChange = (status: IssueStatus) => {
if (!issueId) return
@@ -192,6 +222,23 @@ export function IssueDetail() {
const firstAlert = issue.alerts?.[0]
const ruleId = firstAlert?.rule_id ?? null
+ const rule: AdvisorRule | undefined = rules?.find((r) => r.id === ruleId)
+
+ const handleAskAssistant = () => {
+ track('advisor_assistant_button_clicked', {
+ origin: 'issue_detail',
+ advisorCategory: issue.category,
+ advisorType: rule?.name ?? 'unknown',
+ advisorLevel: issue.severity,
+ })
+ openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
+ snap.newChat({
+ name: 'Analyze issue',
+ initialMessage: createIssueSummaryPrompt(issue, rule),
+ })
+ }
+
+ const buildPromptForCopy = () => createIssueSummaryPrompt(issue, rule)
return (
@@ -213,34 +260,67 @@ export function IssueDetail() {
{issue.category}
{issue.status}
·
- {issue.alert_count} alert{issue.alert_count !== 1 ? 's' : ''}
+
+ {issue.alert_count} alert{issue.alert_count !== 1 ? 's' : ''}
+
+
{isActive && issue.status !== 'acknowledged' && (
-
} onClick={() => handleStatusChange('acknowledged')}>
+
}
+ onClick={() => handleStatusChange('acknowledged')}
+ >
Acknowledge
)}
{isActive && (
-
} onClick={() => handleStatusChange('snoozed')}>
+
}
+ onClick={() => handleStatusChange('snoozed')}
+ >
Snooze
)}
{isActive && (
-
} onClick={() => handleStatusChange('resolved')}>
+
}
+ onClick={() => handleStatusChange('resolved')}
+ >
Resolve
)}
{!isActive && issue.status === 'resolved' && (
-
} onClick={() => handleStatusChange('open')}>
+
}
+ onClick={() => handleStatusChange('open')}
+ >
Reopen
)}
{isActive && (
-
} onClick={() => handleStatusChange('dismissed')}>
+
}
+ onClick={() => handleStatusChange('dismissed')}
+ >
Dismiss
)}
@@ -249,14 +329,14 @@ export function IssueDetail() {
-
-
+
+
-
-
Alert Timeline
+
+
Alert Timeline
-
+
{issue.alerts && issue.alerts.length > 0 ? (
{issue.alerts.map((alert) => (
@@ -272,14 +352,14 @@ export function IssueDetail() {
-
+
{issue.description && (
-
- Description
+
+ Description
{issue.description}
@@ -288,38 +368,50 @@ export function IssueDetail() {
)}
-
- Details
-
+
+ Details
+
- Status
- - {issue.status}
+ - {issue.status}
- Category
- - {issue.category}
+ - {issue.category}
- Alerts
- - {issue.alert_count}
+ - {issue.alert_count}
- First seen
- -
-
+
-
+
- Last seen
- -
-
+
-
+
{issue.resolved_at && (
- Resolved
- -
-
+
-
+
)}
diff --git a/apps/studio/components/interfaces/Advisors/Issues/IssueDetail.utils.ts b/apps/studio/components/interfaces/Advisors/Issues/IssueDetail.utils.ts
new file mode 100644
index 00000000000..e9819f0da9d
--- /dev/null
+++ b/apps/studio/components/interfaces/Advisors/Issues/IssueDetail.utils.ts
@@ -0,0 +1,68 @@
+import type { LintInfo } from '@/components/interfaces/Linter/Linter.constants'
+import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
+import type { AdvisorIssue, AdvisorRule } from 'data/advisors/types'
+
+export function createIssueSummaryPrompt(issue: AdvisorIssue, rule?: AdvisorRule | null): string {
+ const entity =
+ issue.metadata?.schema && issue.metadata?.name
+ ? `${issue.metadata.schema}.${issue.metadata.name}`
+ : (issue.metadata?.entity as string) ?? 'N/A'
+
+ const schema = (issue.metadata?.schema as string) ?? 'N/A'
+
+ const suggestedActionsSummary =
+ issue.suggested_actions.length > 0
+ ? issue.suggested_actions.map((a) => `- [${a.type}] ${a.label}`).join('\n')
+ : 'None'
+
+ const parts = [
+ `Analyze this database issue and suggest specific fixes:`,
+ `Title: ${issue.title}`,
+ `Severity: ${issue.severity}`,
+ `Category: ${issue.category}`,
+ `Entity: ${entity}`,
+ `Schema: ${schema}`,
+ ]
+
+ if (issue.description) {
+ parts.push(`Description: ${issue.description}`)
+ }
+
+ if (rule) {
+ parts.push(`Rule: ${rule.name}`)
+ if (rule.remediation) {
+ parts.push(`Documentation: ${rule.remediation}`)
+ }
+ }
+
+ parts.push(`Suggested Actions:\n${suggestedActionsSummary}`)
+
+ if (issue.metadata && Object.keys(issue.metadata).length > 0) {
+ parts.push(`Metadata: ${JSON.stringify(issue.metadata)}`)
+ }
+
+ return parts.join('\n')
+}
+
+export interface KnownIssueInfo {
+ lintInfo: LintInfo
+ docsLink: string
+ navLink: string
+ navLinkText: string
+}
+
+export function getKnownIssueInfo(
+ ruleName: string,
+ projectRef: string,
+ metadata: Record
+): KnownIssueInfo | null {
+ const lintInfo = lintInfoMap.find((item) => item.name === ruleName)
+ if (!lintInfo) return null
+
+ return {
+ lintInfo,
+ docsLink: lintInfo.docsLink,
+ navLink: lintInfo.link({ projectRef, metadata }),
+ navLinkText: lintInfo.linkText,
+ }
+}
diff --git a/apps/studio/components/interfaces/Advisors/Overview/AdvisorsOverview.tsx b/apps/studio/components/interfaces/Advisors/Overview/AdvisorsOverview.tsx
index db5bb41fe21..a610f522122 100644
--- a/apps/studio/components/interfaces/Advisors/Overview/AdvisorsOverview.tsx
+++ b/apps/studio/components/interfaces/Advisors/Overview/AdvisorsOverview.tsx
@@ -1,11 +1,11 @@
+import { useParams } from 'common'
import { useIsAdvisorsV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants'
import { lintInfoMap } from 'components/interfaces/Linter/Linter.utils'
-import { useParams } from 'common'
-import { useAdvisorIssuesQuery } from 'data/advisors/issues-query'
-import { useAdvisorRulesQuery } from 'data/advisors/rules-query'
import { useAdvisorAgentsQuery } from 'data/advisors/agents-query'
import { useAdvisorChannelsQuery } from 'data/advisors/channels-query'
+import { useAdvisorIssuesQuery } from 'data/advisors/issues-query'
+import { useAdvisorRulesQuery } from 'data/advisors/rules-query'
import type { AdvisorIssue } from 'data/advisors/types'
import { Lint, useProjectLintsQuery } from 'data/lint/lint-query'
import {
@@ -27,6 +27,7 @@ import {
import Link from 'next/link'
import { Badge, Button, Card, CardContent } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+
import { QuickSetupCard } from './QuickSetupCard'
function HealthScoreBanner({
@@ -62,8 +63,7 @@ function HealthScoreBanner({
const parts: string[] = []
if (critical.length > 0)
parts.push(`${critical.length} critical issue${critical.length !== 1 ? 's' : ''}`)
- if (lintErrors > 0)
- parts.push(`${lintErrors} lint error${lintErrors !== 1 ? 's' : ''}`)
+ if (lintErrors > 0) parts.push(`${lintErrors} lint error${lintErrors !== 1 ? 's' : ''}`)
const extras: string[] = []
if (warnings.length > 0)
@@ -82,9 +82,7 @@ function HealthScoreBanner({
{parts.join(' and ')} need attention
- {extras.length > 0
- ? `Plus ${extras.join(' and ')}. `
- : ''}
+ {extras.length > 0 ? `Plus ${extras.join(' and ')}. ` : ''}
Review the Security and Performance advisors to keep your project healthy.
@@ -92,7 +90,7 @@ function HealthScoreBanner({
}
>
View issues
@@ -127,7 +125,7 @@ function HealthScoreBanner({
}
>
View issues
@@ -390,11 +388,7 @@ function LintFindingsSummary({
{warnings.length} warning{warnings.length !== 1 ? 's' : ''}
)}
- {infos.length > 0 && (
-
- {infos.length} info
-
- )}
+ {infos.length > 0 &&
{infos.length} info}
Performance Advisor
-
- Identify performance bottlenecks
-
+
Identify performance bottlenecks
@@ -504,10 +496,7 @@ export function AdvisorsOverview() {
isPending: lintsLoading,
isRefetching: lintsRefetching,
refetch: refetchLints,
- } = useProjectLintsQuery(
- { projectRef },
- { enabled: !!isV2 }
- )
+ } = useProjectLintsQuery({ projectRef }, { enabled: !!isV2 })
const isLoading = issuesLoading || rulesLoading || (isV2 && lintsLoading)
@@ -561,8 +550,8 @@ export function AdvisorsOverview() {
{isV2 && allLints.length > 0 && (
- Lint findings are real-time checks from the Security and Performance Advisors.
- Click a finding to see full details and remediation steps.
+ Lint findings are real-time checks from the Security and Performance Advisors. Click a
+ finding to see full details and remediation steps.
- {ALL_TEMPLATES.map((template) => (
-
- ))}
-
+
+
+
+
+
+
+
+
+
Rule Templates
+
+ Quick-start rule packs you can apply. Customize after adding.
+
+
+
+
+
+ View more templates
+
+
+
+ {ALL_TEMPLATES.map((template) => (
+
+ ))}
+
+
+
)
}
@@ -57,14 +87,19 @@ function TemplateCard({
}
return (
-
+
-
{template.name}
+
+
{template.name}
+
+ Template
+
+
{template.description}
diff --git a/apps/studio/components/interfaces/Advisors/Rules/RulesList.tsx b/apps/studio/components/interfaces/Advisors/Rules/RulesList.tsx
index 9c82905b413..507a7d667c4 100644
--- a/apps/studio/components/interfaces/Advisors/Rules/RulesList.tsx
+++ b/apps/studio/components/interfaces/Advisors/Rules/RulesList.tsx
@@ -124,6 +124,8 @@ export function RulesList() {
return (
<>
+
r.name)} />
+
- r.name)} />
-
diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx
index 0a7d50cd8fd..5fb5eaf0697 100644
--- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx
+++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx
@@ -23,7 +23,7 @@ export function AdvisorsSidebarMenu({ page }: AdvisorsSidebarMenuProps) {
return (
diff --git a/apps/studio/data/advisors/agents-query.ts b/apps/studio/data/advisors/agents-query.ts
index a4f41f33cbc..2b61a12ef0a 100644
--- a/apps/studio/data/advisors/agents-query.ts
+++ b/apps/studio/data/advisors/agents-query.ts
@@ -3,7 +3,7 @@ import { executeSql } from 'data/sql/execute-sql-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { advisorKeys } from './keys'
import { S, literal } from './advisors-fetch'
-import type { AdvisorAgent, AdvisorAgentTask } from './types'
+import type { AdvisorAgent, AdvisorAgentTask, AdvisorConversation } from './types'
export function useAdvisorAgentsQuery(
projectRef?: string,
@@ -223,3 +223,59 @@ export function useDeleteAgentTaskMutation(projectRef?: string) {
},
})
}
+
+// Task Conversations (run history)
+
+export function useTaskConversationsQuery(
+ projectRef?: string,
+ taskId?: string,
+ options?: { enabled?: boolean }
+) {
+ const { data: project } = useSelectedProjectQuery()
+ const connectionString = project?.connectionString
+
+ return useQuery({
+ queryKey: advisorKeys.taskConversations(projectRef, taskId),
+ queryFn: async ({ signal }) => {
+ const { result } = await executeSql(
+ {
+ projectRef,
+ connectionString,
+ sql: `
+ SELECT c.*,
+ (SELECT count(*)::int FROM ${S}.conversation_messages WHERE conversation_id = c.id) as message_count
+ FROM ${S}.conversations c
+ WHERE c.task_id = ${literal(taskId)}
+ ORDER BY c.updated_at DESC
+ LIMIT 20
+ `,
+ queryKey: advisorKeys.taskConversations(projectRef, taskId),
+ },
+ signal
+ )
+ return result ?? []
+ },
+ enabled: (options?.enabled ?? true) && !!projectRef && !!connectionString && !!taskId,
+ })
+}
+
+export function useExecuteAgentTaskMutation(projectRef?: string) {
+ const queryClient = useQueryClient()
+ const { data: project } = useSelectedProjectQuery()
+ const connectionString = project?.connectionString
+
+ return useMutation({
+ mutationFn: async (taskId: string) => {
+ await executeSql({
+ projectRef,
+ connectionString,
+ sql: `SELECT ${S}.execute_agent_task(${literal(taskId)}::uuid)`,
+ queryKey: ['advisor-execute-agent-task'],
+ })
+ },
+ onSuccess: (_data, taskId) => {
+ queryClient.invalidateQueries({ queryKey: advisorKeys.taskConversations(projectRef, taskId) })
+ queryClient.invalidateQueries({ queryKey: advisorKeys.agentTasks(projectRef) })
+ },
+ })
+}
diff --git a/apps/studio/data/advisors/keys.ts b/apps/studio/data/advisors/keys.ts
index a28519e13dc..6be468201b3 100644
--- a/apps/studio/data/advisors/keys.ts
+++ b/apps/studio/data/advisors/keys.ts
@@ -17,4 +17,6 @@ export const advisorKeys = {
['projects', projectRef, 'advisor-channels'] as const,
conversations: (projectRef: string | undefined, issueId?: string) =>
['projects', projectRef, 'advisor-conversations', issueId] as const,
+ taskConversations: (projectRef: string | undefined, taskId?: string) =>
+ ['projects', projectRef, 'advisor-task-conversations', taskId] as const,
}
diff --git a/apps/studio/data/advisors/supabase/migrations/20260304120004_enhanced_notifications.sql b/apps/studio/data/advisors/supabase/migrations/20260304120004_enhanced_notifications.sql
new file mode 100644
index 00000000000..f45aaa42804
--- /dev/null
+++ b/apps/studio/data/advisors/supabase/migrations/20260304120004_enhanced_notifications.sql
@@ -0,0 +1,215 @@
+-- =============================================================================
+-- Enhanced Notification Dispatcher
+-- Richer payloads for Slack (action buttons, docs link, context block),
+-- webhooks (actions array), and email (structured metadata).
+-- Replaces the function from 20260304120002.
+-- =============================================================================
+
+create or replace function _supabase_advisors.dispatch_notifications()
+returns trigger
+language plpgsql
+security definer
+as $$
+declare
+ v_channel record;
+ v_payload jsonb;
+ v_has_pg_net boolean;
+ v_rule record;
+ v_docs_url text;
+ v_supabase_url text;
+ v_issue_path text;
+ v_actions jsonb;
+begin
+ if TG_OP = 'INSERT' or (
+ TG_OP = 'UPDATE'
+ and OLD.severity != NEW.severity
+ and NEW.severity = 'critical'
+ ) then
+
+ select exists(select 1 from pg_extension where extname = 'pg_net') into v_has_pg_net;
+
+ -- Look up the originating rule for docs/remediation info
+ select r.* into v_rule
+ from _supabase_advisors.alerts a
+ join _supabase_advisors.rules r on r.id = a.rule_id
+ where a.issue_id = NEW.id
+ order by a.triggered_at desc
+ limit 1;
+
+ v_docs_url := coalesce(v_rule.remediation, null);
+
+ -- Try to get the Supabase URL for building dashboard links
+ begin
+ select decrypted_secret into v_supabase_url
+ from vault.decrypted_secrets where name = 'supabase_url' limit 1;
+ exception when others then
+ v_supabase_url := null;
+ end;
+
+ v_issue_path := '/project/_/advisors/issues/' || NEW.id::text;
+
+ -- Build actions array for structured payloads
+ v_actions := jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'status_update',
+ 'label', 'Acknowledge',
+ 'target_status', 'acknowledged'
+ ),
+ jsonb_build_object(
+ 'type', 'status_update',
+ 'label', 'Resolve',
+ 'target_status', 'resolved'
+ )
+ );
+
+ if v_docs_url is not null then
+ v_actions := v_actions || jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'link',
+ 'label', 'View Documentation',
+ 'url', v_docs_url
+ )
+ );
+ end if;
+
+ for v_channel in
+ select * from _supabase_advisors.channels
+ where is_enabled = true
+ and NEW.severity = any(severity_filter)
+ and (category_filter is null or NEW.category = any(category_filter))
+ loop
+ v_payload := jsonb_build_object(
+ 'issue_id', NEW.id,
+ 'title', NEW.title,
+ 'severity', NEW.severity,
+ 'category', NEW.category,
+ 'status', NEW.status,
+ 'alert_count', NEW.alert_count,
+ 'description', NEW.description,
+ 'suggested_actions', NEW.suggested_actions,
+ 'actions', v_actions,
+ 'docs_url', v_docs_url,
+ 'issue_path', v_issue_path,
+ 'rule_name', coalesce(v_rule.name, null),
+ 'rule_title', coalesce(v_rule.title, null)
+ );
+
+ insert into _supabase_advisors.notifications (
+ issue_id, channel_id, channel_type, recipient, payload, status
+ ) values (
+ NEW.id,
+ v_channel.id,
+ v_channel.type,
+ case v_channel.type
+ when 'email' then coalesce((v_channel.config->>'email')::text, 'unknown')
+ when 'slack' then coalesce((v_channel.config->>'channel')::text, 'unknown')
+ when 'webhook' then coalesce((v_channel.config->>'url')::text, 'unknown')
+ else 'unknown'
+ end,
+ v_payload,
+ case when v_has_pg_net then 'pending' else 'failed' end
+ );
+
+ if v_has_pg_net and v_channel.type in ('webhook', 'slack') then
+ declare
+ v_url text;
+ v_slack_blocks jsonb;
+ v_action_elements jsonb;
+ v_context_parts text;
+ begin
+ if v_channel.type = 'slack' then
+ v_url := (v_channel.config->>'webhook_url')::text;
+ if v_url is not null then
+ -- Build action buttons for Slack
+ v_action_elements := jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'button',
+ 'text', jsonb_build_object('type', 'plain_text', 'text', 'View Issue'),
+ 'url', v_issue_path
+ )
+ );
+
+ if v_docs_url is not null then
+ v_action_elements := v_action_elements || jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'button',
+ 'text', jsonb_build_object('type', 'plain_text', 'text', 'View Docs'),
+ 'url', v_docs_url
+ )
+ );
+ end if;
+
+ v_context_parts := format(
+ 'Actions: Acknowledge · Resolve · Dismiss | %s alert(s) · %s',
+ NEW.alert_count,
+ NEW.category
+ );
+
+ v_slack_blocks := jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'section',
+ 'text', jsonb_build_object(
+ 'type', 'mrkdwn',
+ 'text', format(
+ '*%s* `%s` _%s_\n%s',
+ NEW.title,
+ upper(NEW.severity),
+ NEW.category,
+ coalesce(NEW.description, '')
+ )
+ )
+ ),
+ jsonb_build_object(
+ 'type', 'actions',
+ 'elements', v_action_elements
+ ),
+ jsonb_build_object(
+ 'type', 'context',
+ 'elements', jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'mrkdwn',
+ 'text', v_context_parts
+ )
+ )
+ )
+ );
+
+ perform net.http_post(
+ url := v_url,
+ headers := '{"Content-Type": "application/json"}'::jsonb,
+ body := jsonb_build_object(
+ 'text', format('[%s] %s: %s', upper(NEW.severity), NEW.category, NEW.title),
+ 'blocks', v_slack_blocks
+ )
+ );
+ end if;
+ elsif v_channel.type = 'webhook' then
+ v_url := (v_channel.config->>'url')::text;
+ if v_url is not null then
+ perform net.http_post(
+ url := v_url,
+ headers := '{"Content-Type": "application/json"}'::jsonb,
+ body := v_payload
+ );
+ end if;
+ end if;
+
+ update _supabase_advisors.notifications
+ set status = 'sent', sent_at = now()
+ where issue_id = NEW.id
+ and channel_id = v_channel.id
+ and status = 'pending';
+ exception when others then
+ update _supabase_advisors.notifications
+ set status = 'failed'
+ where issue_id = NEW.id
+ and channel_id = v_channel.id
+ and status = 'pending';
+ end;
+ end if;
+ end loop;
+ end if;
+
+ return NEW;
+end;
+$$;