Skip to content

Commit 8e1fe3b

Browse files
committed
Implement live notifications for all user-facing events
Replace hardcoded/empty notifications with real end-to-end triggers across the full stack. ## Backend (Supabase edge functions) _shared/notify.ts — shared insertNotification() helper used by all edge functions; fire-and-forget, never throws, single INSERT per event. api-notifications — add POST /create endpoint so the frontend can create notifications for client-side events with auth verification. api-billing — notify on: - Payment successful (credits added, new balance) - Payment failed (signature verification failure) - Promo code redeemed (credits added) api-team — notify on: - Team member invited (email + role) - Team member role updated - Team member removed (fetches email before delete) api-keys — notify on: - API key created (name, security reminder) - API key revoked (fetches name before delete) playbooks — notify on: - Playbooks generated (count of templates created) ## Frontend notificationService.ts — thin wrapper around api-notifications/create with module-level token cache; fire-and-forget, never throws. NotificationCenter — replace 30s setInterval polling with Supabase Realtime subscription (user-scoped filter: user_id=eq.{uid}). INSERT events increment badge and prepend notification to open list instantly. DELETE events remove from list. Eliminates 33K req/s at 1M users from polling. DemoCallContext — notify on: - Call completed (caller name, duration, follow-up flag) - Knowledge base saved (after successful Supabase upsert) SettingsView — notify on: - Profile updated - Data export downloaded - Account deletion scheduled https://claude.ai/code/session_01SGdxNUC1TVMDtW73TZbxjW
1 parent 9f06019 commit 8e1fe3b

File tree

10 files changed

+322
-3
lines changed

10 files changed

+322
-3
lines changed

src/components/DashboardViews/NotificationCenter.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,54 @@ export default function NotificationCenter({ isDark, className }: NotificationCe
101101
}
102102
}, [apiCall]);
103103

104-
// Poll unread count
104+
// Subscribe to new notifications via Realtime (replaces 30s polling)
105+
// User-scoped filter ensures each user only receives their own events —
106+
// critical at 1M-user scale to avoid O(N²) broadcast fan-out.
105107
useEffect(() => {
106108
fetchUnreadCount();
107-
const interval = setInterval(fetchUnreadCount, 30000);
108-
return () => clearInterval(interval);
109+
110+
let channel: ReturnType<typeof supabase.channel> | null = null;
111+
112+
supabase.auth.getSession().then(({ data: { session } }) => {
113+
if (!session?.user?.id) return;
114+
115+
channel = supabase
116+
.channel(`notifications_${session.user.id}`)
117+
.on(
118+
'postgres_changes',
119+
{
120+
event: 'INSERT',
121+
schema: 'public',
122+
table: 'notifications',
123+
filter: `user_id=eq.${session.user.id}`,
124+
},
125+
(payload) => {
126+
// Increment badge immediately
127+
setUnreadCount(prev => prev + 1);
128+
// If dropdown is open, prepend the new notification
129+
if (payload.new) {
130+
setNotifications(prev => [payload.new as NotificationEntry, ...prev]);
131+
}
132+
}
133+
)
134+
.on(
135+
'postgres_changes',
136+
{
137+
event: 'DELETE',
138+
schema: 'public',
139+
table: 'notifications',
140+
filter: `user_id=eq.${session.user.id}`,
141+
},
142+
(payload) => {
143+
setNotifications(prev => prev.filter(n => n.id !== (payload.old as { id: string }).id));
144+
}
145+
)
146+
.subscribe();
147+
});
148+
149+
return () => {
150+
if (channel) supabase.removeChannel(channel);
151+
};
109152
}, [fetchUnreadCount]);
110153

111154
// Fetch full list when opened

src/components/DashboardViews/SettingsView.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useEffect, useCallback } from 'react';
2+
import { createNotification } from '../../services/notificationService';
23
import {
34
User, Building2, Phone as PhoneIcon, Globe, Shield, Download, Trash2,
45
Loader2, Save, Check, AlertCircle, Bell, Mail, Smartphone, ChevronRight,
@@ -177,6 +178,13 @@ export default function SettingsView({ isDark }: SettingsViewProps) {
177178
});
178179
setSaved(true);
179180
setTimeout(() => setSaved(false), 2000);
181+
void createNotification({
182+
type: 'success',
183+
title: 'Profile updated',
184+
message: 'Your profile changes have been saved.',
185+
category: 'system',
186+
action_url: '/dashboard?tab=settings',
187+
});
180188
} catch (err) {
181189
console.error('[SettingsView] save error:', err);
182190
} finally {
@@ -204,6 +212,12 @@ export default function SettingsView({ isDark }: SettingsViewProps) {
204212
a.download = `clerktree-export-${new Date().toISOString().slice(0, 10)}.json`;
205213
a.click();
206214
URL.revokeObjectURL(url);
215+
void createNotification({
216+
type: 'success',
217+
title: 'Data export ready',
218+
message: 'Your account data has been downloaded.',
219+
category: 'system',
220+
});
207221
} catch (err) {
208222
console.error('[SettingsView] export error:', err);
209223
} finally {
@@ -215,6 +229,12 @@ export default function SettingsView({ isDark }: SettingsViewProps) {
215229
try {
216230
await apiCall('account', { method: 'DELETE' });
217231
setDeleteConfirm(false);
232+
void createNotification({
233+
type: 'warning',
234+
title: 'Account deletion scheduled',
235+
message: 'Your account will be deleted. Check your email for confirmation.',
236+
category: 'security',
237+
});
218238
alert('Account deletion has been scheduled. You will receive a confirmation email.');
219239
} catch (err) {
220240
console.error('[SettingsView] delete error:', err);

src/contexts/DemoCallContext.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useAuth } from './AuthContext';
55
import { DEFAULT_VOICE_ID, normalizeVoiceId } from '../config/voiceConfig';
66
import type { RescueEngineSettings, RescuePlaybookTemplate } from '../types/rescuePlaybook';
77
import { DEFAULT_RESCUE_PLAYBOOKS, DEFAULT_RESCUE_SETTINGS } from '../types/rescuePlaybook';
8+
import { createNotification } from '../services/notificationService';
89

910
// Types for dynamic context extraction
1011
export interface ExtractedField {
@@ -667,6 +668,13 @@ export function DemoCallProvider({ children, initialAgentId }: { children: React
667668
console.warn('Supabase save failed (localStorage still saved):', error.message);
668669
} else {
669670
console.log('✅ Saved knowledge base to Supabase for user:', userId);
671+
void createNotification({
672+
type: 'success',
673+
title: 'Knowledge base saved',
674+
message: 'Your AI agent configuration has been updated.',
675+
category: 'system',
676+
action_url: '/dashboard?tab=knowledge-base',
677+
});
670678
}
671679

672680
return true;
@@ -1039,6 +1047,15 @@ export function DemoCallProvider({ children, initialAgentId }: { children: React
10391047
));
10401048
console.log('📝 Call history updated with summary:', historyId);
10411049

1050+
// Notify user that the call summary is ready
1051+
void createNotification({
1052+
type: finalSummary.followUpRequired ? 'warning' : 'success',
1053+
title: 'Call completed',
1054+
message: `${callerName !== 'Unknown Caller' ? callerName + ' · ' : ''}${Math.floor(duration / 60)}m ${duration % 60}s${finalSummary.followUpRequired ? ' · Follow-up required' : ''}`,
1055+
category: 'calls',
1056+
action_url: '/dashboard?tab=history',
1057+
});
1058+
10421059
// Save to Supabase
10431060
try {
10441061
// Build base payload
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Frontend notification service.
3+
*
4+
* Calls the api-notifications/create edge function to insert a notification
5+
* for the currently authenticated user. Used for events that originate on
6+
* the client side (call ended, knowledge base saved, settings updated, etc.).
7+
*
8+
* Fire-and-forget: never throws — errors are silently logged so a failed
9+
* notification never disrupts the user action that triggered it.
10+
*/
11+
12+
import { supabase } from '../config/supabase';
13+
14+
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
15+
export type NotificationCategory = 'billing' | 'team' | 'security' | 'calls' | 'system';
16+
17+
export interface CreateNotificationPayload {
18+
type: NotificationType;
19+
title: string;
20+
message?: string;
21+
category: NotificationCategory;
22+
action_url?: string;
23+
}
24+
25+
let _cachedToken: string | null = null;
26+
let _tokenExpiry = 0;
27+
28+
supabase.auth.onAuthStateChange((_event, session) => {
29+
_cachedToken = session?.access_token ?? null;
30+
_tokenExpiry = session ? session.expires_at! * 1000 : 0;
31+
});
32+
33+
async function getToken(): Promise<string | null> {
34+
if (_cachedToken && Date.now() < _tokenExpiry - 60_000) return _cachedToken;
35+
try {
36+
const { data: { session } } = await supabase.auth.getSession();
37+
_cachedToken = session?.access_token ?? null;
38+
_tokenExpiry = session ? session.expires_at! * 1000 : 0;
39+
} catch {
40+
_cachedToken = null;
41+
}
42+
return _cachedToken;
43+
}
44+
45+
/**
46+
* Create a notification for the authenticated user.
47+
* Safe to fire-and-forget: `void createNotification(...)`.
48+
*/
49+
export async function createNotification(payload: CreateNotificationPayload): Promise<void> {
50+
try {
51+
const token = await getToken();
52+
if (!token) return; // Not authenticated — skip silently
53+
54+
const res = await fetch(
55+
`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/api-notifications/create`,
56+
{
57+
method: 'POST',
58+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
59+
body: JSON.stringify(payload),
60+
}
61+
);
62+
63+
if (!res.ok) {
64+
console.warn('[notificationService] create failed:', res.status);
65+
}
66+
} catch (err) {
67+
console.warn('[notificationService] error:', err);
68+
}
69+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Shared notification helper for Supabase edge functions.
3+
*
4+
* Usage (from any edge function):
5+
* import { insertNotification } from '../_shared/notify.ts';
6+
* await insertNotification(adminClient, userId, {
7+
* type: 'success',
8+
* title: 'Payment successful',
9+
* message: '500 credits added to your account.',
10+
* category: 'billing',
11+
* });
12+
*
13+
* Fire-and-forget — never throws, errors are logged only.
14+
* Designed for 1M-user scale: single INSERT per event, user-scoped.
15+
*/
16+
17+
import { SupabaseClient } from "jsr:@supabase/supabase-js@2";
18+
19+
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
20+
export type NotificationCategory = 'billing' | 'team' | 'security' | 'calls' | 'system';
21+
22+
export interface NotificationPayload {
23+
type: NotificationType;
24+
title: string;
25+
message?: string;
26+
category: NotificationCategory;
27+
action_url?: string;
28+
}
29+
30+
/**
31+
* Insert a notification row for a user. Never throws — safe to await anywhere.
32+
*/
33+
export async function insertNotification(
34+
adminClient: SupabaseClient,
35+
userId: string,
36+
payload: NotificationPayload,
37+
): Promise<void> {
38+
try {
39+
const { error } = await adminClient.from('notifications').insert({
40+
user_id: userId,
41+
type: payload.type,
42+
title: payload.title,
43+
message: payload.message ?? '',
44+
category: payload.category,
45+
action_url: payload.action_url ?? null,
46+
is_read: false,
47+
});
48+
if (error) console.error('[notify] insert error:', error.message);
49+
} catch (err) {
50+
console.error('[notify] unexpected error:', err);
51+
}
52+
}

supabase/functions/api-billing/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
22
import { createClient } from "jsr:@supabase/supabase-js@2";
3+
import { insertNotification } from "../_shared/notify.ts";
34

45
const corsBaseHeaders = {
56
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
@@ -251,6 +252,14 @@ Deno.serve(async (req: Request) => {
251252
if (error) throw error;
252253
if (!data.success) throw new Error(data.message);
253254

255+
await insertNotification(adminClient, user.id, {
256+
type: 'success',
257+
title: 'Credits redeemed',
258+
message: `${data.added_amount} credits added to your account via promo code.`,
259+
category: 'billing',
260+
action_url: '/dashboard?tab=billing',
261+
});
262+
254263
return jsonResponse(req, 200, { message: data.message, added_amount: data.added_amount });
255264
}
256265

@@ -315,6 +324,13 @@ Deno.serve(async (req: Request) => {
315324
credits_added: 0,
316325
}).catch((err: unknown) => console.error('[api-billing] failed to record failed payment:', err));
317326

327+
await insertNotification(adminClient, user.id, {
328+
type: 'error',
329+
title: 'Payment failed',
330+
message: 'We could not verify your payment. No credits were added. Please try again.',
331+
category: 'billing',
332+
action_url: '/dashboard?tab=billing',
333+
});
318334
return jsonResponse(req, 400, { error: 'Invalid signature. Payment could not be verified.' });
319335
}
320336

@@ -384,6 +400,14 @@ Deno.serve(async (req: Request) => {
384400
credits_added: creditsToAdd,
385401
}).catch((err: unknown) => console.error('[api-billing] failed to record payment history:', err));
386402

403+
await insertNotification(adminClient, user.id, {
404+
type: 'success',
405+
title: 'Payment successful',
406+
message: `${creditsToAdd} credits added to your account (${derivedPlan} plan).${newBalance !== null ? ` New balance: ${newBalance} credits.` : ''}`,
407+
category: 'billing',
408+
action_url: '/dashboard?tab=billing',
409+
});
410+
387411
return jsonResponse(req, 200, {
388412
success: true,
389413
plan: derivedPlan,

supabase/functions/api-keys/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
22
import { createClient } from "jsr:@supabase/supabase-js@2";
3+
import { insertNotification } from "../_shared/notify.ts";
34

45
const corsBaseHeaders = {
56
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
@@ -163,6 +164,14 @@ Deno.serve(async (req: Request) => {
163164

164165
if (error) throw error;
165166

167+
await insertNotification(adminClient, user.id, {
168+
type: 'success',
169+
title: 'API key created',
170+
message: `New API key "${name}" is now active. Store it securely — it won't be shown again.`,
171+
category: 'security',
172+
action_url: '/dashboard?tab=api-keys',
173+
});
174+
166175
// Return the full token only on creation (never again)
167176
return jsonResponse(req, 201, { key: data });
168177
}
@@ -174,6 +183,14 @@ Deno.serve(async (req: Request) => {
174183
return jsonResponse(req, 400, { error: 'Missing key id.' });
175184
}
176185

186+
// Fetch name before deleting for the notification message
187+
const { data: keyToRevoke } = await adminClient
188+
.from('user_api_keys')
189+
.select('name')
190+
.eq('id', keyId)
191+
.eq('user_id', user.id)
192+
.maybeSingle();
193+
177194
const { error } = await adminClient
178195
.from('user_api_keys')
179196
.delete()
@@ -182,6 +199,14 @@ Deno.serve(async (req: Request) => {
182199

183200
if (error) throw error;
184201

202+
await insertNotification(adminClient, user.id, {
203+
type: 'warning',
204+
title: 'API key revoked',
205+
message: keyToRevoke?.name ? `API key "${keyToRevoke.name}" has been permanently revoked.` : 'An API key has been revoked.',
206+
category: 'security',
207+
action_url: '/dashboard?tab=api-keys',
208+
});
209+
185210
return jsonResponse(req, 200, { success: true });
186211
}
187212

0 commit comments

Comments
 (0)