Skip to Content
📚 MyStoryFlow Docs — Your guide to preserving family stories

F020 - Analysis Completion Notifications

Objective

Implement a comprehensive notification system to alert authors when their manuscript analysis is complete, including email notifications, in-app alerts, and real-time updates.

Quick Implementation

Using NextSaaS Components

  • Toast notifications for real-time alerts
  • Badge component for notification counts
  • Dropdown menu for notification center
  • Email templates from NextSaaS

New Requirements

  • Email service integration
  • WebSocket/SSE for real-time updates
  • Notification preferences management
  • Queue system for reliable delivery

MVP Implementation

1. Database Schema

-- Notification preferences CREATE TABLE analyzer.notification_preferences ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES auth.users(id) UNIQUE, email_enabled BOOLEAN DEFAULT true, email_analysis_complete BOOLEAN DEFAULT true, email_weekly_summary BOOLEAN DEFAULT true, email_achievements BOOLEAN DEFAULT true, push_enabled BOOLEAN DEFAULT false, in_app_enabled BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); -- Notifications CREATE TABLE analyzer.notifications ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES auth.users(id), type VARCHAR(50) NOT NULL, -- 'analysis_complete', 'achievement_earned', etc. title VARCHAR(255) NOT NULL, message TEXT NOT NULL, data JSONB DEFAULT '{}', read BOOLEAN DEFAULT false, read_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() ); -- Email queue CREATE TABLE analyzer.email_queue ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID REFERENCES auth.users(id), email_type VARCHAR(50) NOT NULL, to_email VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, template_data JSONB NOT NULL, status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'sent', 'failed' sent_at TIMESTAMP, error_message TEXT, created_at TIMESTAMP DEFAULT NOW() ); -- Indexes CREATE INDEX idx_notifications_user_id_unread ON analyzer.notifications(user_id, read); CREATE INDEX idx_email_queue_status ON analyzer.email_queue(status);

2. Notification Service

// packages/notifications/src/services/notification-service.ts import { createClient } from '@mystoryflow/database/server' import { EmailService } from './email-service' import { RealtimeService } from './realtime-service' interface NotificationPayload { userId: string type: NotificationType title: string message: string data?: any } type NotificationType = | 'analysis_complete' | 'achievement_earned' | 'goal_completed' | 'new_feature' | 'subscription_update' export class NotificationService { private supabase = getSupabaseBrowserClient() private emailService: EmailService private realtimeService: RealtimeService constructor() { this.emailService = new EmailService() this.realtimeService = new RealtimeService() } async notify(payload: NotificationPayload): Promise<void> { // Get user preferences const preferences = await this.getUserPreferences(payload.userId) // Create in-app notification if (preferences.in_app_enabled) { await this.createInAppNotification(payload) } // Send email if enabled if (preferences.email_enabled && this.shouldSendEmail(payload.type, preferences)) { await this.queueEmail(payload) } // Send real-time notification await this.sendRealtimeNotification(payload) } async notifyAnalysisComplete( userId: string, manuscriptId: string, analysisResult: any ): Promise<void> { // Get manuscript details const { data: manuscript } = await this.supabase .from('analyzer.manuscripts') .select('title') .eq('id', manuscriptId) .single() const overallScore = analysisResult.overall_score const scoreEmoji = overallScore >= 85 ? '🎉' : overallScore >= 70 ? '👍' : '💪' await this.notify({ userId, type: 'analysis_complete', title: `Analysis Complete ${scoreEmoji}`, message: `Your manuscript "${manuscript.title}" scored ${overallScore}%. View your detailed report now!`, data: { manuscriptId, analysisId: analysisResult.id, score: overallScore } }) } async notifyAchievementEarned( userId: string, achievement: any ): Promise<void> { await this.notify({ userId, type: 'achievement_earned', title: `Achievement Unlocked! ${achievement.metadata.icon}`, message: `You've earned "${achievement.name}" - ${achievement.description}`, data: { achievementId: achievement.id, achievementCode: achievement.code } }) } private async createInAppNotification(payload: NotificationPayload): Promise<void> { await this.supabase .from('analyzer.notifications') .insert({ user_id: payload.userId, type: payload.type, title: payload.title, message: payload.message, data: payload.data }) } private async queueEmail(payload: NotificationPayload): Promise<void> { // Get user email const { data: user } = await this.supabase .from('analyzer.users') .select('email') .eq('id', payload.userId) .single() if (!user?.email) return const emailTemplate = this.getEmailTemplate(payload.type) await this.supabase .from('analyzer.email_queue') .insert({ user_id: payload.userId, email_type: payload.type, to_email: user.email, subject: emailTemplate.subject(payload), template_data: { ...payload, userName: user.email.split('@')[0] } }) } private async sendRealtimeNotification(payload: NotificationPayload): Promise<void> { await this.realtimeService.broadcast(`user:${payload.userId}`, { event: 'notification', payload }) } private async getUserPreferences(userId: string): Promise<any> { const { data } = await this.supabase .from('analyzer.notification_preferences') .select('*') .eq('user_id', userId) .single() // Return defaults if no preferences set return data || { email_enabled: true, email_analysis_complete: true, email_weekly_summary: true, email_achievements: true, in_app_enabled: true } } private shouldSendEmail(type: NotificationType, preferences: any): boolean { const emailPreferenceMap = { analysis_complete: preferences.email_analysis_complete, achievement_earned: preferences.email_achievements, goal_completed: preferences.email_achievements, new_feature: true, subscription_update: true } return emailPreferenceMap[type] ?? false } private getEmailTemplate(type: NotificationType) { const templates = { analysis_complete: { subject: (p: NotificationPayload) => `Your manuscript analysis is ready! ${p.data?.score >= 80 ? '🎉' : '📊'}`, template: 'analysis-complete' }, achievement_earned: { subject: (p: NotificationPayload) => `Achievement Unlocked: ${p.title}`, template: 'achievement-earned' }, goal_completed: { subject: () => 'Congratulations! You\'ve completed a goal 🎯', template: 'goal-completed' }, new_feature: { subject: (p: NotificationPayload) => p.title, template: 'new-feature' }, subscription_update: { subject: (p: NotificationPayload) => p.title, template: 'subscription-update' } } return templates[type] } }

3. Email Service

// packages/notifications/src/services/email-service.ts import { Resend } from 'resend' import { createClient } from '@mystoryflow/database/server' import { AnalysisCompleteEmail } from '../emails/analysis-complete' import { AchievementEarnedEmail } from '../emails/achievement-earned' export class EmailService { private resend: Resend private supabase = getSupabaseBrowserClient() constructor() { this.resend = new Resend(process.env.RESEND_API_KEY) } async processEmailQueue(): Promise<void> { // Get pending emails const { data: emails } = await this.supabase .from('analyzer.email_queue') .select('*') .eq('status', 'pending') .limit(10) if (!emails || emails.length === 0) return // Process each email for (const email of emails) { try { await this.sendEmail(email) // Mark as sent await this.supabase .from('analyzer.email_queue') .update({ status: 'sent', sent_at: new Date().toISOString() }) .eq('id', email.id) } catch (error) { // Mark as failed await this.supabase .from('analyzer.email_queue') .update({ status: 'failed', error_message: error.message }) .eq('id', email.id) } } } private async sendEmail(email: any): Promise<void> { const EmailComponent = this.getEmailComponent(email.email_type) await this.resend.emails.send({ from: 'Story Analyzer <notifications@analyzer.mystoryflow.com>', to: email.to_email, subject: email.subject, react: <EmailComponent {...email.template_data} /> }) } private getEmailComponent(type: string) { const components = { 'analysis_complete': AnalysisCompleteEmail, 'achievement_earned': AchievementEarnedEmail, // Add more email components as needed } return components[type] || AnalysisCompleteEmail } }

4. Email Templates

// packages/notifications/src/emails/analysis-complete.tsx import { Body, Button, Container, Head, Heading, Html, Img, Link, Preview, Section, Text, } from '@react-email/components' interface AnalysisCompleteEmailProps { userName: string title: string message: string data: { manuscriptId: string score: number } } export function AnalysisCompleteEmail({ userName, title, message, data }: AnalysisCompleteEmailProps) { const reportUrl = `https://analyzer.mystoryflow.com/manuscripts/${data.manuscriptId}/report` return ( <Html> <Head /> <Preview>Your manuscript analysis is ready to view</Preview> <Body style={main}> <Container style={container}> <Section style={header}> <Img src="https://analyzer.mystoryflow.com/logo.png" width="150" height="50" alt="Story Analyzer" /> </Section> <Heading style={h1}>Analysis Complete! 🎉</Heading> <Text style={text}>Hi {userName},</Text> <Text style={text}>{message}</Text> <Section style={scoreSection}> <Text style={scoreLabel}>Overall Score</Text> <Text style={scoreValue}>{data.score}%</Text> </Section> <Section style={buttonContainer}> <Button style={button} href={reportUrl}> View Detailed Report </Button> </Section> <Text style={text}> Your report includes: </Text> <ul> <li>Detailed analysis across 12 categories</li> <li>Specific strengths and areas for improvement</li> <li>Genre-specific recommendations</li> <li>Market readiness assessment</li> <li>Actionable next steps</li> </ul> <Section style={footer}> <Text style={footerText}> Happy writing!<br /> The Story Analyzer Team </Text> <Link href="https://analyzer.mystoryflow.com/settings/notifications" style={link}> Manage notification preferences </Link> </Section> </Container> </Body> </Html> ) } const main = { backgroundColor: '#f6f9fc', fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', } const container = { backgroundColor: '#ffffff', margin: '0 auto', padding: '20px 0 48px', marginBottom: '64px', } const header = { padding: '24px', textAlign: 'center' as const, } const h1 = { color: '#333', fontSize: '24px', fontWeight: '600', lineHeight: '40px', margin: '0 0 20px', textAlign: 'center' as const, } const text = { color: '#333', fontSize: '16px', lineHeight: '26px', margin: '0 0 10px', padding: '0 24px', } const scoreSection = { backgroundColor: '#f4f4f5', borderRadius: '8px', margin: '32px 24px', padding: '24px', textAlign: 'center' as const, } const scoreLabel = { color: '#666', fontSize: '14px', margin: '0 0 8px', } const scoreValue = { color: '#10b981', fontSize: '48px', fontWeight: '700', margin: '0', } const buttonContainer = { padding: '24px', textAlign: 'center' as const, } const button = { backgroundColor: '#5469d4', borderRadius: '4px', color: '#fff', fontSize: '16px', fontWeight: '600', textDecoration: 'none', textAlign: 'center' as const, display: 'inline-block', padding: '12px 20px', } const footer = { borderTop: '1px solid #e6ebf1', marginTop: '32px', padding: '32px 24px 0', textAlign: 'center' as const, } const footerText = { color: '#666', fontSize: '14px', lineHeight: '24px', margin: '0 0 16px', } const link = { color: '#5469d4', fontSize: '14px', textDecoration: 'underline', }

5. Notification Center Component

// apps/analyzer-app/src/components/notifications/NotificationCenter.tsx 'use client' import { useState, useEffect } from 'react' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Badge, Button, ScrollArea } from '@mystoryflow/ui' import { Bell, Check, Settings } from 'lucide-react' import { useNotifications } from '@/hooks/useNotifications' import { formatDistanceToNow } from 'date-fns' import Link from 'next/link' export function NotificationCenter() { const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications() const [isOpen, setIsOpen] = useState(false) return ( <DropdownMenu open={isOpen} onOpenChange={setIsOpen}> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className="relative"> <Bell className="h-5 w-5" /> {unreadCount > 0 && ( <Badge variant="destructive" className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center" > {unreadCount > 99 ? '99+' : unreadCount} </Badge> )} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-80"> <div className="flex items-center justify-between p-4 pb-2"> <h3 className="font-semibold">Notifications</h3> {unreadCount > 0 && ( <Button variant="ghost" size="sm" onClick={markAllAsRead} className="text-xs" > Mark all as read </Button> )} </div> <DropdownMenuSeparator /> <ScrollArea className="h-96"> {notifications.length === 0 ? ( <div className="p-8 text-center text-muted-foreground"> No notifications yet </div> ) : ( <div className="divide-y"> {notifications.map(notification => ( <NotificationItem key={notification.id} notification={notification} onRead={() => markAsRead(notification.id)} /> ))} </div> )} </ScrollArea> <DropdownMenuSeparator /> <DropdownMenuItem asChild> <Link href="/settings/notifications" className="flex items-center justify-center gap-2 p-3" > <Settings className="h-4 w-4" /> Notification Settings </Link> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) } function NotificationItem({ notification, onRead }: { notification: any, onRead: () => void }) { const handleClick = () => { if (!notification.read) { onRead() } } const getNotificationLink = () => { switch (notification.type) { case 'analysis_complete': return `/manuscripts/${notification.data.manuscriptId}/report` case 'achievement_earned': return '/progress#achievements' default: return '#' } } return ( <Link href={getNotificationLink()} className={` block p-4 hover:bg-muted/50 transition-colors ${!notification.read ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''} `} onClick={handleClick} > <div className="flex items-start justify-between gap-3"> <div className="flex-1 space-y-1"> <p className="font-medium text-sm">{notification.title}</p> <p className="text-sm text-muted-foreground line-clamp-2"> {notification.message} </p> <p className="text-xs text-muted-foreground"> {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })} </p> </div> {!notification.read && ( <div className="h-2 w-2 bg-blue-500 rounded-full mt-2" /> )} </div> </Link> ) }

6. Real-time Updates Hook

// apps/analyzer-app/src/hooks/useNotifications.ts import { useEffect, useState } from 'react' import { createClient } from '@mystoryflow/database/client' export function useNotifications() { const [notifications, setNotifications] = useState<any[]>([]) const [unreadCount, setUnreadCount] = useState(0) const supabase = getSupabaseBrowserClient() useEffect(() => { // Initial load loadNotifications() // Subscribe to real-time updates const channel = supabase .channel('notifications') .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'notifications' }, (payload) => { setNotifications(prev => [payload.new, ...prev]) setUnreadCount(prev => prev + 1) } ) .subscribe() return () => { supabase.removeChannel(channel) } }, []) const loadNotifications = async () => { const { data } = await supabase .from('analyzer.notifications') .select('*') .order('created_at', { ascending: false }) .limit(50) if (data) { setNotifications(data) setUnreadCount(data.filter(n => !n.read).length) } } const markAsRead = async (notificationId: string) => { await supabase .from('analyzer.notifications') .update({ read: true, read_at: new Date().toISOString() }) .eq('id', notificationId) setNotifications(prev => prev.map(n => n.id === notificationId ? { ...n, read: true, read_at: new Date().toISOString() } : n ) ) setUnreadCount(prev => Math.max(0, prev - 1)) } const markAllAsRead = async () => { await supabase .from('analyzer.notifications') .update({ read: true, read_at: new Date().toISOString() }) .eq('read', false) setNotifications(prev => prev.map(n => ({ ...n, read: true, read_at: new Date().toISOString() })) ) setUnreadCount(0) } return { notifications, unreadCount, markAsRead, markAllAsRead } }

MVP Acceptance Criteria

  • Email notifications for analysis completion
  • In-app notification center
  • Real-time notification updates
  • Notification preferences management
  • Email queue for reliable delivery
  • Professional email templates
  • Mobile-responsive notification UI
  • Unread count badge

Post-MVP Enhancements

  • Push notifications (PWA)
  • SMS notifications
  • Webhook integrations
  • Notification scheduling
  • Digest emails
  • Custom notification types
  • Rich media notifications
  • Notification analytics

Implementation Time

  • Development: 2 days
  • Testing: 0.5 days
  • Total: 2.5 days

Dependencies

  • Email service setup (Resend/SendGrid)
  • WebSocket configuration
  • Background job processing

Next Feature

After completion, proceed to F021-SUBSCRIPTION-TIERS for billing.