From e2f0df22cc2a9ccc4b5b256a532381567061189c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:26:12 +0000 Subject: [PATCH] feat: Implement Novu notification system --- .env | 5 + .env.example | 11 +- .../notifications/NotificationCenter.tsx | 94 +++ src/components/settings/NotificationsTab.tsx | 573 ++++++++++-------- src/hooks/useNovuNotifications.ts | 55 ++ src/integrations/supabase/types.ts | 188 ++++++ src/lib/notificationService.ts | 258 ++++++++ supabase/config.toml | 12 + .../functions/create-novu-subscriber/index.ts | 64 ++ supabase/functions/novu-webhook/index.ts | 106 ++++ .../functions/trigger-notification/index.ts | 63 ++ .../update-novu-preferences/index.ts | 88 +++ ...3_07e1fef5-b13a-471a-a684-86f69b14434f.sql | 143 +++++ 13 files changed, 1407 insertions(+), 253 deletions(-) create mode 100644 src/components/notifications/NotificationCenter.tsx create mode 100644 src/hooks/useNovuNotifications.ts create mode 100644 src/lib/notificationService.ts create mode 100644 supabase/functions/create-novu-subscriber/index.ts create mode 100644 supabase/functions/novu-webhook/index.ts create mode 100644 supabase/functions/trigger-notification/index.ts create mode 100644 supabase/functions/update-novu-preferences/index.ts create mode 100644 supabase/migrations/20251001122233_07e1fef5-b13a-471a-a684-86f69b14434f.sql diff --git a/.env b/.env index 0994d0f8..38b478b8 100644 --- a/.env +++ b/.env @@ -8,3 +8,8 @@ VITE_SUPABASE_URL="https://ydvtmnrszybqnbcqbdcy.supabase.co" # - Always passes: 1x00000000000000000000AA # - Always fails: 3x00000000000000000000FF VITE_TURNSTILE_SITE_KEY=0x4AAAAAAAyqVp3RjccrC9Kz + +# Novu Configuration +VITE_NOVU_APPLICATION_IDENTIFIER="" +VITE_NOVU_SOCKET_URL="wss://ws.novu.co" +VITE_NOVU_API_URL="https://api.novu.co" diff --git a/.env.example b/.env.example index cf3f8f6e..657cd9f0 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,13 @@ VITE_SUPABASE_URL=https://your-project-id.supabase.co # - Visible test key (always passes): 1x00000000000000000000AA # - Invisible test key (always passes): 2x00000000000000000000AB # - Visible test key (always fails): 3x00000000000000000000FF -VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key \ No newline at end of file +VITE_TURNSTILE_SITE_KEY=your-turnstile-site-key + +# Novu Configuration +# For Novu Cloud, use these defaults: +# - Socket URL: wss://ws.novu.co +# - API URL: https://api.novu.co +# For self-hosted Novu, replace with your instance URLs +VITE_NOVU_APPLICATION_IDENTIFIER=your-novu-app-identifier +VITE_NOVU_SOCKET_URL=wss://ws.novu.co +VITE_NOVU_API_URL=https://api.novu.co \ No newline at end of file diff --git a/src/components/notifications/NotificationCenter.tsx b/src/components/notifications/NotificationCenter.tsx new file mode 100644 index 00000000..923fb2e7 --- /dev/null +++ b/src/components/notifications/NotificationCenter.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import { Bell } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useNovuNotifications } from '@/hooks/useNovuNotifications'; +import { Separator } from '@/components/ui/separator'; + +export function NotificationCenter() { + const { notifications, unreadCount, markAsRead, markAllAsRead, isEnabled } = useNovuNotifications(); + const [open, setOpen] = useState(false); + + if (!isEnabled) { + return null; + } + + return ( + + + + + +
+

Notifications

+ {unreadCount > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +
+ +

No notifications yet

+
+ ) : ( +
+ {notifications.map((notification) => ( +
{ + if (!notification.read) { + markAsRead(notification.id); + } + // Handle CTA action if exists + if (notification.cta) { + // Navigate or perform action based on notification.cta + } + }} + > +
+ {!notification.read && ( +
+ )} +
+

{notification.content}

+

+ {new Date(notification.createdAt).toLocaleString()} +

+
+
+
+ ))} +
+ )} + + + + ); +} diff --git a/src/components/settings/NotificationsTab.tsx b/src/components/settings/NotificationsTab.tsx index c95bfe18..266db976 100644 --- a/src/components/settings/NotificationsTab.tsx +++ b/src/components/settings/NotificationsTab.tsx @@ -1,289 +1,358 @@ -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Separator } from '@/components/ui/separator'; -import { useToast } from '@/hooks/use-toast'; -import { useAuth } from '@/hooks/useAuth'; -import { supabase } from '@/integrations/supabase/client'; -import { Bell, Mail, Smartphone, Volume2 } from 'lucide-react'; -interface EmailNotifications { - review_replies: boolean; - new_followers: boolean; - system_announcements: boolean; - weekly_digest: boolean; - monthly_digest: boolean; +import { useEffect, useState } from "react"; +import { useAuth } from "@/hooks/useAuth"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { notificationService } from "@/lib/notificationService"; + +interface ChannelPreferences { + in_app: boolean; + email: boolean; + push: boolean; + sms: boolean; } -interface PushNotifications { - browser_enabled: boolean; - new_content: boolean; - social_updates: boolean; + +interface WorkflowPreferences { + [workflowId: string]: boolean; } + +interface FrequencySettings { + digest: 'realtime' | 'hourly' | 'daily' | 'weekly'; + max_per_hour: number; +} + +interface NotificationTemplate { + id: string; + workflow_id: string; + name: string; + description: string; + category: string; + is_active: boolean; +} + export function NotificationsTab() { - const { - user - } = useAuth(); - const { - toast - } = useToast(); - const [loading, setLoading] = useState(false); - const [emailNotifications, setEmailNotifications] = useState({ - review_replies: true, - new_followers: true, - system_announcements: true, - weekly_digest: false, - monthly_digest: true + const { user } = useAuth(); + const [loading, setLoading] = useState(true); + const [templates, setTemplates] = useState([]); + const [channelPreferences, setChannelPreferences] = useState({ + in_app: true, + email: true, + push: false, + sms: false, }); - const [pushNotifications, setPushNotifications] = useState({ - browser_enabled: false, - new_content: true, - social_updates: true + const [workflowPreferences, setWorkflowPreferences] = useState({}); + const [frequencySettings, setFrequencySettings] = useState({ + digest: 'daily', + max_per_hour: 10, }); + const isNovuEnabled = notificationService.isEnabled(); + useEffect(() => { - fetchNotificationPreferences(); + if (user) { + loadPreferences(); + loadTemplates(); + } }, [user]); - const fetchNotificationPreferences = async () => { + + const loadPreferences = async () => { if (!user) return; + try { - const { - data, - error - } = await supabase.from('user_preferences').select('email_notifications, push_notifications').eq('user_id', user.id).maybeSingle(); - if (error && error.code !== 'PGRST116') { - console.error('Error fetching notification preferences:', error); - return; - } - if (data) { - if (data.email_notifications) { - setEmailNotifications(data.email_notifications as unknown as EmailNotifications); - } - if (data.push_notifications) { - setPushNotifications(data.push_notifications as unknown as PushNotifications); - } - } else { - // Initialize preferences if they don't exist - await initializePreferences(); + const preferences = await notificationService.getPreferences(user.id); + + if (preferences) { + setChannelPreferences(preferences.channelPreferences); + setWorkflowPreferences(preferences.workflowPreferences); + setFrequencySettings(preferences.frequencySettings); } } catch (error) { - console.error('Error fetching notification preferences:', error); - } - }; - const initializePreferences = async () => { - if (!user) return; - try { - const { - error - } = await supabase.from('user_preferences').insert([{ - user_id: user.id, - email_notifications: emailNotifications as any, - push_notifications: pushNotifications as any - }]); - if (error) throw error; - } catch (error) { - console.error('Error initializing preferences:', error); - } - }; - const updateEmailNotification = (key: keyof EmailNotifications, value: boolean) => { - setEmailNotifications(prev => ({ - ...prev, - [key]: value - })); - }; - const updatePushNotification = (key: keyof PushNotifications, value: boolean) => { - setPushNotifications(prev => ({ - ...prev, - [key]: value - })); - }; - const saveNotificationPreferences = async () => { - if (!user) return; - setLoading(true); - try { - const { - error - } = await supabase.from('user_preferences').upsert([{ - user_id: user.id, - email_notifications: emailNotifications as any, - push_notifications: pushNotifications as any, - updated_at: new Date().toISOString() - }]); - if (error) throw error; - toast({ - title: 'Preferences saved', - description: 'Your notification preferences have been updated.' - }); - } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Failed to save notification preferences', - variant: 'destructive' - }); + console.error('Error loading preferences:', error); + toast.error("Failed to load notification preferences"); } finally { setLoading(false); } }; - const requestPushPermission = async () => { - if ('Notification' in window) { - const permission = await Notification.requestPermission(); - if (permission === 'granted') { - updatePushNotification('browser_enabled', true); - toast({ - title: 'Push notifications enabled', - description: 'You will now receive browser push notifications.' - }); - } else { - toast({ - title: 'Permission denied', - description: 'Push notifications require permission to work.', - variant: 'destructive' - }); + + const loadTemplates = async () => { + try { + const templateData = await notificationService.getTemplates(); + setTemplates(templateData); + + // Initialize workflow preferences if not set + const initialPrefs: WorkflowPreferences = {}; + templateData.forEach((template) => { + if (!(template.workflow_id in workflowPreferences)) { + initialPrefs[template.workflow_id] = true; + } + }); + if (Object.keys(initialPrefs).length > 0) { + setWorkflowPreferences((prev) => ({ ...prev, ...initialPrefs })); } + } catch (error) { + console.error('Error loading templates:', error); } }; - return
- {/* Email Notifications */} -
-
- -

Email Notifications

-
- - + + const savePreferences = async () => { + if (!user) return; + + setLoading(true); + try { + const result = await notificationService.updatePreferences(user.id, { + channelPreferences, + workflowPreferences, + frequencySettings, + }); + + if (result.success) { + toast.success("Notification preferences saved successfully"); + } else { + throw new Error(result.error || 'Failed to save preferences'); + } + } catch (error: any) { + console.error('Error saving preferences:', error); + toast.error(error.message || "Failed to save notification preferences"); + } finally { + setLoading(false); + } + }; + + const updateChannelPreference = (channel: keyof ChannelPreferences, value: boolean) => { + setChannelPreferences((prev) => ({ ...prev, [channel]: value })); + }; + + const updateWorkflowPreference = (workflowId: string, value: boolean) => { + setWorkflowPreferences((prev) => ({ ...prev, [workflowId]: value })); + }; + + const requestPushPermission = async () => { + if (!('Notification' in window)) { + toast.error("This browser doesn't support push notifications"); + return; + } + + try { + const permission = await Notification.requestPermission(); + if (permission === 'granted') { + updateChannelPreference('push', true); + toast.success("Push notifications enabled"); + } else { + toast.error("Push notification permission denied"); + } + } catch (error) { + console.error('Error requesting push permission:', error); + toast.error("Failed to enable push notifications"); + } + }; + + const groupedTemplates = templates.reduce((acc, template) => { + if (!acc[template.category]) { + acc[template.category] = []; + } + acc[template.category].push(template); + return acc; + }, {} as Record); + + return ( +
+ {!isNovuEnabled && ( + + Novu Not Configured - Choose which email notifications you'd like to receive. + Novu notifications are not configured. To enable advanced notifications, add your Novu Application Identifier to the environment variables. - -
-
- -

- Get notified when someone replies to your reviews -

-
- updateEmailNotification('review_replies', checked)} /> -
- -
-
- -

- Get notified when someone follows you -

-
- updateEmailNotification('new_followers', checked)} /> -
- -
-
- -

- Important updates and announcements from ThrillWiki -

-
- updateEmailNotification('system_announcements', checked)} /> -
- - - -
-
- -

- Weekly summary of new parks, rides, and community activity -

-
- updateEmailNotification('weekly_digest', checked)} /> -
- -
-
- -

- Monthly roundup of popular content and your activity stats -

-
- updateEmailNotification('monthly_digest', checked)} /> -
-
-
+ )} - + + + Notification Channels + + Choose which channels you'd like to receive notifications through + + + +
+
+ +

+ Receive notifications within the application +

+
+ updateChannelPreference('in_app', checked)} + /> +
- {/* Push Notifications */} -
-
- -

Push Notifications

-
- - - - - Receive instant notifications in your browser when important events happen. - - - -
-
- -

- Enable push notifications in your browser -

-
-
- {!pushNotifications.browser_enabled && } - { - if (!checked) { - updatePushNotification('browser_enabled', false); - } else { + + +
+
+ +

+ Receive notifications via email +

+
+ updateChannelPreference('email', checked)} + /> +
+ + + +
+
+ +

+ Browser push notifications +

+
+ { + if (checked) { requestPushPermission(); + } else { + updateChannelPreference('push', false); } - }} /> + }} + /> +
+ + {isNovuEnabled && ( + <> + +
+
+ +

+ Receive notifications via text message +

+
+ updateChannelPreference('sms', checked)} + disabled + />
-
+ + )} + + - {pushNotifications.browser_enabled && <> + + + Notification Frequency + + Control how often you receive notifications + + + +
+ + +

+ Group notifications and send them in batches +

+
+ + + +
+ + +

+ Limit the number of notifications you receive per hour +

+
+
+
+ + {Object.keys(groupedTemplates).map((category) => ( + + + {category} Notifications + + Manage your {category} notification preferences + + + + {groupedTemplates[category].map((template, index) => ( +
+ {index > 0 && }
-
- +
+

- Notifications about new parks, rides, and reviews + {template.description}

- updatePushNotification('new_content', checked)} /> + + updateWorkflowPreference(template.workflow_id, checked) + } + />
- -
-
- -

- Notifications about followers, replies, and mentions -

-
- updatePushNotification('social_updates', checked)} /> -
- } +
+ ))} -
+ ))} - - - {/* Sound Settings */} - - - {/* Save Button */} -
- -
-
; -} \ No newline at end of file + +
+ ); +} diff --git a/src/hooks/useNovuNotifications.ts b/src/hooks/useNovuNotifications.ts new file mode 100644 index 00000000..6abe5d71 --- /dev/null +++ b/src/hooks/useNovuNotifications.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { useAuth } from '@/hooks/useAuth'; + +export interface NotificationItem { + id: string; + content: string; + read: boolean; + createdAt: string; + cta?: { + type: string; + data: any; + }; +} + +export function useNovuNotifications() { + const { user } = useAuth(); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + const applicationIdentifier = import.meta.env.VITE_NOVU_APPLICATION_IDENTIFIER; + const isEnabled = !!applicationIdentifier && !!user; + + useEffect(() => { + if (!isEnabled) { + setIsLoading(false); + return; + } + + // TODO: Initialize Novu Headless SDK when configuration is complete + // This will require the @novu/headless package to be properly configured + setIsLoading(false); + }, [isEnabled, user]); + + const markAsRead = async (notificationId: string) => { + setNotifications((prev) => + prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n)) + ); + setUnreadCount((prev) => Math.max(0, prev - 1)); + }; + + const markAllAsRead = async () => { + setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + setUnreadCount(0); + }; + + return { + notifications, + unreadCount, + isLoading, + markAsRead, + markAllAsRead, + isEnabled, + }; +} diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 778bff31..6c7d1730 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -130,6 +130,8 @@ export type Database = { approval_mode: string | null content: Json created_at: string + escalated: boolean | null + escalated_at: string | null escalated_by: string | null escalation_reason: string | null id: string @@ -146,6 +148,8 @@ export type Database = { approval_mode?: string | null content: Json created_at?: string + escalated?: boolean | null + escalated_at?: string | null escalated_by?: string | null escalation_reason?: string | null id?: string @@ -162,6 +166,8 @@ export type Database = { approval_mode?: string | null content?: Json created_at?: string + escalated?: boolean | null + escalated_at?: string | null escalated_by?: string | null escalation_reason?: string | null id?: string @@ -184,6 +190,36 @@ export type Database = { }, ] } + email_aliases: { + Row: { + created_at: string + description: string | null + email: string + id: string + key: string + owner_id: string | null + updated_at: string + } + Insert: { + created_at?: string + description?: string | null + email: string + id?: string + key: string + owner_id?: string | null + updated_at?: string + } + Update: { + created_at?: string + description?: string | null + email?: string + id?: string + key?: string + owner_id?: string | null + updated_at?: string + } + Relationships: [] + } locations: { Row: { city: string | null @@ -223,6 +259,125 @@ export type Database = { } Relationships: [] } + notification_channels: { + Row: { + channel_type: string + configuration: Json | null + created_at: string + description: string | null + id: string + is_enabled: boolean | null + name: string + updated_at: string + } + Insert: { + channel_type: string + configuration?: Json | null + created_at?: string + description?: string | null + id?: string + is_enabled?: boolean | null + name: string + updated_at?: string + } + Update: { + channel_type?: string + configuration?: Json | null + created_at?: string + description?: string | null + id?: string + is_enabled?: boolean | null + name?: string + updated_at?: string + } + Relationships: [] + } + notification_logs: { + Row: { + channel: string + created_at: string + delivered_at: string | null + error_message: string | null + id: string + novu_transaction_id: string | null + payload: Json | null + read_at: string | null + status: string + template_id: string | null + user_id: string + } + Insert: { + channel: string + created_at?: string + delivered_at?: string | null + error_message?: string | null + id?: string + novu_transaction_id?: string | null + payload?: Json | null + read_at?: string | null + status?: string + template_id?: string | null + user_id: string + } + Update: { + channel?: string + created_at?: string + delivered_at?: string | null + error_message?: string | null + id?: string + novu_transaction_id?: string | null + payload?: Json | null + read_at?: string | null + status?: string + template_id?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "notification_logs_template_id_fkey" + columns: ["template_id"] + isOneToOne: false + referencedRelation: "notification_templates" + referencedColumns: ["id"] + }, + ] + } + notification_templates: { + Row: { + category: string + created_at: string + description: string | null + id: string + is_active: boolean | null + name: string + novu_workflow_id: string | null + updated_at: string + workflow_id: string + } + Insert: { + category: string + created_at?: string + description?: string | null + id?: string + is_active?: boolean | null + name: string + novu_workflow_id?: string | null + updated_at?: string + workflow_id: string + } + Update: { + category?: string + created_at?: string + description?: string | null + id?: string + is_active?: boolean | null + name?: string + novu_workflow_id?: string | null + updated_at?: string + workflow_id?: string + } + Relationships: [] + } park_operating_hours: { Row: { closing_time: string | null @@ -932,6 +1087,39 @@ export type Database = { } Relationships: [] } + user_notification_preferences: { + Row: { + channel_preferences: Json + created_at: string + frequency_settings: Json + id: string + novu_subscriber_id: string | null + updated_at: string + user_id: string + workflow_preferences: Json + } + Insert: { + channel_preferences?: Json + created_at?: string + frequency_settings?: Json + id?: string + novu_subscriber_id?: string | null + updated_at?: string + user_id: string + workflow_preferences?: Json + } + Update: { + channel_preferences?: Json + created_at?: string + frequency_settings?: Json + id?: string + novu_subscriber_id?: string | null + updated_at?: string + user_id?: string + workflow_preferences?: Json + } + Relationships: [] + } user_preferences: { Row: { accessibility_options: Json diff --git a/src/lib/notificationService.ts b/src/lib/notificationService.ts new file mode 100644 index 00000000..e34a1e71 --- /dev/null +++ b/src/lib/notificationService.ts @@ -0,0 +1,258 @@ +import { supabase } from "@/integrations/supabase/client"; + +export interface NotificationPayload { + workflowId: string; + subscriberId: string; + payload: Record; + overrides?: Record; +} + +export interface SubscriberData { + subscriberId: string; + email?: string; + firstName?: string; + lastName?: string; + phone?: string; + avatar?: string; + data?: Record; +} + +export interface NotificationPreferences { + channelPreferences: { + in_app: boolean; + email: boolean; + push: boolean; + sms: boolean; + }; + workflowPreferences: Record; + frequencySettings: { + digest: 'realtime' | 'hourly' | 'daily' | 'weekly'; + max_per_hour: number; + }; +} + +class NotificationService { + private readonly isNovuEnabled: boolean; + + constructor() { + this.isNovuEnabled = !!import.meta.env.VITE_NOVU_APPLICATION_IDENTIFIER; + } + + /** + * Create or update a Novu subscriber + */ + async createSubscriber(subscriberData: SubscriberData): Promise<{ success: boolean; error?: string }> { + if (!this.isNovuEnabled) { + console.warn('Novu is not configured. Skipping subscriber creation.'); + return { success: false, error: 'Novu not configured' }; + } + + try { + const { data, error } = await supabase.functions.invoke('create-novu-subscriber', { + body: subscriberData, + }); + + if (error) throw error; + + // Update local database with Novu subscriber ID + const { error: dbError } = await supabase + .from('user_notification_preferences') + .upsert({ + user_id: subscriberData.subscriberId, + novu_subscriber_id: data.subscriberId, + }); + + if (dbError) throw dbError; + + return { success: true }; + } catch (error: any) { + console.error('Error creating Novu subscriber:', error); + return { success: false, error: error.message }; + } + } + + /** + * Update subscriber preferences in Novu + */ + async updatePreferences( + userId: string, + preferences: NotificationPreferences + ): Promise<{ success: boolean; error?: string }> { + if (!this.isNovuEnabled) { + // Save to local database only + try { + const { error } = await supabase + .from('user_notification_preferences') + .upsert({ + user_id: userId, + channel_preferences: preferences.channelPreferences, + workflow_preferences: preferences.workflowPreferences, + frequency_settings: preferences.frequencySettings, + }); + + if (error) throw error; + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + + try { + const { error } = await supabase.functions.invoke('update-novu-preferences', { + body: { + userId, + preferences, + }, + }); + + if (error) throw error; + + // Also update local database + const { error: dbError } = await supabase + .from('user_notification_preferences') + .upsert({ + user_id: userId, + channel_preferences: preferences.channelPreferences, + workflow_preferences: preferences.workflowPreferences, + frequency_settings: preferences.frequencySettings, + }); + + if (dbError) throw dbError; + + return { success: true }; + } catch (error: any) { + console.error('Error updating preferences:', error); + return { success: false, error: error.message }; + } + } + + /** + * Trigger a notification workflow + */ + async trigger(payload: NotificationPayload): Promise<{ success: boolean; transactionId?: string; error?: string }> { + if (!this.isNovuEnabled) { + console.warn('Novu is not configured. Notification not sent.'); + return { success: false, error: 'Novu not configured' }; + } + + try { + const { data, error } = await supabase.functions.invoke('trigger-notification', { + body: payload, + }); + + if (error) throw error; + + // Log notification in local database + await this.logNotification({ + userId: payload.subscriberId, + workflowId: payload.workflowId, + transactionId: data.transactionId, + payload: payload.payload, + }); + + return { success: true, transactionId: data.transactionId }; + } catch (error: any) { + console.error('Error triggering notification:', error); + return { success: false, error: error.message }; + } + } + + /** + * Get user's notification preferences + */ + async getPreferences(userId: string): Promise { + try { + const { data, error } = await supabase + .from('user_notification_preferences') + .select('channel_preferences, workflow_preferences, frequency_settings') + .eq('user_id', userId) + .single(); + + if (error && error.code !== 'PGRST116') throw error; + + if (!data) { + // Return default preferences + return { + channelPreferences: { + in_app: true, + email: true, + push: false, + sms: false, + }, + workflowPreferences: {}, + frequencySettings: { + digest: 'daily', + max_per_hour: 10, + }, + }; + } + + return { + channelPreferences: data.channel_preferences as any, + workflowPreferences: data.workflow_preferences as any, + frequencySettings: data.frequency_settings as any, + }; + } catch (error: any) { + console.error('Error fetching preferences:', error); + return null; + } + } + + /** + * Get notification templates + */ + async getTemplates(): Promise { + try { + const { data, error } = await supabase + .from('notification_templates') + .select('*') + .eq('is_active', true) + .order('category', { ascending: true }); + + if (error) throw error; + return data || []; + } catch (error: any) { + console.error('Error fetching templates:', error); + return []; + } + } + + /** + * Log notification in database + */ + private async logNotification(log: { + userId: string; + workflowId: string; + transactionId: string; + payload: Record; + }): Promise { + try { + // Get template ID from workflow ID + const { data: template } = await supabase + .from('notification_templates') + .select('id') + .eq('workflow_id', log.workflowId) + .single(); + + await supabase.from('notification_logs').insert({ + user_id: log.userId, + template_id: template?.id, + novu_transaction_id: log.transactionId, + channel: 'multi', + status: 'sent', + payload: log.payload, + }); + } catch (error) { + console.error('Error logging notification:', error); + } + } + + /** + * Check if Novu is enabled + */ + isEnabled(): boolean { + return this.isNovuEnabled; + } +} + +export const notificationService = new NotificationService(); diff --git a/supabase/config.toml b/supabase/config.toml index ed0edb88..69b49fbe 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,5 +1,17 @@ project_id = "ydvtmnrszybqnbcqbdcy" +[functions.create-novu-subscriber] +verify_jwt = true + +[functions.update-novu-preferences] +verify_jwt = true + +[functions.trigger-notification] +verify_jwt = true + +[functions.novu-webhook] +verify_jwt = false + [functions.detect-location] verify_jwt = false diff --git a/supabase/functions/create-novu-subscriber/index.ts b/supabase/functions/create-novu-subscriber/index.ts new file mode 100644 index 00000000..20e08504 --- /dev/null +++ b/supabase/functions/create-novu-subscriber/index.ts @@ -0,0 +1,64 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { Novu } from "npm:@novu/node@2.0.2"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const novuApiKey = Deno.env.get('NOVU_API_KEY'); + + if (!novuApiKey) { + throw new Error('NOVU_API_KEY is not configured'); + } + + const novu = new Novu(novuApiKey, { + backendUrl: Deno.env.get('VITE_NOVU_API_URL') || 'https://api.novu.co', + }); + + const { subscriberId, email, firstName, lastName, phone, avatar, data } = await req.json(); + + console.log('Creating Novu subscriber:', { subscriberId, email }); + + const subscriber = await novu.subscribers.identify(subscriberId, { + email, + firstName, + lastName, + phone, + avatar, + data, + }); + + console.log('Subscriber created successfully:', subscriber.data); + + return new Response( + JSON.stringify({ + success: true, + subscriberId: subscriber.data._id, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error: any) { + console.error('Error creating Novu subscriber:', error); + + return new Response( + JSON.stringify({ + success: false, + error: error.message, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } + ); + } +}); diff --git a/supabase/functions/novu-webhook/index.ts b/supabase/functions/novu-webhook/index.ts new file mode 100644 index 00000000..02216f71 --- /dev/null +++ b/supabase/functions/novu-webhook/index.ts @@ -0,0 +1,106 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + const event = await req.json(); + + console.log('Received Novu webhook event:', event.type); + + // Handle different webhook events + switch (event.type) { + case 'notification.sent': + await handleNotificationSent(supabase, event); + break; + case 'notification.delivered': + await handleNotificationDelivered(supabase, event); + break; + case 'notification.read': + await handleNotificationRead(supabase, event); + break; + case 'notification.failed': + await handleNotificationFailed(supabase, event); + break; + default: + console.log('Unhandled event type:', event.type); + } + + return new Response( + JSON.stringify({ success: true }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error: any) { + console.error('Error processing webhook:', error); + + return new Response( + JSON.stringify({ + success: false, + error: error.message, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } + ); + } +}); + +async function handleNotificationSent(supabase: any, event: any) { + const { transactionId, channel } = event.data; + + await supabase + .from('notification_logs') + .update({ status: 'sent' }) + .eq('novu_transaction_id', transactionId); +} + +async function handleNotificationDelivered(supabase: any, event: any) { + const { transactionId } = event.data; + + await supabase + .from('notification_logs') + .update({ + status: 'delivered', + delivered_at: new Date().toISOString(), + }) + .eq('novu_transaction_id', transactionId); +} + +async function handleNotificationRead(supabase: any, event: any) { + const { transactionId } = event.data; + + await supabase + .from('notification_logs') + .update({ + read_at: new Date().toISOString(), + }) + .eq('novu_transaction_id', transactionId); +} + +async function handleNotificationFailed(supabase: any, event: any) { + const { transactionId, error } = event.data; + + await supabase + .from('notification_logs') + .update({ + status: 'failed', + error_message: error, + }) + .eq('novu_transaction_id', transactionId); +} diff --git a/supabase/functions/trigger-notification/index.ts b/supabase/functions/trigger-notification/index.ts new file mode 100644 index 00000000..8b99c4bf --- /dev/null +++ b/supabase/functions/trigger-notification/index.ts @@ -0,0 +1,63 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { Novu } from "npm:@novu/node@2.0.2"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const novuApiKey = Deno.env.get('NOVU_API_KEY'); + + if (!novuApiKey) { + throw new Error('NOVU_API_KEY is not configured'); + } + + const novu = new Novu(novuApiKey, { + backendUrl: Deno.env.get('VITE_NOVU_API_URL') || 'https://api.novu.co', + }); + + const { workflowId, subscriberId, payload, overrides } = await req.json(); + + console.log('Triggering notification:', { workflowId, subscriberId }); + + const result = await novu.trigger(workflowId, { + to: { + subscriberId, + }, + payload, + overrides, + }); + + console.log('Notification triggered successfully:', result.data); + + return new Response( + JSON.stringify({ + success: true, + transactionId: result.data.transactionId, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error: any) { + console.error('Error triggering notification:', error); + + return new Response( + JSON.stringify({ + success: false, + error: error.message, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } + ); + } +}); diff --git a/supabase/functions/update-novu-preferences/index.ts b/supabase/functions/update-novu-preferences/index.ts new file mode 100644 index 00000000..dcc1cd82 --- /dev/null +++ b/supabase/functions/update-novu-preferences/index.ts @@ -0,0 +1,88 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { Novu } from "npm:@novu/node@2.0.2"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const novuApiKey = Deno.env.get('NOVU_API_KEY'); + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + + if (!novuApiKey) { + throw new Error('NOVU_API_KEY is not configured'); + } + + const novu = new Novu(novuApiKey, { + backendUrl: Deno.env.get('VITE_NOVU_API_URL') || 'https://api.novu.co', + }); + + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + const { userId, preferences } = await req.json(); + + console.log('Updating preferences for user:', userId); + + // Get Novu subscriber ID from database + const { data: prefData, error: prefError } = await supabase + .from('user_notification_preferences') + .select('novu_subscriber_id') + .eq('user_id', userId) + .single(); + + if (prefError || !prefData?.novu_subscriber_id) { + throw new Error('Novu subscriber not found'); + } + + const subscriberId = prefData.novu_subscriber_id; + + // Update channel preferences in Novu + const channelPrefs = preferences.channelPreferences; + + await novu.subscribers.updatePreference( + subscriberId, + { + enabled: true, + channels: { + email: channelPrefs.email, + sms: channelPrefs.sms, + in_app: channelPrefs.in_app, + push: channelPrefs.push, + }, + } + ); + + console.log('Preferences updated successfully'); + + return new Response( + JSON.stringify({ + success: true, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + } + ); + } catch (error: any) { + console.error('Error updating Novu preferences:', error); + + return new Response( + JSON.stringify({ + success: false, + error: error.message, + }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + } + ); + } +}); diff --git a/supabase/migrations/20251001122233_07e1fef5-b13a-471a-a684-86f69b14434f.sql b/supabase/migrations/20251001122233_07e1fef5-b13a-471a-a684-86f69b14434f.sql new file mode 100644 index 00000000..c11fb2f8 --- /dev/null +++ b/supabase/migrations/20251001122233_07e1fef5-b13a-471a-a684-86f69b14434f.sql @@ -0,0 +1,143 @@ +-- Create notification_templates table +CREATE TABLE public.notification_templates ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + workflow_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL, + novu_workflow_id TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Create notification_logs table +CREATE TABLE public.notification_logs ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL, + template_id UUID REFERENCES public.notification_templates(id), + novu_transaction_id TEXT, + channel TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + payload JSONB, + error_message TEXT, + delivered_at TIMESTAMP WITH TIME ZONE, + read_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Create notification_channels table +CREATE TABLE public.notification_channels ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + channel_type TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + is_enabled BOOLEAN DEFAULT true, + configuration JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Create user_notification_preferences table (enhanced) +CREATE TABLE public.user_notification_preferences ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL UNIQUE, + novu_subscriber_id TEXT, + channel_preferences JSONB NOT NULL DEFAULT '{ + "in_app": true, + "email": true, + "push": false, + "sms": false + }'::jsonb, + workflow_preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + frequency_settings JSONB NOT NULL DEFAULT '{ + "digest": "daily", + "max_per_hour": 10 + }'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Enable RLS +ALTER TABLE public.notification_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.notification_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.notification_channels ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.user_notification_preferences ENABLE ROW LEVEL SECURITY; + +-- RLS Policies for notification_templates +CREATE POLICY "Public read access to notification templates" + ON public.notification_templates FOR SELECT + USING (true); + +CREATE POLICY "Admins can manage notification templates" + ON public.notification_templates FOR ALL + USING (is_moderator(auth.uid())); + +-- RLS Policies for notification_logs +CREATE POLICY "Users can view their own notification logs" + ON public.notification_logs FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Moderators can view all notification logs" + ON public.notification_logs FOR SELECT + USING (is_moderator(auth.uid())); + +CREATE POLICY "System can insert notification logs" + ON public.notification_logs FOR INSERT + WITH CHECK (true); + +-- RLS Policies for notification_channels +CREATE POLICY "Public read access to notification channels" + ON public.notification_channels FOR SELECT + USING (true); + +CREATE POLICY "Admins can manage notification channels" + ON public.notification_channels FOR ALL + USING (is_moderator(auth.uid())); + +-- RLS Policies for user_notification_preferences +CREATE POLICY "Users can manage their own notification preferences" + ON public.user_notification_preferences FOR ALL + USING (auth.uid() = user_id); + +CREATE POLICY "Moderators can view all notification preferences" + ON public.user_notification_preferences FOR SELECT + USING (is_moderator(auth.uid())); + +-- Create indexes +CREATE INDEX idx_notification_logs_user_id ON public.notification_logs(user_id); +CREATE INDEX idx_notification_logs_status ON public.notification_logs(status); +CREATE INDEX idx_notification_logs_created_at ON public.notification_logs(created_at DESC); +CREATE INDEX idx_user_notification_preferences_user_id ON public.user_notification_preferences(user_id); +CREATE INDEX idx_user_notification_preferences_novu_subscriber_id ON public.user_notification_preferences(novu_subscriber_id); + +-- Create trigger for updated_at +CREATE TRIGGER update_notification_templates_updated_at + BEFORE UPDATE ON public.notification_templates + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_notification_channels_updated_at + BEFORE UPDATE ON public.notification_channels + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_user_notification_preferences_updated_at + BEFORE UPDATE ON public.user_notification_preferences + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Insert default notification channels +INSERT INTO public.notification_channels (channel_type, name, description) VALUES + ('in_app', 'In-App Notifications', 'Real-time notifications within the application'), + ('email', 'Email Notifications', 'Email notifications sent to user''s registered email'), + ('push', 'Push Notifications', 'Browser push notifications'), + ('sms', 'SMS Notifications', 'Text message notifications (optional)'); + +-- Insert default notification templates +INSERT INTO public.notification_templates (workflow_id, name, description, category) VALUES + ('review-reply', 'Review Reply', 'Notification when someone replies to your review', 'engagement'), + ('new-follower', 'New Follower', 'Notification when someone follows you', 'social'), + ('system-announcement', 'System Announcement', 'Important system-wide announcements', 'system'), + ('weekly-digest', 'Weekly Digest', 'Weekly summary of activity', 'digest'), + ('monthly-digest', 'Monthly Digest', 'Monthly summary of activity', 'digest'), + ('moderation-alert', 'Moderation Alert', 'Notification for moderators about pending content', 'moderation'), + ('content-approved', 'Content Approved', 'Notification when your submitted content is approved', 'moderation'), + ('content-rejected', 'Content Rejected', 'Notification when your submitted content is rejected', 'moderation'); \ No newline at end of file