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.