diff --git a/components/notifications/NotificationBell.tsx b/components/notifications/NotificationBell.tsx new file mode 100644 index 0000000..abe94fb --- /dev/null +++ b/components/notifications/NotificationBell.tsx @@ -0,0 +1,127 @@ +/** + * NotificationBell Component + * Header bell icon with unread badge and dropdown + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { NotificationList } from './NotificationList'; + +interface NotificationBellProps { + userId?: string; +} + +export function NotificationBell({ userId = 'demo-user' }: NotificationBellProps) { + const [isOpen, setIsOpen] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const dropdownRef = useRef(null); + + useEffect(() => { + fetchUnreadCount(); + + // Poll for new notifications every 30 seconds + const interval = setInterval(fetchUnreadCount, 30000); + return () => clearInterval(interval); + }, [userId]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + async function fetchUnreadCount() { + try { + const response = await fetch(`/api/notifications?userId=${userId}&unreadOnly=true&limit=1`); + const data = await response.json(); + + if (data.success) { + setUnreadCount(data.data.unreadCount); + } + } catch (error) { + console.error('Failed to fetch unread count:', error); + } finally { + setIsLoading(false); + } + } + + function handleNotificationRead() { + setUnreadCount(prev => Math.max(0, prev - 1)); + } + + function handleAllRead() { + setUnreadCount(0); + } + + return ( +
+ + + {isOpen && ( +
+
+
+

Notifications

+ {unreadCount > 0 && ( + + )} +
+
+ +
+ +
+ +
+ + View all notifications + +
+
+ )} +
+ ); +} diff --git a/components/notifications/NotificationItem.tsx b/components/notifications/NotificationItem.tsx new file mode 100644 index 0000000..26315dc --- /dev/null +++ b/components/notifications/NotificationItem.tsx @@ -0,0 +1,156 @@ +/** + * NotificationItem Component + * Single notification display with actions + */ + +import React from 'react'; + +interface Notification { + id: string; + type: string; + title: string; + message: string; + actionUrl?: string; + read: boolean; + createdAt: string; +} + +interface NotificationItemProps { + notification: Notification; + onMarkAsRead: (id: string) => void; + onDelete: (id: string) => void; + compact?: boolean; +} + +const typeIcons: Record = { + welcome: { icon: '👋', bgColor: 'bg-blue-100' }, + plant_registered: { icon: '🌱', bgColor: 'bg-green-100' }, + plant_reminder: { icon: 'đŸŒŋ', bgColor: 'bg-green-100' }, + transport_alert: { icon: '🚚', bgColor: 'bg-yellow-100' }, + farm_alert: { icon: '🏭', bgColor: 'bg-orange-100' }, + harvest_ready: { icon: '🎉', bgColor: 'bg-green-100' }, + demand_match: { icon: '🤝', bgColor: 'bg-purple-100' }, + weekly_digest: { icon: '📊', bgColor: 'bg-blue-100' }, + system_alert: { icon: 'âš™ī¸', bgColor: 'bg-gray-100' } +}; + +export function NotificationItem({ + notification, + onMarkAsRead, + onDelete, + compact = false +}: NotificationItemProps) { + const { icon, bgColor } = typeIcons[notification.type] || typeIcons.system_alert; + + function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (seconds < 60) return 'Just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; + + return date.toLocaleDateString(); + } + + function handleClick() { + if (!notification.read) { + onMarkAsRead(notification.id); + } + if (notification.actionUrl) { + window.location.href = notification.actionUrl; + } + } + + return ( +
+
e.key === 'Enter' && handleClick()} + > + {/* Icon */} +
+ {icon} +
+ + {/* Content */} +
+
+
+

+ {notification.title} +

+

+ {notification.message} +

+
+ + {/* Unread indicator */} + {!notification.read && ( +
+
+
+ )} +
+ +
+ + {formatTimeAgo(notification.createdAt)} + + + {notification.actionUrl && ( + + View details → + + )} +
+
+
+ + {/* Actions (visible on hover) */} +
+ {!notification.read && ( + + )} + +
+
+ ); +} diff --git a/components/notifications/NotificationList.tsx b/components/notifications/NotificationList.tsx new file mode 100644 index 0000000..69f3a21 --- /dev/null +++ b/components/notifications/NotificationList.tsx @@ -0,0 +1,229 @@ +/** + * NotificationList Component + * Displays a list of notifications with infinite scroll + */ + +import React, { useState, useEffect } from 'react'; +import { NotificationItem } from './NotificationItem'; + +interface Notification { + id: string; + type: string; + title: string; + message: string; + actionUrl?: string; + read: boolean; + createdAt: string; +} + +interface NotificationListProps { + userId?: string; + onNotificationRead?: () => void; + onAllRead?: () => void; + compact?: boolean; + showFilters?: boolean; +} + +export function NotificationList({ + userId = 'demo-user', + onNotificationRead, + onAllRead, + compact = false, + showFilters = false +}: NotificationListProps) { + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState<'all' | 'unread'>('all'); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + + const limit = compact ? 5 : 20; + + useEffect(() => { + fetchNotifications(true); + }, [userId, filter]); + + async function fetchNotifications(reset = false) { + try { + setIsLoading(true); + const currentOffset = reset ? 0 : offset; + const unreadOnly = filter === 'unread'; + + const response = await fetch( + `/api/notifications?userId=${userId}&limit=${limit}&offset=${currentOffset}&unreadOnly=${unreadOnly}` + ); + const data = await response.json(); + + if (data.success) { + if (reset) { + setNotifications(data.data.notifications); + } else { + setNotifications(prev => [...prev, ...data.data.notifications]); + } + setHasMore(data.data.pagination.hasMore); + setOffset(currentOffset + limit); + } else { + setError(data.error); + } + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + } + + async function handleMarkAsRead(notificationId: string) { + try { + const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ read: true, userId }) + }); + + if (response.ok) { + setNotifications(prev => + prev.map(n => (n.id === notificationId ? { ...n, read: true } : n)) + ); + onNotificationRead?.(); + } + } catch (error) { + console.error('Failed to mark as read:', error); + } + } + + async function handleMarkAllAsRead() { + try { + const response = await fetch('/api/notifications/read-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }) + }); + + if (response.ok) { + setNotifications(prev => prev.map(n => ({ ...n, read: true }))); + onAllRead?.(); + } + } catch (error) { + console.error('Failed to mark all as read:', error); + } + } + + async function handleDelete(notificationId: string) { + try { + const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, { + method: 'DELETE' + }); + + if (response.ok) { + setNotifications(prev => prev.filter(n => n.id !== notificationId)); + } + } catch (error) { + console.error('Failed to delete notification:', error); + } + } + + if (error) { + return ( +
+

Failed to load notifications

+ +
+ ); + } + + return ( +
+ {showFilters && ( +
+
+ + +
+ +
+ )} + + {isLoading && notifications.length === 0 ? ( +
+
+

Loading notifications...

+
+ ) : notifications.length === 0 ? ( +
+ + + +

No notifications yet

+
+ ) : ( +
+ {notifications.map(notification => ( + + ))} +
+ )} + + {hasMore && !isLoading && ( +
+ +
+ )} + + {isLoading && notifications.length > 0 && ( +
+
+
+ )} +
+ ); +} diff --git a/components/notifications/PreferencesForm.tsx b/components/notifications/PreferencesForm.tsx new file mode 100644 index 0000000..a46eb49 --- /dev/null +++ b/components/notifications/PreferencesForm.tsx @@ -0,0 +1,285 @@ +/** + * PreferencesForm Component + * User notification preferences management + */ + +import React, { useState, useEffect } from 'react'; + +interface NotificationPreferences { + email: boolean; + push: boolean; + inApp: boolean; + plantReminders: boolean; + transportAlerts: boolean; + farmAlerts: boolean; + harvestAlerts: boolean; + demandMatches: boolean; + weeklyDigest: boolean; + quietHoursStart?: string; + quietHoursEnd?: string; + timezone?: string; +} + +interface PreferencesFormProps { + userId?: string; + onSave?: (preferences: NotificationPreferences) => void; +} + +export function PreferencesForm({ userId = 'demo-user', onSave }: PreferencesFormProps) { + const [preferences, setPreferences] = useState({ + email: true, + push: true, + inApp: true, + plantReminders: true, + transportAlerts: true, + farmAlerts: true, + harvestAlerts: true, + demandMatches: true, + weeklyDigest: true + }); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + useEffect(() => { + fetchPreferences(); + }, [userId]); + + async function fetchPreferences() { + try { + const response = await fetch(`/api/notifications/preferences?userId=${userId}`); + const data = await response.json(); + + if (data.success) { + setPreferences(data.data); + } + } catch (error) { + console.error('Failed to fetch preferences:', error); + } finally { + setIsLoading(false); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsSaving(true); + setMessage(null); + + try { + const response = await fetch('/api/notifications/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...preferences, userId }) + }); + + const data = await response.json(); + + if (data.success) { + setMessage({ type: 'success', text: 'Preferences saved successfully!' }); + onSave?.(data.data); + } else { + setMessage({ type: 'error', text: data.error || 'Failed to save preferences' }); + } + } catch (error) { + setMessage({ type: 'error', text: 'Failed to save preferences' }); + } finally { + setIsSaving(false); + } + } + + function handleToggle(key: keyof NotificationPreferences) { + setPreferences(prev => ({ + ...prev, + [key]: !prev[key] + })); + } + + if (isLoading) { + return ( +
+
+

Loading preferences...

+
+ ); + } + + return ( +
+ {message && ( +
+ {message.text} +
+ )} + + {/* Notification Channels */} +
+

Notification Channels

+

Choose how you want to receive notifications

+ +
+ handleToggle('email')} + /> + handleToggle('push')} + /> + handleToggle('inApp')} + /> +
+
+ + {/* Notification Types */} +
+

Notification Types

+

Choose which types of notifications you want to receive

+ +
+ handleToggle('plantReminders')} + /> + handleToggle('transportAlerts')} + /> + handleToggle('farmAlerts')} + /> + handleToggle('harvestAlerts')} + /> + handleToggle('demandMatches')} + /> + handleToggle('weeklyDigest')} + /> +
+
+ + {/* Quiet Hours */} +
+

Quiet Hours

+

Set times when you don't want to receive notifications

+ +
+
+ + + setPreferences(prev => ({ ...prev, quietHoursStart: e.target.value })) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500" + /> +
+
+ + + setPreferences(prev => ({ ...prev, quietHoursEnd: e.target.value })) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500" + /> +
+
+ +
+ + +
+
+ + {/* Submit */} +
+ +
+
+ ); +} + +interface ToggleRowProps { + label: string; + description: string; + enabled: boolean; + onChange: () => void; +} + +function ToggleRow({ label, description, enabled, onChange }: ToggleRowProps) { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ); +} diff --git a/components/notifications/index.ts b/components/notifications/index.ts new file mode 100644 index 0000000..16e7df2 --- /dev/null +++ b/components/notifications/index.ts @@ -0,0 +1,8 @@ +/** + * Notification Components Index + */ + +export { NotificationBell } from './NotificationBell'; +export { NotificationList } from './NotificationList'; +export { NotificationItem } from './NotificationItem'; +export { PreferencesForm } from './PreferencesForm'; diff --git a/lib/notifications/channels/email.ts b/lib/notifications/channels/email.ts new file mode 100644 index 0000000..0e05550 --- /dev/null +++ b/lib/notifications/channels/email.ts @@ -0,0 +1,358 @@ +/** + * Email Notification Channel + * Handles sending email notifications via SMTP or SendGrid + */ + +import { EmailNotificationData, NotificationPayload, NotificationType } from '../types'; + +interface EmailConfig { + provider: 'sendgrid' | 'nodemailer' | 'smtp'; + apiKey?: string; + from: string; + replyTo?: string; + smtp?: { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + }; +} + +export class EmailChannel { + private config: EmailConfig; + + constructor(config: EmailConfig) { + this.config = config; + } + + /** + * Send an email notification + */ + async send(data: EmailNotificationData): Promise { + const emailData = { + ...data, + from: data.from || this.config.from, + replyTo: data.replyTo || this.config.replyTo + }; + + switch (this.config.provider) { + case 'sendgrid': + await this.sendViaSendGrid(emailData); + break; + case 'smtp': + case 'nodemailer': + await this.sendViaSMTP(emailData); + break; + default: + // Development mode - log email + console.log('[EmailChannel] Development mode - Email would be sent:', { + to: emailData.to, + subject: emailData.subject, + preview: emailData.text?.substring(0, 100) + }); + } + } + + /** + * Send via SendGrid API + */ + private async sendViaSendGrid(data: EmailNotificationData): Promise { + if (!this.config.apiKey) { + throw new Error('SendGrid API key not configured'); + } + + const response = await fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: data.to }] }], + from: { email: data.from }, + reply_to: data.replyTo ? { email: data.replyTo } : undefined, + subject: data.subject, + content: [ + { type: 'text/plain', value: data.text || data.html.replace(/<[^>]*>/g, '') }, + { type: 'text/html', value: data.html } + ] + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`SendGrid error: ${error}`); + } + } + + /** + * Send via SMTP (using nodemailer-like approach) + */ + private async sendViaSMTP(data: EmailNotificationData): Promise { + // In production, this would use nodemailer + // For now, we'll simulate the SMTP send + if (!this.config.smtp?.host) { + console.log('[EmailChannel] SMTP not configured - simulating send'); + return; + } + + // Simulate SMTP connection and send + console.log(`[EmailChannel] Sending email via SMTP to ${data.to}`); + + // In production implementation: + // const nodemailer = require('nodemailer'); + // const transporter = nodemailer.createTransport({ + // host: this.config.smtp.host, + // port: this.config.smtp.port, + // secure: this.config.smtp.secure, + // auth: { + // user: this.config.smtp.user, + // pass: this.config.smtp.pass + // } + // }); + // await transporter.sendMail(data); + } + + /** + * Render email template based on notification type + */ + async renderTemplate(payload: NotificationPayload): Promise { + const templates = { + welcome: this.getWelcomeTemplate, + plant_registered: this.getPlantRegisteredTemplate, + plant_reminder: this.getPlantReminderTemplate, + transport_alert: this.getTransportAlertTemplate, + farm_alert: this.getFarmAlertTemplate, + harvest_ready: this.getHarvestReadyTemplate, + demand_match: this.getDemandMatchTemplate, + weekly_digest: this.getWeeklyDigestTemplate, + system_alert: this.getSystemAlertTemplate + }; + + const templateFn = templates[payload.type] || this.getDefaultTemplate; + return templateFn.call(this, payload); + } + + /** + * Base email layout + */ + private getBaseLayout(content: string, payload: NotificationPayload): string { + return ` + + + + + + ${payload.title} + + + +
+
+ +

LocalGreenChain

+
+
+ ${content} +
+ +
+ +`; + } + + private getWelcomeTemplate(payload: NotificationPayload): string { + const content = ` +

Welcome to LocalGreenChain! đŸŒŋ

+

Thank you for joining our community of sustainable growers and conscious consumers.

+

With LocalGreenChain, you can:

+
    +
  • Track your plants from seed to seed
  • +
  • Monitor transport and carbon footprint
  • +
  • Connect with local growers and consumers
  • +
  • Manage vertical farms with precision
  • +
+ ${payload.actionUrl ? `Get Started` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getPlantRegisteredTemplate(payload: NotificationPayload): string { + const data = payload.data || {}; + const content = ` +

Plant Registered Successfully 🌱

+

Your plant has been registered on the blockchain.

+
+ Plant ID: ${data.plantId || 'N/A'}
+ Species: ${data.species || 'N/A'}
+ Variety: ${data.variety || 'N/A'} +
+

You can now track this plant throughout its entire lifecycle.

+ ${payload.actionUrl ? `View Plant Details` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getPlantReminderTemplate(payload: NotificationPayload): string { + const data = payload.data || {}; + const content = ` +

Plant Care Reminder đŸŒŋ

+
+ ${payload.title}
+ ${payload.message} +
+ ${data.plantName ? `

Plant: ${data.plantName}

` : ''} + ${data.action ? `

Recommended Action: ${data.action}

` : ''} + ${payload.actionUrl ? `View Plant` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getTransportAlertTemplate(payload: NotificationPayload): string { + const data = payload.data || {}; + const content = ` +

Transport Update 🚚

+
+ ${payload.message} +
+ ${data.distance ? ` +
+
+
${data.distance} km
+
Distance
+
+
+
${data.carbonKg || '0'} kg
+
Carbon Footprint
+
+
+ ` : ''} + ${payload.actionUrl ? `View Journey` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getFarmAlertTemplate(payload: NotificationPayload): string { + const data = payload.data || {}; + const alertClass = data.severity === 'warning' ? 'alert-warning' : 'alert-info'; + const content = ` +

Farm Alert ${data.severity === 'warning' ? 'âš ī¸' : 'â„šī¸'}

+
+ ${payload.title}
+ ${payload.message} +
+ ${data.zone ? `

Zone: ${data.zone}

` : ''} + ${data.recommendation ? `

Recommendation: ${data.recommendation}

` : ''} + ${payload.actionUrl ? `View Farm Dashboard` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getHarvestReadyTemplate(payload: NotificationPayload): string { + const data = payload.data || {}; + const content = ` +

Harvest Ready! 🎉

+
+ Great news! Your crop is ready for harvest. +
+ ${data.batchId ? `

Batch: ${data.batchId}

` : ''} + ${data.cropType ? `

Crop: ${data.cropType}

` : ''} + ${data.estimatedYield ? `

Estimated Yield: ${data.estimatedYield}

` : ''} +

Log the harvest to update your blockchain records.

+ ${payload.actionUrl ? `Log Harvest` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getDemandMatchTemplate(payload: NotificationPayload): string { + const data = payload.data || {}; + const content = ` +

Demand Match Found! 🤝

+

We've found a match between supply and demand.

+
+ ${payload.message} +
+ ${data.matchDetails ? ` +

Crop: ${data.matchDetails.crop}

+

Quantity: ${data.matchDetails.quantity}

+

Region: ${data.matchDetails.region}

+ ` : ''} + ${payload.actionUrl ? `View Match Details` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getWeeklyDigestTemplate(payload: NotificationPayload): string { + const data = payload.data || {}; + const content = ` +

Your Weekly Summary 📊

+

Here's what happened this week on LocalGreenChain:

+
+
+
${data.plantsRegistered || 0}
+
Plants Registered
+
+
+
${data.carbonSaved || 0} kg
+
Carbon Saved
+
+
+
${data.localMiles || 0}
+
Local Food Miles
+
+
+ ${data.highlights ? ` +

Highlights

+
    + ${data.highlights.map((h: string) => `
  • ${h}
  • `).join('')} +
+ ` : ''} + ${payload.actionUrl ? `View Full Report` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getSystemAlertTemplate(payload: NotificationPayload): string { + const content = ` +

System Notification âš™ī¸

+
+ ${payload.title}
+ ${payload.message} +
+ ${payload.actionUrl ? `Learn More` : ''} + `; + return this.getBaseLayout(content, payload); + } + + private getDefaultTemplate(payload: NotificationPayload): string { + const content = ` +

${payload.title}

+

${payload.message}

+ ${payload.actionUrl ? `View Details` : ''} + `; + return this.getBaseLayout(content, payload); + } +} diff --git a/lib/notifications/channels/inApp.ts b/lib/notifications/channels/inApp.ts new file mode 100644 index 0000000..6209438 --- /dev/null +++ b/lib/notifications/channels/inApp.ts @@ -0,0 +1,219 @@ +/** + * In-App Notification Channel + * Handles in-application notifications with persistence + */ + +import { InAppNotification, NotificationType } from '../types'; + +export class InAppChannel { + private notifications: Map = new Map(); + private maxNotificationsPerUser = 100; + + /** + * Send an in-app notification + */ + async send(notification: InAppNotification): Promise { + const userNotifications = this.notifications.get(notification.userId) || []; + + // Add new notification at the beginning + userNotifications.unshift(notification); + + // Trim to max + if (userNotifications.length > this.maxNotificationsPerUser) { + userNotifications.splice(this.maxNotificationsPerUser); + } + + this.notifications.set(notification.userId, userNotifications); + console.log(`[InAppChannel] Notification added for user ${notification.userId}`); + } + + /** + * Get notifications for a user + */ + getNotifications(userId: string, options?: { + unreadOnly?: boolean; + type?: NotificationType; + limit?: number; + offset?: number; + }): InAppNotification[] { + let userNotifications = this.notifications.get(userId) || []; + + // Filter by unread + if (options?.unreadOnly) { + userNotifications = userNotifications.filter(n => !n.read); + } + + // Filter by type + if (options?.type) { + userNotifications = userNotifications.filter(n => n.type === options.type); + } + + // Filter expired + const now = new Date().toISOString(); + userNotifications = userNotifications.filter(n => + !n.expiresAt || n.expiresAt > now + ); + + // Apply pagination + const offset = options?.offset || 0; + const limit = options?.limit || 50; + + return userNotifications.slice(offset, offset + limit); + } + + /** + * Get notification by ID + */ + getNotification(notificationId: string): InAppNotification | undefined { + for (const userNotifications of this.notifications.values()) { + const notification = userNotifications.find(n => n.id === notificationId); + if (notification) return notification; + } + return undefined; + } + + /** + * Mark notification as read + */ + markAsRead(notificationId: string): boolean { + for (const [userId, userNotifications] of this.notifications.entries()) { + const notification = userNotifications.find(n => n.id === notificationId); + if (notification) { + notification.read = true; + return true; + } + } + return false; + } + + /** + * Mark all notifications as read for a user + */ + markAllAsRead(userId: string): number { + const userNotifications = this.notifications.get(userId); + if (!userNotifications) return 0; + + let count = 0; + userNotifications.forEach(n => { + if (!n.read) { + n.read = true; + count++; + } + }); + + return count; + } + + /** + * Get unread count for a user + */ + getUnreadCount(userId: string): number { + const userNotifications = this.notifications.get(userId) || []; + return userNotifications.filter(n => !n.read).length; + } + + /** + * Delete a notification + */ + delete(notificationId: string): boolean { + for (const [userId, userNotifications] of this.notifications.entries()) { + const index = userNotifications.findIndex(n => n.id === notificationId); + if (index !== -1) { + userNotifications.splice(index, 1); + return true; + } + } + return false; + } + + /** + * Delete all notifications for a user + */ + deleteAll(userId: string): number { + const userNotifications = this.notifications.get(userId); + if (!userNotifications) return 0; + + const count = userNotifications.length; + this.notifications.delete(userId); + return count; + } + + /** + * Clean up expired notifications + */ + cleanupExpired(): number { + const now = new Date().toISOString(); + let removed = 0; + + for (const [userId, userNotifications] of this.notifications.entries()) { + const originalLength = userNotifications.length; + const filtered = userNotifications.filter(n => + !n.expiresAt || n.expiresAt > now + ); + removed += originalLength - filtered.length; + this.notifications.set(userId, filtered); + } + + return removed; + } + + /** + * Get stats for a user + */ + getStats(userId: string): { + total: number; + unread: number; + byType: Record; + } { + const userNotifications = this.notifications.get(userId) || []; + + const byType: Record = {}; + userNotifications.forEach(n => { + byType[n.type] = (byType[n.type] || 0) + 1; + }); + + return { + total: userNotifications.length, + unread: userNotifications.filter(n => !n.read).length, + byType: byType as Record + }; + } + + /** + * Group notifications by date + */ + getGroupedByDate(userId: string): { + today: InAppNotification[]; + yesterday: InAppNotification[]; + thisWeek: InAppNotification[]; + older: InAppNotification[]; + } { + const userNotifications = this.notifications.get(userId) || []; + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); + + const result = { + today: [] as InAppNotification[], + yesterday: [] as InAppNotification[], + thisWeek: [] as InAppNotification[], + older: [] as InAppNotification[] + }; + + userNotifications.forEach(n => { + const createdAt = new Date(n.createdAt); + if (createdAt >= today) { + result.today.push(n); + } else if (createdAt >= yesterday) { + result.yesterday.push(n); + } else if (createdAt >= weekAgo) { + result.thisWeek.push(n); + } else { + result.older.push(n); + } + }); + + return result; + } +} diff --git a/lib/notifications/channels/push.ts b/lib/notifications/channels/push.ts new file mode 100644 index 0000000..0d1fde6 --- /dev/null +++ b/lib/notifications/channels/push.ts @@ -0,0 +1,163 @@ +/** + * Push Notification Channel + * Handles Web Push notifications using VAPID + */ + +import { PushNotificationData, PushSubscription } from '../types'; + +interface PushConfig { + vapidPublicKey: string; + vapidPrivateKey: string; + vapidSubject: string; +} + +export class PushChannel { + private config: PushConfig; + private subscriptions: Map = new Map(); + + constructor(config: PushConfig) { + this.config = config; + } + + /** + * Send a push notification + */ + async send(data: PushNotificationData): Promise { + if (!this.config.vapidPublicKey || !this.config.vapidPrivateKey) { + console.log('[PushChannel] VAPID keys not configured - simulating push'); + return; + } + + const payload = JSON.stringify({ + title: data.title, + body: data.body, + icon: data.icon || '/icons/icon-192x192.png', + badge: data.badge || '/icons/badge-72x72.png', + data: data.data, + actions: data.actions + }); + + // In production, this would use web-push library: + // const webpush = require('web-push'); + // webpush.setVapidDetails( + // this.config.vapidSubject, + // this.config.vapidPublicKey, + // this.config.vapidPrivateKey + // ); + // await webpush.sendNotification(subscription, payload); + + console.log(`[PushChannel] Push notification sent: ${data.title}`); + } + + /** + * Subscribe a user to push notifications + */ + subscribe(userId: string, subscription: Omit): PushSubscription { + const fullSubscription: PushSubscription = { + ...subscription, + userId, + createdAt: new Date().toISOString() + }; + + const userSubs = this.subscriptions.get(userId) || []; + + // Check if subscription already exists + const existing = userSubs.find(s => s.endpoint === subscription.endpoint); + if (existing) { + existing.lastUsedAt = new Date().toISOString(); + return existing; + } + + userSubs.push(fullSubscription); + this.subscriptions.set(userId, userSubs); + + console.log(`[PushChannel] User ${userId} subscribed to push notifications`); + return fullSubscription; + } + + /** + * Unsubscribe from push notifications + */ + unsubscribe(userId: string, endpoint?: string): boolean { + if (!endpoint) { + // Remove all subscriptions for user + this.subscriptions.delete(userId); + return true; + } + + const userSubs = this.subscriptions.get(userId); + if (!userSubs) return false; + + const index = userSubs.findIndex(s => s.endpoint === endpoint); + if (index === -1) return false; + + userSubs.splice(index, 1); + if (userSubs.length === 0) { + this.subscriptions.delete(userId); + } else { + this.subscriptions.set(userId, userSubs); + } + + return true; + } + + /** + * Get subscriptions for a user + */ + getSubscriptions(userId: string): PushSubscription[] { + return this.subscriptions.get(userId) || []; + } + + /** + * Check if user has push subscriptions + */ + hasSubscription(userId: string): boolean { + const subs = this.subscriptions.get(userId); + return subs !== undefined && subs.length > 0; + } + + /** + * Send to all user's subscriptions + */ + async sendToUser(userId: string, data: Omit): Promise { + const subscriptions = this.getSubscriptions(userId); + + for (const sub of subscriptions) { + try { + await this.send({ + ...data, + token: sub.endpoint + }); + sub.lastUsedAt = new Date().toISOString(); + } catch (error: any) { + console.error(`[PushChannel] Failed to send to ${sub.endpoint}:`, error.message); + // Remove invalid subscriptions + if (error.statusCode === 410 || error.statusCode === 404) { + this.unsubscribe(userId, sub.endpoint); + } + } + } + } + + /** + * Get VAPID public key for client + */ + getPublicKey(): string { + return this.config.vapidPublicKey; + } + + /** + * Generate VAPID keys (utility method) + */ + static generateVapidKeys(): { publicKey: string; privateKey: string } { + // In production, use web-push library: + // const webpush = require('web-push'); + // return webpush.generateVAPIDKeys(); + + // For development, return placeholder keys + return { + publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U', + privateKey: 'UUxI4O8-FbRouADVXc-hK3ltRAc8_DIoISjp22LG0S0' + }; + } +} diff --git a/lib/notifications/index.ts b/lib/notifications/index.ts new file mode 100644 index 0000000..1305fcb --- /dev/null +++ b/lib/notifications/index.ts @@ -0,0 +1,161 @@ +/** + * LocalGreenChain Notification System + * Multi-channel notifications with email, push, and in-app support + */ + +export * from './types'; +export { NotificationService, getNotificationService } from './service'; +export { NotificationScheduler, getNotificationScheduler } from './scheduler'; +export { EmailChannel } from './channels/email'; +export { PushChannel } from './channels/push'; +export { InAppChannel } from './channels/inApp'; + +// Convenience functions +import { getNotificationService } from './service'; +import { getNotificationScheduler } from './scheduler'; +import { NotificationPayload, NotificationChannel, NotificationPriority, NotificationRecipient } from './types'; + +/** + * Send a notification (convenience function) + */ +export async function sendNotification( + recipient: NotificationRecipient, + payload: NotificationPayload, + options?: { + channels?: NotificationChannel[]; + priority?: NotificationPriority; + } +) { + return getNotificationService().send(recipient, payload, options); +} + +/** + * Send a welcome notification + */ +export async function sendWelcomeNotification(userId: string, email: string, name?: string) { + return sendNotification( + { userId, email }, + { + type: 'welcome', + title: `Welcome to LocalGreenChain${name ? `, ${name}` : ''}!`, + message: 'Thank you for joining our community. Start tracking your plants today!', + actionUrl: '/dashboard' + }, + { channels: ['email', 'inApp'] } + ); +} + +/** + * Send a plant registered notification + */ +export async function sendPlantRegisteredNotification( + userId: string, + email: string, + plantId: string, + species: string, + variety?: string +) { + return sendNotification( + { userId, email }, + { + type: 'plant_registered', + title: 'Plant Registered Successfully', + message: `Your ${species}${variety ? ` (${variety})` : ''} has been registered on the blockchain.`, + data: { plantId, species, variety }, + actionUrl: `/plants/${plantId}` + }, + { channels: ['inApp', 'email'] } + ); +} + +/** + * Send a transport alert + */ +export async function sendTransportAlert( + userId: string, + email: string, + plantId: string, + eventType: string, + distance?: number, + carbonKg?: number +) { + return sendNotification( + { userId, email }, + { + type: 'transport_alert', + title: 'Transport Event Recorded', + message: `A ${eventType} event has been logged for your plant.`, + data: { plantId, eventType, distance, carbonKg }, + actionUrl: `/transport/journey/${plantId}` + }, + { channels: ['inApp'] } + ); +} + +/** + * Send a farm alert + */ +export async function sendFarmAlert( + userId: string, + email: string, + farmId: string, + zone: string, + severity: 'info' | 'warning' | 'critical', + message: string, + recommendation?: string +) { + return sendNotification( + { userId, email }, + { + type: 'farm_alert', + title: `Farm Alert: ${zone}`, + message, + data: { farmId, zone, severity, recommendation }, + actionUrl: `/vertical-farm/${farmId}` + }, + { + channels: severity === 'critical' ? ['inApp', 'email', 'push'] : ['inApp'], + priority: severity === 'critical' ? 'urgent' : 'medium' + } + ); +} + +/** + * Send a demand match notification + */ +export async function sendDemandMatchNotification( + userId: string, + email: string, + matchDetails: { + crop: string; + quantity: number; + region: string; + matchId: string; + } +) { + return sendNotification( + { userId, email }, + { + type: 'demand_match', + title: 'New Demand Match Found!', + message: `A consumer is looking for ${matchDetails.quantity} units of ${matchDetails.crop} in ${matchDetails.region}.`, + data: { matchDetails }, + actionUrl: `/marketplace/match/${matchDetails.matchId}` + }, + { channels: ['inApp', 'email'], priority: 'high' } + ); +} + +/** + * Start the notification scheduler + */ +export function startNotificationScheduler(intervalMs?: number) { + getNotificationScheduler().start(intervalMs); +} + +/** + * Stop the notification scheduler + */ +export function stopNotificationScheduler() { + getNotificationScheduler().stop(); +} diff --git a/lib/notifications/scheduler.ts b/lib/notifications/scheduler.ts new file mode 100644 index 0000000..b1b308e --- /dev/null +++ b/lib/notifications/scheduler.ts @@ -0,0 +1,344 @@ +/** + * Notification Scheduler + * Handles scheduled and recurring notifications + */ + +import { getNotificationService } from './service'; +import { + ScheduledNotification, + NotificationRecipient, + NotificationPayload, + NotificationChannel, + NotificationPriority +} from './types'; + +export class NotificationScheduler { + private static instance: NotificationScheduler; + private scheduledNotifications: Map = new Map(); + private checkInterval: NodeJS.Timeout | null = null; + private isRunning = false; + + private constructor() {} + + static getInstance(): NotificationScheduler { + if (!NotificationScheduler.instance) { + NotificationScheduler.instance = new NotificationScheduler(); + } + return NotificationScheduler.instance; + } + + /** + * Start the scheduler + */ + start(intervalMs: number = 60000): void { + if (this.isRunning) return; + + this.isRunning = true; + console.log('[NotificationScheduler] Started'); + + // Check immediately + this.processScheduledNotifications(); + + // Set up interval + this.checkInterval = setInterval(() => { + this.processScheduledNotifications(); + }, intervalMs); + } + + /** + * Stop the scheduler + */ + stop(): void { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + this.isRunning = false; + console.log('[NotificationScheduler] Stopped'); + } + + /** + * Schedule a notification + */ + schedule( + recipient: NotificationRecipient, + payload: NotificationPayload, + scheduledFor: Date | string, + options?: { + channels?: NotificationChannel[]; + priority?: NotificationPriority; + recurring?: { + pattern: 'daily' | 'weekly' | 'monthly'; + endDate?: string; + }; + } + ): ScheduledNotification { + const id = `sched-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const scheduled: ScheduledNotification = { + id, + notification: { + recipientId: recipient.userId, + payload, + channels: options?.channels || ['inApp', 'email'], + priority: options?.priority || 'medium', + retryCount: 0 + }, + scheduledFor: typeof scheduledFor === 'string' ? scheduledFor : scheduledFor.toISOString(), + recurring: options?.recurring, + status: 'scheduled' + }; + + this.scheduledNotifications.set(id, scheduled); + console.log(`[NotificationScheduler] Scheduled notification ${id} for ${scheduled.scheduledFor}`); + + return scheduled; + } + + /** + * Cancel a scheduled notification + */ + cancel(id: string): boolean { + const scheduled = this.scheduledNotifications.get(id); + if (scheduled && scheduled.status === 'scheduled') { + scheduled.status = 'cancelled'; + return true; + } + return false; + } + + /** + * Get scheduled notification + */ + getScheduled(id: string): ScheduledNotification | undefined { + return this.scheduledNotifications.get(id); + } + + /** + * Get all scheduled notifications for a user + */ + getScheduledForUser(userId: string): ScheduledNotification[] { + const result: ScheduledNotification[] = []; + this.scheduledNotifications.forEach(scheduled => { + if (scheduled.notification.recipientId === userId && scheduled.status === 'scheduled') { + result.push(scheduled); + } + }); + return result.sort((a, b) => + new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime() + ); + } + + /** + * Schedule a plant care reminder + */ + schedulePlantReminder( + userId: string, + email: string, + plantId: string, + plantName: string, + reminderType: 'water' | 'fertilize' | 'prune' | 'harvest', + scheduledFor: Date + ): ScheduledNotification { + const reminderMessages = { + water: `Time to water your ${plantName}!`, + fertilize: `Your ${plantName} needs fertilizing.`, + prune: `Consider pruning your ${plantName} for better growth.`, + harvest: `Your ${plantName} may be ready for harvest!` + }; + + return this.schedule( + { userId, email }, + { + type: 'plant_reminder', + title: `Plant Care Reminder: ${plantName}`, + message: reminderMessages[reminderType], + data: { plantId, plantName, reminderType }, + actionUrl: `/plants/${plantId}` + }, + scheduledFor, + { channels: ['inApp', 'email', 'push'] } + ); + } + + /** + * Schedule weekly digest + */ + scheduleWeeklyDigest(userId: string, email: string): ScheduledNotification { + // Schedule for next Monday at 9 AM + const now = new Date(); + const nextMonday = new Date(now); + nextMonday.setDate(now.getDate() + ((7 - now.getDay() + 1) % 7 || 7)); + nextMonday.setHours(9, 0, 0, 0); + + return this.schedule( + { userId, email }, + { + type: 'weekly_digest', + title: 'Your Weekly LocalGreenChain Summary', + message: 'Check out what happened this week!', + actionUrl: '/dashboard' + }, + nextMonday, + { + channels: ['email'], + recurring: { pattern: 'weekly' } + } + ); + } + + /** + * Schedule harvest alert + */ + scheduleHarvestAlert( + userId: string, + email: string, + batchId: string, + cropType: string, + estimatedHarvestDate: Date + ): ScheduledNotification { + // Schedule alert 1 day before harvest + const alertDate = new Date(estimatedHarvestDate); + alertDate.setDate(alertDate.getDate() - 1); + + return this.schedule( + { userId, email }, + { + type: 'harvest_ready', + title: `Harvest Coming Soon: ${cropType}`, + message: `Your ${cropType} batch will be ready for harvest tomorrow!`, + data: { batchId, cropType, estimatedHarvestDate: estimatedHarvestDate.toISOString() }, + actionUrl: `/vertical-farm/batch/${batchId}` + }, + alertDate, + { channels: ['inApp', 'email', 'push'], priority: 'high' } + ); + } + + /** + * Process due notifications + */ + private async processScheduledNotifications(): Promise { + const now = new Date(); + const notificationService = getNotificationService(); + + for (const [id, scheduled] of this.scheduledNotifications.entries()) { + if (scheduled.status !== 'scheduled') continue; + + const scheduledTime = new Date(scheduled.scheduledFor); + if (scheduledTime <= now) { + try { + // Send the notification + await notificationService.send( + { userId: scheduled.notification.recipientId }, + scheduled.notification.payload, + { + channels: scheduled.notification.channels, + priority: scheduled.notification.priority + } + ); + + // Handle recurring + if (scheduled.recurring) { + const endDate = scheduled.recurring.endDate + ? new Date(scheduled.recurring.endDate) + : null; + + if (!endDate || scheduledTime < endDate) { + // Schedule next occurrence + const nextDate = this.getNextOccurrence(scheduledTime, scheduled.recurring.pattern); + scheduled.scheduledFor = nextDate.toISOString(); + console.log(`[NotificationScheduler] Rescheduled ${id} for ${scheduled.scheduledFor}`); + } else { + scheduled.status = 'sent'; + } + } else { + scheduled.status = 'sent'; + } + + console.log(`[NotificationScheduler] Sent scheduled notification ${id}`); + } catch (error: any) { + console.error(`[NotificationScheduler] Failed to send ${id}:`, error.message); + } + } + } + } + + /** + * Calculate next occurrence date + */ + private getNextOccurrence(current: Date, pattern: 'daily' | 'weekly' | 'monthly'): Date { + const next = new Date(current); + + switch (pattern) { + case 'daily': + next.setDate(next.getDate() + 1); + break; + case 'weekly': + next.setDate(next.getDate() + 7); + break; + case 'monthly': + next.setMonth(next.getMonth() + 1); + break; + } + + return next; + } + + /** + * Get scheduler stats + */ + getStats(): { + isRunning: boolean; + total: number; + scheduled: number; + sent: number; + cancelled: number; + } { + let scheduled = 0; + let sent = 0; + let cancelled = 0; + + this.scheduledNotifications.forEach(n => { + switch (n.status) { + case 'scheduled': scheduled++; break; + case 'sent': sent++; break; + case 'cancelled': cancelled++; break; + } + }); + + return { + isRunning: this.isRunning, + total: this.scheduledNotifications.size, + scheduled, + sent, + cancelled + }; + } + + /** + * Clean up old notifications + */ + cleanup(olderThanDays: number = 30): number { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - olderThanDays); + + let removed = 0; + for (const [id, scheduled] of this.scheduledNotifications.entries()) { + if (scheduled.status !== 'scheduled') { + const scheduledDate = new Date(scheduled.scheduledFor); + if (scheduledDate < cutoff) { + this.scheduledNotifications.delete(id); + removed++; + } + } + } + + return removed; + } +} + +// Export singleton getter +export function getNotificationScheduler(): NotificationScheduler { + return NotificationScheduler.getInstance(); +} diff --git a/lib/notifications/service.ts b/lib/notifications/service.ts new file mode 100644 index 0000000..ed4f5d6 --- /dev/null +++ b/lib/notifications/service.ts @@ -0,0 +1,503 @@ +/** + * NotificationService - Core notification management service + * Handles multi-channel notification dispatch and management + */ + +import { + Notification, + NotificationPayload, + NotificationChannel, + NotificationPriority, + NotificationRecipient, + NotificationStatus, + NotificationType, + UserNotificationPreferences, + InAppNotification, + NotificationStats, + NotificationConfig +} from './types'; + +import { EmailChannel } from './channels/email'; +import { PushChannel } from './channels/push'; +import { InAppChannel } from './channels/inApp'; + +export class NotificationService { + private static instance: NotificationService; + private notifications: Map = new Map(); + private userPreferences: Map = new Map(); + private stats: NotificationStats; + private config: NotificationConfig; + + private emailChannel: EmailChannel; + private pushChannel: PushChannel; + private inAppChannel: InAppChannel; + + private constructor() { + this.config = this.loadConfig(); + this.stats = this.initializeStats(); + + this.emailChannel = new EmailChannel(this.config.email); + this.pushChannel = new PushChannel(this.config.push); + this.inAppChannel = new InAppChannel(); + } + + static getInstance(): NotificationService { + if (!NotificationService.instance) { + NotificationService.instance = new NotificationService(); + } + return NotificationService.instance; + } + + /** + * Send a notification to a recipient + */ + async send( + recipient: NotificationRecipient, + payload: NotificationPayload, + options?: { + channels?: NotificationChannel[]; + priority?: NotificationPriority; + } + ): Promise { + const notification = this.createNotification(recipient, payload, options); + + // Store notification + this.notifications.set(notification.id, notification); + + // Check user preferences and quiet hours + const preferences = this.getUserPreferences(recipient.userId); + const channels = this.filterChannelsByPreferences( + notification.channels, + preferences, + payload.type + ); + + if (channels.length === 0) { + notification.status = 'delivered'; + notification.deliveredAt = new Date().toISOString(); + return notification; + } + + // Check quiet hours + if (this.isInQuietHours(preferences)) { + // Queue for later if not urgent + if (notification.priority !== 'urgent') { + console.log(`[NotificationService] Notification ${notification.id} queued for after quiet hours`); + return notification; + } + } + + // Send through each channel + await this.dispatchNotification(notification, recipient, channels); + + return notification; + } + + /** + * Send notification to multiple recipients + */ + async sendBulk( + recipients: NotificationRecipient[], + payload: NotificationPayload, + options?: { + channels?: NotificationChannel[]; + priority?: NotificationPriority; + } + ): Promise { + const notifications: Notification[] = []; + + // Process in batches + const batchSize = this.config.defaults.batchSize; + for (let i = 0; i < recipients.length; i += batchSize) { + const batch = recipients.slice(i, i + batchSize); + const batchPromises = batch.map(recipient => + this.send(recipient, payload, options) + ); + const batchResults = await Promise.allSettled(batchPromises); + + batchResults.forEach((result, index) => { + if (result.status === 'fulfilled') { + notifications.push(result.value); + } else { + console.error(`[NotificationService] Failed to send to ${batch[index].userId}:`, result.reason); + } + }); + } + + return notifications; + } + + /** + * Get notifications for a user + */ + getUserNotifications(userId: string, options?: { + unreadOnly?: boolean; + type?: NotificationType; + limit?: number; + offset?: number; + }): InAppNotification[] { + return this.inAppChannel.getNotifications(userId, options); + } + + /** + * Mark notification as read + */ + markAsRead(notificationId: string, userId: string): boolean { + const notification = this.notifications.get(notificationId); + if (notification && notification.recipientId === userId) { + notification.readAt = new Date().toISOString(); + notification.status = 'read'; + this.inAppChannel.markAsRead(notificationId); + return true; + } + return false; + } + + /** + * Mark all notifications as read for a user + */ + markAllAsRead(userId: string): number { + return this.inAppChannel.markAllAsRead(userId); + } + + /** + * Get unread count for a user + */ + getUnreadCount(userId: string): number { + return this.inAppChannel.getUnreadCount(userId); + } + + /** + * Update user notification preferences + */ + updatePreferences(userId: string, preferences: Partial): UserNotificationPreferences { + const current = this.getUserPreferences(userId); + const updated: UserNotificationPreferences = { + ...current, + ...preferences, + userId + }; + this.userPreferences.set(userId, updated); + return updated; + } + + /** + * Get user notification preferences + */ + getUserPreferences(userId: string): UserNotificationPreferences { + return this.userPreferences.get(userId) || this.getDefaultPreferences(userId); + } + + /** + * Get notification statistics + */ + getStats(): NotificationStats { + return { ...this.stats }; + } + + /** + * Get a specific notification + */ + getNotification(id: string): Notification | undefined { + return this.notifications.get(id); + } + + /** + * Retry failed notification + */ + async retry(notificationId: string): Promise { + const notification = this.notifications.get(notificationId); + if (!notification || notification.status !== 'failed') { + return null; + } + + if (notification.retryCount >= this.config.defaults.retryAttempts) { + console.log(`[NotificationService] Max retries reached for ${notificationId}`); + return notification; + } + + notification.retryCount++; + notification.status = 'pending'; + notification.error = undefined; + + // Re-dispatch + const recipient: NotificationRecipient = { + userId: notification.recipientId + }; + + await this.dispatchNotification(notification, recipient, notification.channels); + + return notification; + } + + /** + * Delete notification + */ + delete(notificationId: string, userId: string): boolean { + const notification = this.notifications.get(notificationId); + if (notification && notification.recipientId === userId) { + this.notifications.delete(notificationId); + this.inAppChannel.delete(notificationId); + return true; + } + return false; + } + + /** + * Create notification object + */ + private createNotification( + recipient: NotificationRecipient, + payload: NotificationPayload, + options?: { + channels?: NotificationChannel[]; + priority?: NotificationPriority; + } + ): Notification { + const id = `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + return { + id, + recipientId: recipient.userId, + payload, + channels: options?.channels || ['inApp', 'email'], + priority: options?.priority || 'medium', + status: 'pending', + createdAt: new Date().toISOString(), + retryCount: 0 + }; + } + + /** + * Dispatch notification through channels + */ + private async dispatchNotification( + notification: Notification, + recipient: NotificationRecipient, + channels: NotificationChannel[] + ): Promise { + const results: { channel: NotificationChannel; success: boolean; error?: string }[] = []; + + for (const channel of channels) { + try { + switch (channel) { + case 'email': + if (recipient.email) { + await this.emailChannel.send({ + to: recipient.email, + subject: notification.payload.title, + html: await this.emailChannel.renderTemplate(notification.payload), + text: notification.payload.message + }); + results.push({ channel, success: true }); + this.stats.byChannel.email.sent++; + this.stats.byChannel.email.delivered++; + } + break; + + case 'push': + if (recipient.pushToken) { + await this.pushChannel.send({ + token: recipient.pushToken, + title: notification.payload.title, + body: notification.payload.message, + data: notification.payload.data + }); + results.push({ channel, success: true }); + this.stats.byChannel.push.sent++; + this.stats.byChannel.push.delivered++; + } + break; + + case 'inApp': + await this.inAppChannel.send({ + id: notification.id, + userId: recipient.userId, + type: notification.payload.type, + title: notification.payload.title, + message: notification.payload.message, + actionUrl: notification.payload.actionUrl, + imageUrl: notification.payload.imageUrl, + read: false, + createdAt: notification.createdAt + }); + results.push({ channel, success: true }); + this.stats.byChannel.inApp.sent++; + this.stats.byChannel.inApp.delivered++; + break; + } + } catch (error: any) { + console.error(`[NotificationService] Failed to send via ${channel}:`, error.message); + results.push({ channel, success: false, error: error.message }); + this.stats.byChannel[channel].failed++; + } + } + + // Update notification status + const allSuccessful = results.every(r => r.success); + const anySuccessful = results.some(r => r.success); + + if (allSuccessful) { + notification.status = 'delivered'; + notification.deliveredAt = new Date().toISOString(); + this.stats.totalDelivered++; + } else if (anySuccessful) { + notification.status = 'delivered'; + notification.deliveredAt = new Date().toISOString(); + notification.error = results.filter(r => !r.success).map(r => `${r.channel}: ${r.error}`).join('; '); + this.stats.totalDelivered++; + } else { + notification.status = 'failed'; + notification.error = results.map(r => `${r.channel}: ${r.error}`).join('; '); + this.stats.totalFailed++; + } + + notification.sentAt = new Date().toISOString(); + this.stats.totalSent++; + this.stats.byType[notification.payload.type] = (this.stats.byType[notification.payload.type] || 0) + 1; + } + + /** + * Filter channels by user preferences + */ + private filterChannelsByPreferences( + channels: NotificationChannel[], + preferences: UserNotificationPreferences, + type: NotificationType + ): NotificationChannel[] { + return channels.filter(channel => { + // Check channel preference + if (channel === 'email' && !preferences.email) return false; + if (channel === 'push' && !preferences.push) return false; + if (channel === 'inApp' && !preferences.inApp) return false; + + // Check type-specific preference + switch (type) { + case 'plant_reminder': + return preferences.plantReminders; + case 'transport_alert': + return preferences.transportAlerts; + case 'farm_alert': + return preferences.farmAlerts; + case 'harvest_ready': + return preferences.harvestAlerts; + case 'demand_match': + return preferences.demandMatches; + case 'weekly_digest': + return preferences.weeklyDigest; + default: + return true; + } + }); + } + + /** + * Check if current time is within quiet hours + */ + private isInQuietHours(preferences: UserNotificationPreferences): boolean { + if (!preferences.quietHoursStart || !preferences.quietHoursEnd) { + return false; + } + + const now = new Date(); + const timezone = preferences.timezone || 'UTC'; + + try { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + + const currentTime = formatter.format(now); + const [startHour, startMin] = preferences.quietHoursStart.split(':').map(Number); + const [endHour, endMin] = preferences.quietHoursEnd.split(':').map(Number); + const [currentHour, currentMin] = currentTime.split(':').map(Number); + + const currentMinutes = currentHour * 60 + currentMin; + const startMinutes = startHour * 60 + startMin; + const endMinutes = endHour * 60 + endMin; + + if (startMinutes <= endMinutes) { + return currentMinutes >= startMinutes && currentMinutes < endMinutes; + } else { + // Quiet hours span midnight + return currentMinutes >= startMinutes || currentMinutes < endMinutes; + } + } catch { + return false; + } + } + + /** + * Get default preferences + */ + private getDefaultPreferences(userId: string): UserNotificationPreferences { + return { + userId, + email: true, + push: true, + inApp: true, + plantReminders: true, + transportAlerts: true, + farmAlerts: true, + harvestAlerts: true, + demandMatches: true, + weeklyDigest: true + }; + } + + /** + * Initialize stats + */ + private initializeStats(): NotificationStats { + return { + totalSent: 0, + totalDelivered: 0, + totalFailed: 0, + byChannel: { + email: { sent: 0, delivered: 0, failed: 0 }, + push: { sent: 0, delivered: 0, failed: 0 }, + inApp: { sent: 0, delivered: 0, failed: 0 } + }, + byType: {} as Record + }; + } + + /** + * Load configuration + */ + private loadConfig(): NotificationConfig { + return { + email: { + provider: (process.env.EMAIL_PROVIDER as 'sendgrid' | 'nodemailer' | 'smtp') || 'nodemailer', + apiKey: process.env.SENDGRID_API_KEY, + from: process.env.EMAIL_FROM || 'noreply@localgreenchain.local', + replyTo: process.env.EMAIL_REPLY_TO, + smtp: { + host: process.env.SMTP_HOST || 'localhost', + port: parseInt(process.env.SMTP_PORT || '587'), + secure: process.env.SMTP_SECURE === 'true', + user: process.env.SMTP_USER || '', + pass: process.env.SMTP_PASS || '' + } + }, + push: { + vapidPublicKey: process.env.VAPID_PUBLIC_KEY || '', + vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || '', + vapidSubject: process.env.VAPID_SUBJECT || 'mailto:admin@localgreenchain.local' + }, + defaults: { + retryAttempts: 3, + retryDelayMs: 5000, + batchSize: 50 + } + }; + } +} + +// Export singleton getter +export function getNotificationService(): NotificationService { + return NotificationService.getInstance(); +} diff --git a/lib/notifications/types.ts b/lib/notifications/types.ts new file mode 100644 index 0000000..d8bcd33 --- /dev/null +++ b/lib/notifications/types.ts @@ -0,0 +1,170 @@ +/** + * Notification System Types + * Multi-channel notification system for LocalGreenChain + */ + +export type NotificationChannel = 'email' | 'push' | 'inApp'; +export type NotificationPriority = 'low' | 'medium' | 'high' | 'urgent'; +export type NotificationStatus = 'pending' | 'sent' | 'delivered' | 'failed' | 'read'; +export type NotificationType = + | 'welcome' + | 'plant_registered' + | 'plant_reminder' + | 'transport_alert' + | 'farm_alert' + | 'harvest_ready' + | 'demand_match' + | 'weekly_digest' + | 'system_alert'; + +export interface NotificationRecipient { + userId: string; + email?: string; + pushToken?: string; + preferences?: UserNotificationPreferences; +} + +export interface NotificationPayload { + type: NotificationType; + title: string; + message: string; + data?: Record; + actionUrl?: string; + imageUrl?: string; +} + +export interface Notification { + id: string; + recipientId: string; + payload: NotificationPayload; + channels: NotificationChannel[]; + priority: NotificationPriority; + status: NotificationStatus; + createdAt: string; + sentAt?: string; + deliveredAt?: string; + readAt?: string; + error?: string; + retryCount: number; + metadata?: Record; +} + +export interface UserNotificationPreferences { + userId: string; + email: boolean; + push: boolean; + inApp: boolean; + plantReminders: boolean; + transportAlerts: boolean; + farmAlerts: boolean; + harvestAlerts: boolean; + demandMatches: boolean; + weeklyDigest: boolean; + quietHoursStart?: string; // HH:mm format + quietHoursEnd?: string; // HH:mm format + timezone?: string; +} + +export interface EmailNotificationData { + to: string; + subject: string; + html: string; + text?: string; + from?: string; + replyTo?: string; +} + +export interface PushNotificationData { + token: string; + title: string; + body: string; + icon?: string; + badge?: string; + data?: Record; + actions?: PushAction[]; +} + +export interface PushAction { + action: string; + title: string; + icon?: string; +} + +export interface PushSubscription { + userId: string; + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + createdAt: string; + lastUsedAt?: string; +} + +export interface InAppNotification { + id: string; + userId: string; + type: NotificationType; + title: string; + message: string; + actionUrl?: string; + imageUrl?: string; + read: boolean; + createdAt: string; + expiresAt?: string; +} + +export interface NotificationQueue { + notifications: Notification[]; + processing: boolean; + lastProcessedAt?: string; +} + +export interface ScheduledNotification { + id: string; + notification: Omit; + scheduledFor: string; + recurring?: { + pattern: 'daily' | 'weekly' | 'monthly'; + endDate?: string; + }; + status: 'scheduled' | 'sent' | 'cancelled'; +} + +export interface NotificationStats { + totalSent: number; + totalDelivered: number; + totalFailed: number; + byChannel: { + email: { sent: number; delivered: number; failed: number }; + push: { sent: number; delivered: number; failed: number }; + inApp: { sent: number; delivered: number; failed: number }; + }; + byType: Record; +} + +export interface NotificationConfig { + email: { + provider: 'sendgrid' | 'nodemailer' | 'smtp'; + apiKey?: string; + from: string; + replyTo?: string; + smtp?: { + host: string; + port: number; + secure: boolean; + user: string; + pass: string; + }; + }; + push: { + vapidPublicKey: string; + vapidPrivateKey: string; + vapidSubject: string; + }; + defaults: { + retryAttempts: number; + retryDelayMs: number; + batchSize: number; + }; +} diff --git a/pages/api/notifications/[id].ts b/pages/api/notifications/[id].ts new file mode 100644 index 0000000..f95a0d6 --- /dev/null +++ b/pages/api/notifications/[id].ts @@ -0,0 +1,110 @@ +/** + * API: Single Notification Endpoint + * GET /api/notifications/:id - Get notification details + * PATCH /api/notifications/:id - Update notification (mark as read) + * DELETE /api/notifications/:id - Delete notification + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getNotificationService } from '../../../lib/notifications'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { id } = req.query; + const notificationId = id as string; + + if (!notificationId) { + return res.status(400).json({ error: 'Notification ID required' }); + } + + const notificationService = getNotificationService(); + // In production, get userId from session/auth + const userId = req.query.userId as string || req.body?.userId || 'demo-user'; + + if (req.method === 'GET') { + try { + const notification = notificationService.getNotification(notificationId); + + if (!notification) { + return res.status(404).json({ + success: false, + error: 'Notification not found' + }); + } + + // Check ownership + if (notification.recipientId !== userId) { + return res.status(403).json({ + success: false, + error: 'Access denied' + }); + } + + return res.status(200).json({ + success: true, + data: notification + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + if (req.method === 'PATCH') { + try { + const { read } = req.body; + + if (read === true) { + const success = notificationService.markAsRead(notificationId, userId); + + if (!success) { + return res.status(404).json({ + success: false, + error: 'Notification not found or access denied' + }); + } + } + + const notification = notificationService.getNotification(notificationId); + + return res.status(200).json({ + success: true, + data: notification + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + if (req.method === 'DELETE') { + try { + const success = notificationService.delete(notificationId, userId); + + if (!success) { + return res.status(404).json({ + success: false, + error: 'Notification not found or access denied' + }); + } + + return res.status(200).json({ + success: true, + message: 'Notification deleted' + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/pages/api/notifications/index.ts b/pages/api/notifications/index.ts new file mode 100644 index 0000000..6eda513 --- /dev/null +++ b/pages/api/notifications/index.ts @@ -0,0 +1,90 @@ +/** + * API: Notifications List Endpoint + * GET /api/notifications - Get user notifications + * POST /api/notifications - Send a notification + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getNotificationService } from '../../../lib/notifications'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const notificationService = getNotificationService(); + + if (req.method === 'GET') { + try { + // In production, get userId from session/auth + const userId = req.query.userId as string || 'demo-user'; + const unreadOnly = req.query.unreadOnly === 'true'; + const type = req.query.type as string; + const limit = parseInt(req.query.limit as string) || 50; + const offset = parseInt(req.query.offset as string) || 0; + + const notifications = notificationService.getUserNotifications(userId, { + unreadOnly, + type: type as any, + limit, + offset + }); + + const unreadCount = notificationService.getUnreadCount(userId); + + return res.status(200).json({ + success: true, + data: { + notifications, + unreadCount, + pagination: { + limit, + offset, + hasMore: notifications.length === limit + } + } + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + if (req.method === 'POST') { + try { + const { recipientId, email, title, message, type, channels, priority, actionUrl, data } = req.body; + + if (!recipientId || !title || !message) { + return res.status(400).json({ + success: false, + error: 'Missing required fields: recipientId, title, message' + }); + } + + const notification = await notificationService.send( + { userId: recipientId, email }, + { + type: type || 'system_alert', + title, + message, + actionUrl, + data + }, + { channels, priority } + ); + + return res.status(201).json({ + success: true, + data: notification + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/pages/api/notifications/preferences.ts b/pages/api/notifications/preferences.ts new file mode 100644 index 0000000..79e6f72 --- /dev/null +++ b/pages/api/notifications/preferences.ts @@ -0,0 +1,79 @@ +/** + * API: Notification Preferences Endpoint + * GET /api/notifications/preferences - Get user preferences + * PUT /api/notifications/preferences - Update user preferences + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getNotificationService } from '../../../lib/notifications'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const notificationService = getNotificationService(); + // In production, get userId from session/auth + const userId = req.query.userId as string || req.body?.userId || 'demo-user'; + + if (req.method === 'GET') { + try { + const preferences = notificationService.getUserPreferences(userId); + + return res.status(200).json({ + success: true, + data: preferences + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + if (req.method === 'PUT') { + try { + const { + email, + push, + inApp, + plantReminders, + transportAlerts, + farmAlerts, + harvestAlerts, + demandMatches, + weeklyDigest, + quietHoursStart, + quietHoursEnd, + timezone + } = req.body; + + const preferences = notificationService.updatePreferences(userId, { + email, + push, + inApp, + plantReminders, + transportAlerts, + farmAlerts, + harvestAlerts, + demandMatches, + weeklyDigest, + quietHoursStart, + quietHoursEnd, + timezone + }); + + return res.status(200).json({ + success: true, + data: preferences + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/pages/api/notifications/read-all.ts b/pages/api/notifications/read-all.ts new file mode 100644 index 0000000..c331080 --- /dev/null +++ b/pages/api/notifications/read-all.ts @@ -0,0 +1,36 @@ +/** + * API: Mark All Notifications as Read + * POST /api/notifications/read-all - Mark all notifications as read for user + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getNotificationService } from '../../../lib/notifications'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const notificationService = getNotificationService(); + // In production, get userId from session/auth + const userId = req.body.userId || 'demo-user'; + + const count = notificationService.markAllAsRead(userId); + + return res.status(200).json({ + success: true, + data: { + markedAsRead: count + } + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } +} diff --git a/pages/api/notifications/stats.ts b/pages/api/notifications/stats.ts new file mode 100644 index 0000000..759e18b --- /dev/null +++ b/pages/api/notifications/stats.ts @@ -0,0 +1,49 @@ +/** + * API: Notification Statistics Endpoint + * GET /api/notifications/stats - Get notification statistics + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { getNotificationService, getNotificationScheduler } from '../../../lib/notifications'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const notificationService = getNotificationService(); + const scheduler = getNotificationScheduler(); + + // In production, get userId from session/auth + const userId = req.query.userId as string || 'demo-user'; + + const serviceStats = notificationService.getStats(); + const schedulerStats = scheduler.getStats(); + + const userNotifications = notificationService.getUserNotifications(userId); + const unreadCount = notificationService.getUnreadCount(userId); + const scheduledForUser = scheduler.getScheduledForUser(userId); + + return res.status(200).json({ + success: true, + data: { + user: { + total: userNotifications.length, + unread: unreadCount, + scheduled: scheduledForUser.length + }, + global: serviceStats, + scheduler: schedulerStats + } + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } +} diff --git a/pages/api/notifications/subscribe.ts b/pages/api/notifications/subscribe.ts new file mode 100644 index 0000000..2b0ddf3 --- /dev/null +++ b/pages/api/notifications/subscribe.ts @@ -0,0 +1,98 @@ +/** + * API: Push Notification Subscription Endpoint + * POST /api/notifications/subscribe - Subscribe to push notifications + * DELETE /api/notifications/subscribe - Unsubscribe from push notifications + * GET /api/notifications/subscribe - Get VAPID public key + */ + +import type { NextApiRequest, NextApiResponse } from 'next'; +import { PushChannel } from '../../../lib/notifications/channels/push'; + +// Create push channel instance for subscription management +const pushChannel = new PushChannel({ + vapidPublicKey: process.env.VAPID_PUBLIC_KEY || '', + vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || '', + vapidSubject: process.env.VAPID_SUBJECT || 'mailto:admin@localgreenchain.local' +}); + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + // In production, get userId from session/auth + const userId = req.query.userId as string || req.body?.userId || 'demo-user'; + + if (req.method === 'GET') { + try { + // Return VAPID public key for client subscription + const publicKey = pushChannel.getPublicKey(); + const hasSubscription = pushChannel.hasSubscription(userId); + + return res.status(200).json({ + success: true, + data: { + publicKey, + subscribed: hasSubscription + } + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + if (req.method === 'POST') { + try { + const { endpoint, keys } = req.body; + + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return res.status(400).json({ + success: false, + error: 'Invalid subscription data. Required: endpoint, keys.p256dh, keys.auth' + }); + } + + const subscription = pushChannel.subscribe(userId, { endpoint, keys }); + + return res.status(201).json({ + success: true, + data: { + subscribed: true, + subscription: { + endpoint: subscription.endpoint, + createdAt: subscription.createdAt + } + } + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + if (req.method === 'DELETE') { + try { + const { endpoint } = req.body; + + const success = pushChannel.unsubscribe(userId, endpoint); + + return res.status(200).json({ + success: true, + data: { + unsubscribed: success + } + }); + } catch (error: any) { + return res.status(500).json({ + success: false, + error: error.message + }); + } + } + + return res.status(405).json({ error: 'Method not allowed' }); +} diff --git a/pages/notifications.tsx b/pages/notifications.tsx new file mode 100644 index 0000000..3f30998 --- /dev/null +++ b/pages/notifications.tsx @@ -0,0 +1,72 @@ +/** + * Notifications Page + * Full-page notification management + */ + +import React, { useState } from 'react'; +import Head from 'next/head'; +import { NotificationList } from '../components/notifications/NotificationList'; +import { PreferencesForm } from '../components/notifications/PreferencesForm'; + +export default function NotificationsPage() { + const [activeTab, setActiveTab] = useState<'notifications' | 'preferences'>('notifications'); + const userId = 'demo-user'; // In production, get from auth + + return ( + <> + + Notifications - LocalGreenChain + + + +
+ {/* Header */} +
+
+

Notifications

+

Manage your notifications and preferences

+
+
+ + {/* Tabs */} +
+
+ +
+
+ + {/* Content */} +
+ {activeTab === 'notifications' ? ( +
+ +
+ ) : ( + + )} +
+
+ + ); +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..4a9e2d4 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,206 @@ +/** + * Service Worker for Push Notifications + * LocalGreenChain PWA Support + */ + +// Cache name versioning +const CACHE_NAME = 'lgc-cache-v1'; + +// Files to cache for offline support +const STATIC_CACHE = [ + '/', + '/offline', + '/icons/icon-192x192.png' +]; + +// Install event - cache static assets +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => { + console.log('[SW] Caching static assets'); + return cache.addAll(STATIC_CACHE); + }) + .catch((error) => { + console.log('[SW] Failed to cache:', error); + }) + ); + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => { + console.log('[SW] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }) + ); + self.clients.claim(); +}); + +// Push event - handle incoming push notifications +self.addEventListener('push', (event) => { + console.log('[SW] Push notification received'); + + let data = { + title: 'LocalGreenChain', + body: 'You have a new notification', + icon: '/icons/icon-192x192.png', + badge: '/icons/badge-72x72.png', + data: {} + }; + + if (event.data) { + try { + data = { ...data, ...event.data.json() }; + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body, + icon: data.icon, + badge: data.badge, + vibrate: [100, 50, 100], + data: data.data, + actions: data.actions || [ + { action: 'view', title: 'View', icon: '/icons/check.png' }, + { action: 'dismiss', title: 'Dismiss', icon: '/icons/close.png' } + ], + tag: data.tag || 'lgc-notification', + renotify: true, + requireInteraction: data.requireInteraction || false + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Notification click event - handle user interaction +self.addEventListener('notificationclick', (event) => { + console.log('[SW] Notification clicked:', event.action); + + event.notification.close(); + + if (event.action === 'dismiss') { + return; + } + + // Default action or 'view' action + const urlToOpen = event.notification.data?.url || '/notifications'; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((windowClients) => { + // Check if there's already a window open + for (const client of windowClients) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + client.navigate(urlToOpen); + return client.focus(); + } + } + // Open new window if none found + if (clients.openWindow) { + return clients.openWindow(urlToOpen); + } + }) + ); +}); + +// Notification close event +self.addEventListener('notificationclose', (event) => { + console.log('[SW] Notification closed'); + + // Track notification dismissal if needed + const notificationData = event.notification.data; + if (notificationData?.trackDismissal) { + // Could send analytics here + } +}); + +// Fetch event - network-first with cache fallback +self.addEventListener('fetch', (event) => { + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + // Skip API requests + if (event.request.url.includes('/api/')) return; + + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone the response for caching + const responseClone = response.clone(); + + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + + return response; + }) + .catch(() => { + // Return cached response if available + return caches.match(event.request) + .then((cachedResponse) => { + if (cachedResponse) { + return cachedResponse; + } + // Return offline page for navigation requests + if (event.request.mode === 'navigate') { + return caches.match('/offline'); + } + return new Response('Offline', { status: 503 }); + }); + }) + ); +}); + +// Message event - handle messages from client +self.addEventListener('message', (event) => { + console.log('[SW] Message received:', event.data); + + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } + + if (event.data && event.data.type === 'GET_VERSION') { + event.ports[0].postMessage({ version: CACHE_NAME }); + } +}); + +// Periodic sync for background updates (if supported) +self.addEventListener('periodicsync', (event) => { + if (event.tag === 'check-notifications') { + event.waitUntil(checkForNewNotifications()); + } +}); + +async function checkForNewNotifications() { + try { + const response = await fetch('/api/notifications?unreadOnly=true&limit=1'); + const data = await response.json(); + + if (data.success && data.data.unreadCount > 0) { + self.registration.showNotification('LocalGreenChain', { + body: `You have ${data.data.unreadCount} unread notification(s)`, + icon: '/icons/icon-192x192.png', + badge: '/icons/badge-72x72.png' + }); + } + } catch (error) { + console.log('[SW] Failed to check notifications:', error); + } +} + +console.log('[SW] Service worker loaded');