Add comprehensive notification system (Agent 8)
Implement multi-channel notification system with: - Core notification service with email, push, and in-app channels - Email templates for all notification types (welcome, plant registered, transport alerts, farm alerts, harvest ready, demand matches, weekly digest) - Push notification support with VAPID authentication - In-app notification management with read/unread tracking - Notification scheduler for recurring and scheduled notifications - API endpoints for notifications CRUD, preferences, and subscriptions - UI components (NotificationBell, NotificationList, NotificationItem, PreferencesForm) - Full notifications page with preferences management - Service worker for push notification handling
This commit is contained in:
parent
705105d9b6
commit
62c1ded598
20 changed files with 3463 additions and 0 deletions
127
components/notifications/NotificationBell.tsx
Normal file
127
components/notifications/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
components/notifications/NotificationItem.tsx
Normal file
156
components/notifications/NotificationItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
components/notifications/NotificationList.tsx
Normal file
229
components/notifications/NotificationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
285
components/notifications/PreferencesForm.tsx
Normal file
285
components/notifications/PreferencesForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
components/notifications/index.ts
Normal file
8
components/notifications/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Notification Components Index
|
||||
*/
|
||||
|
||||
export { NotificationBell } from './NotificationBell';
|
||||
export { NotificationList } from './NotificationList';
|
||||
export { NotificationItem } from './NotificationItem';
|
||||
export { PreferencesForm } from './PreferencesForm';
|
||||
358
lib/notifications/channels/email.ts
Normal file
358
lib/notifications/channels/email.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
219
lib/notifications/channels/inApp.ts
Normal file
219
lib/notifications/channels/inApp.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
163
lib/notifications/channels/push.ts
Normal file
163
lib/notifications/channels/push.ts
Normal 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
161
lib/notifications/index.ts
Normal 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();
|
||||
}
|
||||
344
lib/notifications/scheduler.ts
Normal file
344
lib/notifications/scheduler.ts
Normal 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();
|
||||
}
|
||||
503
lib/notifications/service.ts
Normal file
503
lib/notifications/service.ts
Normal 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
170
lib/notifications/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
110
pages/api/notifications/[id].ts
Normal file
110
pages/api/notifications/[id].ts
Normal 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' });
|
||||
}
|
||||
90
pages/api/notifications/index.ts
Normal file
90
pages/api/notifications/index.ts
Normal 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' });
|
||||
}
|
||||
79
pages/api/notifications/preferences.ts
Normal file
79
pages/api/notifications/preferences.ts
Normal 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' });
|
||||
}
|
||||
36
pages/api/notifications/read-all.ts
Normal file
36
pages/api/notifications/read-all.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
49
pages/api/notifications/stats.ts
Normal file
49
pages/api/notifications/stats.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
98
pages/api/notifications/subscribe.ts
Normal file
98
pages/api/notifications/subscribe.ts
Normal 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
72
pages/notifications.tsx
Normal 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
206
public/sw.js
Normal 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');
|
||||
Loading…
Reference in a new issue