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