Skip to content

Commit 0d0a94b

Browse files
committed
fix add notifs
1 parent ef5a44d commit 0d0a94b

File tree

7 files changed

+242
-8
lines changed

7 files changed

+242
-8
lines changed

messages/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,9 @@
893893
"notifications-onboarding": "Onboarding",
894894
"notifications-onboarding-emails": "Onboarding emails",
895895
"notifications-onboarding-emails-desc": "Receive helpful onboarding reminders and tips",
896+
"notifications-realtime": "Realtime Feed",
897+
"notifications-cli-realtime-feed": "CLI activity notifications",
898+
"notifications-cli-realtime-feed-desc": "Show real-time toast notifications when CLI actions happen (bundle uploads, channel changes, etc.)",
896899
"org-notifications": "Organization Notifications",
897900
"org-notifications-title": "Organization Email Notifications",
898901
"org-notifications-description": "Control which email notifications are sent to your organization's management email address. These settings only apply when the management email is different from individual admin email addresses.",

src/auto-imports.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ declare global {
239239
const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
240240
const usePrevious: typeof import('@vueuse/core').usePrevious
241241
const useRafFn: typeof import('@vueuse/core').useRafFn
242+
const useRealtimeCLIFeed: typeof import('./composables/useRealtimeCLIFeed').useRealtimeCLIFeed
242243
const useRefHistory: typeof import('@vueuse/core').useRefHistory
243244
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
244245
const useRoute: typeof import('vue-router').useRoute
@@ -575,6 +576,7 @@ declare module 'vue' {
575576
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
576577
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
577578
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
579+
readonly useRealtimeCLIFeed: UnwrapRef<typeof import('./composables/useRealtimeCLIFeed')['useRealtimeCLIFeed']>
578580
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
579581
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
580582
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { RealtimeChannel } from '@supabase/supabase-js'
2+
import { onUnmounted, ref, watch } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import { useRouter } from 'vue-router'
5+
import { toast } from 'vue-sonner'
6+
import { useSupabase } from '~/services/supabase'
7+
import { useMainStore } from '~/stores/main'
8+
import { useOrganizationStore } from '~/stores/organization'
9+
10+
interface CLIActivityPayload {
11+
event: string
12+
channel: string
13+
description?: string
14+
icon?: string
15+
app_id?: string
16+
org_id: string
17+
channel_name?: string
18+
bundle_name?: string
19+
timestamp: string
20+
}
21+
22+
function getRouteForEvent(payload: CLIActivityPayload): string | null {
23+
const appId = payload.app_id
24+
if (!appId)
25+
return null
26+
27+
const evt = payload.event.toLowerCase()
28+
29+
if (evt.includes('deleted') && (evt.includes('app') && !evt.includes('bundle')))
30+
return '/app'
31+
if (evt.includes('upload') || evt.includes('bundle') || evt.includes('external') || evt.includes('unlink'))
32+
return `/app/${appId}/bundles`
33+
if (evt.includes('channel'))
34+
return `/app/${appId}/channels`
35+
if (evt.includes('app'))
36+
return `/app/${appId}`
37+
38+
return `/app/${appId}`
39+
}
40+
41+
export function useRealtimeCLIFeed() {
42+
const supabase = useSupabase()
43+
const main = useMainStore()
44+
const orgStore = useOrganizationStore()
45+
const router = useRouter()
46+
const { t } = useI18n()
47+
48+
let currentChannel: RealtimeChannel | null = null
49+
const isConnected = ref(false)
50+
51+
function isEnabled(): boolean {
52+
const prefs = (main.user as any)?.email_preferences as Record<string, boolean> | undefined
53+
return prefs?.cli_realtime_feed ?? true
54+
}
55+
56+
function subscribe(orgId: string) {
57+
unsubscribe()
58+
if (!isEnabled())
59+
return
60+
61+
const channelName = `cli-events:org:${orgId}`
62+
currentChannel = supabase.channel(channelName)
63+
64+
currentChannel
65+
.on('broadcast', { event: 'cli-activity' }, (message) => {
66+
const payload = message.payload as CLIActivityPayload
67+
showToast(payload)
68+
})
69+
.subscribe(async (status) => {
70+
if (status === 'SUBSCRIBED') {
71+
isConnected.value = true
72+
await currentChannel?.track({
73+
user_id: main.user?.id,
74+
online_at: new Date().toISOString(),
75+
})
76+
}
77+
else {
78+
isConnected.value = false
79+
}
80+
})
81+
}
82+
83+
function unsubscribe() {
84+
if (currentChannel) {
85+
supabase.removeChannel(currentChannel)
86+
currentChannel = null
87+
isConnected.value = false
88+
}
89+
}
90+
91+
function showToast(payload: CLIActivityPayload) {
92+
const route = getRouteForEvent(payload)
93+
const icon = payload.icon ?? '📡'
94+
const title = `${icon} ${payload.event}`
95+
const description = payload.description
96+
?? (payload.app_id ? `App: ${payload.app_id}` : undefined)
97+
98+
if (route) {
99+
toast(title, {
100+
description,
101+
duration: 5000,
102+
action: {
103+
label: t('view'),
104+
onClick: () => router.push(route),
105+
},
106+
})
107+
}
108+
else {
109+
toast(title, {
110+
description,
111+
duration: 4000,
112+
})
113+
}
114+
}
115+
116+
// Re-subscribe when org changes
117+
watch(
118+
() => orgStore.currentOrganization?.gid,
119+
(orgId) => {
120+
if (orgId)
121+
subscribe(orgId)
122+
else
123+
unsubscribe()
124+
},
125+
{ immediate: true },
126+
)
127+
128+
// React to user toggling the setting
129+
watch(
130+
() => (main.user as any)?.email_preferences?.cli_realtime_feed,
131+
(enabled) => {
132+
const orgId = orgStore.currentOrganization?.gid
133+
if (enabled === false) {
134+
unsubscribe()
135+
}
136+
else if (orgId && !currentChannel) {
137+
subscribe(orgId)
138+
}
139+
},
140+
)
141+
142+
onUnmounted(() => unsubscribe())
143+
144+
return { isConnected }
145+
}

src/layouts/default.vue

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
<script setup lang="ts">
22
import { ref } from 'vue'
3+
import { useRealtimeCLIFeed } from '~/composables/useRealtimeCLIFeed'
34
import Navbar from '../components/Navbar.vue'
45
import Sidebar from '../components/Sidebar.vue'
56
67
const sidebarOpen = ref(false)
8+
9+
// Initialize realtime CLI activity feed (toasts for CLI actions)
10+
useRealtimeCLIFeed()
711
</script>
812

913
<template>
10-
<div class="flex overflow-hidden h-full bg-slate-800 pt-safe safe-areas">
14+
<div class="flex h-full overflow-hidden bg-slate-800 pt-safe safe-areas">
1115
<!-- Sidebar -->
1216
<Sidebar :sidebar-open="sidebarOpen" @close-sidebar="sidebarOpen = false" />
1317
<!-- Content area -->
14-
<div class="flex overflow-hidden flex-col flex-1 h-full lg:p-3">
15-
<div class="flex overflow-hidden flex-col h-full border border-gray-200 lg:rounded-xl lg:shadow-sm dark:border-gray-700 bg-slate-100 dark:bg-slate-900">
18+
<div class="flex flex-col flex-1 h-full overflow-hidden lg:p-3">
19+
<div class="flex flex-col h-full overflow-hidden border border-gray-200 lg:rounded-xl lg:shadow-sm dark:border-gray-700 bg-slate-100 dark:bg-slate-900">
1620
<!-- Site header -->
1721
<Navbar :sidebar-open="sidebarOpen" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
18-
<main class="overflow-hidden w-full h-full">
19-
<RouterView class="overflow-y-auto h-full grow" />
22+
<main class="w-full h-full overflow-hidden">
23+
<RouterView class="h-full overflow-y-auto grow" />
2024
</main>
2125
</div>
2226
</div>

src/pages/settings/account/Notifications.vue

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface EmailPreferences {
2828
bundle_deployed?: boolean
2929
device_error?: boolean
3030
channel_self_rejected?: boolean
31+
cli_realtime_feed?: boolean
3132
}
3233
3334
type EmailPreferenceKey = keyof EmailPreferences
@@ -222,6 +223,19 @@ async function toggleEmailPref(key: EmailPreferenceKey) {
222223
</InfoRow>
223224
</dl>
224225

226+
<!-- Realtime CLI Feed Section -->
227+
<h3 class="text-lg font-semibold mb-4 dark:text-white text-slate-700">
228+
{{ t('notifications-realtime') }}
229+
</h3>
230+
<dl class="divide-y divide-slate-200 dark:divide-slate-500 mb-8">
231+
<InfoRow :label="t('notifications-cli-realtime-feed')" :editable="false" :value="t('notifications-cli-realtime-feed-desc')">
232+
<Toggle
233+
:value="getEmailPref('cli_realtime_feed')"
234+
@change="toggleEmailPref('cli_realtime_feed')"
235+
/>
236+
</InfoRow>
237+
</dl>
238+
225239
<!-- Onboarding Section -->
226240
<h3 class="text-lg font-semibold mb-4 dark:text-white text-slate-700">
227241
{{ t('notifications-onboarding') }}

supabase/functions/_backend/private/events.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { trackBentoEvent } from '../utils/bento.ts'
77
import { BRES, parseBody, simpleError, useCors } from '../utils/hono.ts'
88
import { middlewareV2 } from '../utils/hono_middleware.ts'
99
import { logsnag } from '../utils/logsnag.ts'
10+
import { broadcastCLIEvent } from '../utils/realtime_broadcast.ts'
1011
import { supabaseWithAuth } from '../utils/supabase.ts'
1112
import { backgroundTask } from '../utils/utils.ts'
1213

@@ -15,7 +16,28 @@ export const app = new Hono<MiddlewareKeyVariables>()
1516
app.use('/', useCors)
1617

1718
app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => {
18-
const body = await parseBody<TrackOptions>(c)
19+
const body = await parseBody<TrackOptions & { notifyConsole?: boolean }>(c)
20+
21+
const orgId = body.user_id ?? c.get('auth')?.userId ?? ''
22+
23+
// notifyConsole: broadcast to Supabase Realtime only, skip all tracking
24+
if (body.notifyConsole) {
25+
if (orgId) {
26+
await backgroundTask(c, broadcastCLIEvent(c, {
27+
event: body.event,
28+
channel: body.channel,
29+
description: body.description,
30+
icon: body.icon,
31+
app_id: typeof body.tags?.['app-id'] === 'string' ? body.tags['app-id'] : undefined,
32+
org_id: orgId,
33+
channel_name: typeof body.tags?.channel === 'string' ? body.tags.channel : undefined,
34+
bundle_name: typeof body.tags?.bundle === 'string' ? body.tags.bundle : undefined,
35+
timestamp: new Date().toISOString(),
36+
}))
37+
}
38+
return c.json(BRES)
39+
}
40+
1941
const supabase = supabaseWithAuth(c, c.get('auth')!)
2042
const ip = c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
2143

@@ -39,13 +61,12 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => {
3961
await backgroundTask(c, logsnag(c).track(body))
4062
await backgroundTask(c, trackActivationpalEvent(c, activationPayload))
4163
if (body.user_id && body.tags && typeof body.tags['app-id'] === 'string' && body.event === 'onboarding-step-done') {
42-
const orgId = body.user_id
4364
const appId = body.tags['app-id']
4465
await backgroundTask(c, Promise.all([
4566
supabase
4667
.from('orgs')
4768
.select('*')
48-
.eq('id', orgId)
69+
.eq('id', body.user_id)
4970
.single(),
5071
supabase
5172
.from('apps')
@@ -64,5 +85,6 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => {
6485
}, 'app:updated') as any
6586
}))
6687
}
88+
6789
return c.json(BRES)
6890
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Context } from 'hono'
2+
import { getEnv } from './utils.ts'
3+
4+
export interface CLIActivityPayload {
5+
event: string
6+
channel: string
7+
description?: string
8+
icon?: string
9+
app_id?: string
10+
org_id: string
11+
channel_name?: string
12+
bundle_name?: string
13+
timestamp: string
14+
}
15+
16+
export async function broadcastCLIEvent(
17+
c: Context,
18+
payload: CLIActivityPayload,
19+
): Promise<void> {
20+
const supabaseUrl = getEnv(c, 'SUPABASE_URL')
21+
const serviceRoleKey = getEnv(c, 'SUPABASE_SERVICE_ROLE_KEY')
22+
if (!supabaseUrl || !serviceRoleKey)
23+
return
24+
25+
const channelName = `cli-events:org:${payload.org_id}`
26+
27+
await fetch(`${supabaseUrl}/realtime/v1/api/broadcast`, {
28+
method: 'POST',
29+
headers: {
30+
'Content-Type': 'application/json',
31+
'apikey': serviceRoleKey,
32+
'Authorization': `Bearer ${serviceRoleKey}`,
33+
},
34+
body: JSON.stringify({
35+
messages: [{
36+
topic: channelName,
37+
event: 'cli-activity',
38+
payload,
39+
}],
40+
}),
41+
}).catch(() => {
42+
// Silently ignore broadcast failures - this is non-critical
43+
})
44+
}

0 commit comments

Comments
 (0)