Merge: Comprehensive notification system (Agent 8)

This commit is contained in:
Vinnie Esposito 2025-11-23 11:00:44 -06:00
commit da1d8298f0
20 changed files with 3463 additions and 0 deletions

View file

@ -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<HTMLDivElement>(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 (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 rounded-full transition-colors"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{!isLoading && unreadCount > 0 && (
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-96 max-h-[80vh] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
<div className="p-4 border-b border-gray-100">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={handleAllRead}
className="text-sm text-green-600 hover:text-green-700"
>
Mark all as read
</button>
)}
</div>
</div>
<div className="overflow-y-auto max-h-96">
<NotificationList
userId={userId}
onNotificationRead={handleNotificationRead}
onAllRead={handleAllRead}
compact
/>
</div>
<div className="p-3 border-t border-gray-100 bg-gray-50">
<a
href="/notifications"
className="block text-center text-sm text-green-600 hover:text-green-700"
>
View all notifications
</a>
</div>
</div>
)}
</div>
);
}

View file

@ -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<string, { icon: string; bgColor: string }> = {
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 (
<div
className={`relative group ${compact ? 'p-3' : 'p-4'} hover:bg-gray-50 transition-colors ${
!notification.read ? 'bg-green-50/30' : ''
}`}
>
<div
className="flex items-start cursor-pointer"
onClick={handleClick}
role="button"
tabIndex={0}
onKeyPress={e => e.key === 'Enter' && handleClick()}
>
{/* Icon */}
<div
className={`flex-shrink-0 ${compact ? 'w-8 h-8' : 'w-10 h-10'} ${bgColor} rounded-full flex items-center justify-center`}
>
<span className={compact ? 'text-sm' : 'text-lg'}>{icon}</span>
</div>
{/* Content */}
<div className={`flex-1 ${compact ? 'ml-3' : 'ml-4'}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<p
className={`font-medium text-gray-900 ${compact ? 'text-sm' : ''} ${
!notification.read ? 'font-semibold' : ''
}`}
>
{notification.title}
</p>
<p className={`text-gray-600 mt-0.5 ${compact ? 'text-xs line-clamp-2' : 'text-sm'}`}>
{notification.message}
</p>
</div>
{/* Unread indicator */}
{!notification.read && (
<div className="flex-shrink-0 ml-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
</div>
)}
</div>
<div className="flex items-center mt-2">
<span className={`text-gray-400 ${compact ? 'text-xs' : 'text-xs'}`}>
{formatTimeAgo(notification.createdAt)}
</span>
{notification.actionUrl && (
<span className={`ml-2 text-green-600 ${compact ? 'text-xs' : 'text-xs'}`}>
View details
</span>
)}
</div>
</div>
</div>
{/* Actions (visible on hover) */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1">
{!notification.read && (
<button
onClick={e => {
e.stopPropagation();
onMarkAsRead(notification.id);
}}
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded"
title="Mark as read"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
)}
<button
onClick={e => {
e.stopPropagation();
onDelete(notification.id);
}}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}

View file

@ -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<Notification[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="p-4 text-center text-red-600">
<p>Failed to load notifications</p>
<button
onClick={() => fetchNotifications(true)}
className="mt-2 text-sm text-green-600 hover:text-green-700"
>
Try again
</button>
</div>
);
}
return (
<div className={compact ? '' : 'max-w-2xl mx-auto'}>
{showFilters && (
<div className="flex items-center justify-between mb-4 p-4 bg-white rounded-lg border">
<div className="flex space-x-2">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1 rounded-full text-sm ${
filter === 'all'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
All
</button>
<button
onClick={() => setFilter('unread')}
className={`px-3 py-1 rounded-full text-sm ${
filter === 'unread'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Unread
</button>
</div>
<button
onClick={handleMarkAllAsRead}
className="text-sm text-green-600 hover:text-green-700"
>
Mark all as read
</button>
</div>
)}
{isLoading && notifications.length === 0 ? (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
<p className="mt-2 text-gray-500">Loading notifications...</p>
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg
className="w-12 h-12 mx-auto mb-4 text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<p>No notifications yet</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkAsRead={handleMarkAsRead}
onDelete={handleDelete}
compact={compact}
/>
))}
</div>
)}
{hasMore && !isLoading && (
<div className="p-4 text-center">
<button
onClick={() => fetchNotifications(false)}
className="text-sm text-green-600 hover:text-green-700"
>
Load more
</button>
</div>
)}
{isLoading && notifications.length > 0 && (
<div className="p-4 text-center">
<div className="inline-block animate-spin rounded-full h-4 w-4 border-2 border-gray-200 border-t-green-500"></div>
</div>
)}
</div>
);
}

View file

@ -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<NotificationPreferences>({
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 (
<div className="p-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
<p className="mt-2 text-gray-500">Loading preferences...</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{message && (
<div
className={`p-4 rounded-lg ${
message.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
}`}
>
{message.text}
</div>
)}
{/* Notification Channels */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Channels</h3>
<p className="text-sm text-gray-600 mb-4">Choose how you want to receive notifications</p>
<div className="space-y-4">
<ToggleRow
label="Email notifications"
description="Receive notifications via email"
enabled={preferences.email}
onChange={() => handleToggle('email')}
/>
<ToggleRow
label="Push notifications"
description="Receive browser push notifications"
enabled={preferences.push}
onChange={() => handleToggle('push')}
/>
<ToggleRow
label="In-app notifications"
description="See notifications in the app"
enabled={preferences.inApp}
onChange={() => handleToggle('inApp')}
/>
</div>
</div>
{/* Notification Types */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Types</h3>
<p className="text-sm text-gray-600 mb-4">Choose which types of notifications you want to receive</p>
<div className="space-y-4">
<ToggleRow
label="Plant reminders"
description="Reminders for watering, fertilizing, and plant care"
enabled={preferences.plantReminders}
onChange={() => handleToggle('plantReminders')}
/>
<ToggleRow
label="Transport alerts"
description="Updates about plant transport and logistics"
enabled={preferences.transportAlerts}
onChange={() => handleToggle('transportAlerts')}
/>
<ToggleRow
label="Farm alerts"
description="Alerts about vertical farm conditions and issues"
enabled={preferences.farmAlerts}
onChange={() => handleToggle('farmAlerts')}
/>
<ToggleRow
label="Harvest alerts"
description="Notifications when crops are ready for harvest"
enabled={preferences.harvestAlerts}
onChange={() => handleToggle('harvestAlerts')}
/>
<ToggleRow
label="Demand matches"
description="Alerts when your supply matches consumer demand"
enabled={preferences.demandMatches}
onChange={() => handleToggle('demandMatches')}
/>
<ToggleRow
label="Weekly digest"
description="Weekly summary of your activity and insights"
enabled={preferences.weeklyDigest}
onChange={() => handleToggle('weeklyDigest')}
/>
</div>
</div>
{/* Quiet Hours */}
<div className="bg-white p-6 rounded-lg border">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quiet Hours</h3>
<p className="text-sm text-gray-600 mb-4">Set times when you don't want to receive notifications</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start time</label>
<input
type="time"
value={preferences.quietHoursStart || ''}
onChange={e =>
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End time</label>
<input
type="time"
value={preferences.quietHoursEnd || ''}
onChange={e =>
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"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
<select
value={preferences.timezone || ''}
onChange={e => setPreferences(prev => ({ ...prev, timezone: 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"
>
<option value="">Select timezone</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="Europe/London">London</option>
<option value="Europe/Paris">Paris</option>
<option value="Asia/Tokyo">Tokyo</option>
<option value="UTC">UTC</option>
</select>
</div>
</div>
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSaving ? 'Saving...' : 'Save Preferences'}
</button>
</div>
</form>
);
}
interface ToggleRowProps {
label: string;
description: string;
enabled: boolean;
onChange: () => void;
}
function ToggleRow({ label, description, enabled, onChange }: ToggleRowProps) {
return (
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">{label}</p>
<p className="text-sm text-gray-500">{description}</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onChange}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
enabled ? 'bg-green-600' : 'bg-gray-200'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
enabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
);
}

View file

@ -0,0 +1,8 @@
/**
* Notification Components Index
*/
export { NotificationBell } from './NotificationBell';
export { NotificationList } from './NotificationList';
export { NotificationItem } from './NotificationItem';
export { PreferencesForm } from './PreferencesForm';

View file

@ -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<void> {
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<void> {
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<void> {
// 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<string> {
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 `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${payload.title}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
.header h1 { margin: 0; font-size: 24px; }
.logo { font-size: 32px; margin-bottom: 10px; }
.content { background: white; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #22c55e; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
.button:hover { background: #16a34a; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.alert-info { background: #dbeafe; border-left: 4px solid #3b82f6; padding: 15px; margin: 15px 0; }
.alert-warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 15px 0; }
.alert-success { background: #d1fae5; border-left: 4px solid #22c55e; padding: 15px; margin: 15px 0; }
.stats { display: flex; justify-content: space-around; margin: 20px 0; }
.stat-item { text-align: center; padding: 15px; }
.stat-value { font-size: 24px; font-weight: bold; color: #22c55e; }
.stat-label { font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">🌱</div>
<h1>LocalGreenChain</h1>
</div>
<div class="content">
${content}
</div>
<div class="footer">
<p>LocalGreenChain - Transparent Seed-to-Seed Tracking</p>
<p>
<a href="{{unsubscribe_url}}">Manage notification preferences</a>
</p>
</div>
</div>
</body>
</html>`;
}
private getWelcomeTemplate(payload: NotificationPayload): string {
const content = `
<h2>Welcome to LocalGreenChain! 🌿</h2>
<p>Thank you for joining our community of sustainable growers and conscious consumers.</p>
<p>With LocalGreenChain, you can:</p>
<ul>
<li>Track your plants from seed to seed</li>
<li>Monitor transport and carbon footprint</li>
<li>Connect with local growers and consumers</li>
<li>Manage vertical farms with precision</li>
</ul>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Get Started</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getPlantRegisteredTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Plant Registered Successfully 🌱</h2>
<p>Your plant has been registered on the blockchain.</p>
<div class="alert-success">
<strong>Plant ID:</strong> ${data.plantId || 'N/A'}<br>
<strong>Species:</strong> ${data.species || 'N/A'}<br>
<strong>Variety:</strong> ${data.variety || 'N/A'}
</div>
<p>You can now track this plant throughout its entire lifecycle.</p>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Plant Details</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getPlantReminderTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Plant Care Reminder 🌿</h2>
<div class="alert-info">
<strong>${payload.title}</strong><br>
${payload.message}
</div>
${data.plantName ? `<p><strong>Plant:</strong> ${data.plantName}</p>` : ''}
${data.action ? `<p><strong>Recommended Action:</strong> ${data.action}</p>` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Plant</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getTransportAlertTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Transport Update 🚚</h2>
<div class="alert-info">
${payload.message}
</div>
${data.distance ? `
<div class="stats">
<div class="stat-item">
<div class="stat-value">${data.distance} km</div>
<div class="stat-label">Distance</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.carbonKg || '0'} kg</div>
<div class="stat-label">Carbon Footprint</div>
</div>
</div>
` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Journey</a>` : ''}
`;
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 = `
<h2>Farm Alert ${data.severity === 'warning' ? '⚠️' : ''}</h2>
<div class="${alertClass}">
<strong>${payload.title}</strong><br>
${payload.message}
</div>
${data.zone ? `<p><strong>Zone:</strong> ${data.zone}</p>` : ''}
${data.recommendation ? `<p><strong>Recommendation:</strong> ${data.recommendation}</p>` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Farm Dashboard</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getHarvestReadyTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Harvest Ready! 🎉</h2>
<div class="alert-success">
<strong>Great news!</strong> Your crop is ready for harvest.
</div>
${data.batchId ? `<p><strong>Batch:</strong> ${data.batchId}</p>` : ''}
${data.cropType ? `<p><strong>Crop:</strong> ${data.cropType}</p>` : ''}
${data.estimatedYield ? `<p><strong>Estimated Yield:</strong> ${data.estimatedYield}</p>` : ''}
<p>Log the harvest to update your blockchain records.</p>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Log Harvest</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getDemandMatchTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Demand Match Found! 🤝</h2>
<p>We've found a match between supply and demand.</p>
<div class="alert-success">
${payload.message}
</div>
${data.matchDetails ? `
<p><strong>Crop:</strong> ${data.matchDetails.crop}</p>
<p><strong>Quantity:</strong> ${data.matchDetails.quantity}</p>
<p><strong>Region:</strong> ${data.matchDetails.region}</p>
` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Match Details</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getWeeklyDigestTemplate(payload: NotificationPayload): string {
const data = payload.data || {};
const content = `
<h2>Your Weekly Summary 📊</h2>
<p>Here's what happened this week on LocalGreenChain:</p>
<div class="stats">
<div class="stat-item">
<div class="stat-value">${data.plantsRegistered || 0}</div>
<div class="stat-label">Plants Registered</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.carbonSaved || 0} kg</div>
<div class="stat-label">Carbon Saved</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.localMiles || 0}</div>
<div class="stat-label">Local Food Miles</div>
</div>
</div>
${data.highlights ? `
<h3>Highlights</h3>
<ul>
${data.highlights.map((h: string) => `<li>${h}</li>`).join('')}
</ul>
` : ''}
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Full Report</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getSystemAlertTemplate(payload: NotificationPayload): string {
const content = `
<h2>System Notification </h2>
<div class="alert-info">
<strong>${payload.title}</strong><br>
${payload.message}
</div>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Learn More</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
private getDefaultTemplate(payload: NotificationPayload): string {
const content = `
<h2>${payload.title}</h2>
<p>${payload.message}</p>
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Details</a>` : ''}
`;
return this.getBaseLayout(content, payload);
}
}

View file

@ -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<string, InAppNotification[]> = new Map();
private maxNotificationsPerUser = 100;
/**
* Send an in-app notification
*/
async send(notification: InAppNotification): Promise<void> {
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<NotificationType, number>;
} {
const userNotifications = this.notifications.get(userId) || [];
const byType: Record<string, number> = {};
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<NotificationType, number>
};
}
/**
* 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;
}
}

View file

@ -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<string, PushSubscription[]> = new Map();
constructor(config: PushConfig) {
this.config = config;
}
/**
* Send a push notification
*/
async send(data: PushNotificationData): Promise<void> {
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, 'userId' | 'createdAt'>): 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<PushNotificationData, 'token'>): Promise<void> {
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'
};
}
}

161
lib/notifications/index.ts Normal file
View file

@ -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();
}

View file

@ -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<string, ScheduledNotification> = 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<void> {
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();
}

View file

@ -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<string, Notification> = new Map();
private userPreferences: Map<string, UserNotificationPreferences> = 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<Notification> {
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<Notification[]> {
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>): 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<Notification | null> {
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<void> {
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<NotificationType, number>
};
}
/**
* 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();
}

170
lib/notifications/types.ts Normal file
View file

@ -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<string, any>;
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<string, any>;
}
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<string, any>;
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<Notification, 'id' | 'createdAt' | 'status'>;
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<NotificationType, number>;
}
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;
};
}

View file

@ -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' });
}

View file

@ -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' });
}

View file

@ -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' });
}

View file

@ -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
});
}
}

View file

@ -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
});
}
}

View file

@ -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' });
}

72
pages/notifications.tsx Normal file
View file

@ -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 (
<>
<Head>
<title>Notifications - LocalGreenChain</title>
<meta name="description" content="Manage your notifications and preferences" />
</Head>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 py-6">
<h1 className="text-2xl font-bold text-gray-900">Notifications</h1>
<p className="mt-1 text-gray-600">Manage your notifications and preferences</p>
</div>
</header>
{/* Tabs */}
<div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4">
<nav className="flex space-x-8" aria-label="Tabs">
<button
onClick={() => setActiveTab('notifications')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'notifications'
? 'border-green-500 text-green-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
All Notifications
</button>
<button
onClick={() => setActiveTab('preferences')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'preferences'
? 'border-green-500 text-green-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Preferences
</button>
</nav>
</div>
</div>
{/* Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
{activeTab === 'notifications' ? (
<div className="bg-white rounded-lg border overflow-hidden">
<NotificationList userId={userId} showFilters />
</div>
) : (
<PreferencesForm userId={userId} />
)}
</main>
</div>
</>
);
}

206
public/sw.js Normal file
View file

@ -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');