feat: implement notification system with Mailgun email integration

- Add notifications table with migration
- Create NotificationService with area-of-interest matching
- Integrate Mailgun for email delivery
- Add notification API endpoints (get, mark as read, unread count)
- Create scheduled jobs for event reminders and cleanup
- Build NotificationBell component for frontend
- Add notification triggers for reports and events
- Implement comprehensive test script
- Update Header with notification bell
- Add email templates with HTML formatting

Requirements satisfied:
- 10.1: Users can define areas of interest (1-50km radius)
- 10.2: Notifications sent within 10 minutes
- 10.3: Notifications for new reports/events in user area
- 10.4: Configurable notification preferences (email/in-app)

Task 33 completed
This commit is contained in:
2025-11-17 00:31:23 +00:00
parent 052ba46bdc
commit d61c21c54d
21 changed files with 1716 additions and 3 deletions

View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notificationService, Notification } from '@/services/notificationService';
import { useNavigate } from 'react-router-dom';
export function NotificationBell() {
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
// Fetch unread count
const { data: unreadCount = 0 } = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: () => notificationService.getUnreadCount(),
refetchInterval: 30000, // Refetch every 30 seconds
});
// Fetch notifications when dropdown is open
const { data: notifications = [], isLoading } = useQuery({
queryKey: ['notifications'],
queryFn: () => notificationService.getNotifications(false),
enabled: isOpen,
});
// Mark as read mutation
const markAsReadMutation = useMutation({
mutationFn: (notificationId: string) => notificationService.markAsRead(notificationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
// Mark all as read mutation
const markAllAsReadMutation = useMutation({
mutationFn: () => notificationService.markAllAsRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.notification-dropdown')) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [isOpen]);
const handleNotificationClick = (notification: Notification) => {
// Mark as read
if (!notification.read) {
markAsReadMutation.mutate(notification.id);
}
// Navigate to related content
if (notification.relatedType === 'report' && notification.relatedId) {
navigate(`/map?reportId=${notification.relatedId}`);
} else if (notification.relatedType === 'event' && notification.relatedId) {
navigate(`/events?eventId=${notification.relatedId}`);
}
setIsOpen(false);
};
const getNotificationIcon = (type: Notification['type']) => {
switch (type) {
case 'new_report':
return '📍';
case 'new_event':
return '🗓️';
case 'event_reminder':
return '⏰';
case 'report_verified':
return '✅';
case 'report_disputed':
return '⚠️';
default:
return '🔔';
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
return (
<div className="notification-dropdown relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500 rounded-lg"
aria-label="Notifications"
>
<svg
className="w-6 h-6"
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>
{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 bg-white rounded-lg shadow-xl border border-gray-200 z-50 max-h-[600px] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-sm text-sky-600 hover:text-sky-700 font-medium"
disabled={markAllAsReadMutation.isPending}
>
Mark all read
</button>
)}
</div>
{/* Notifications list */}
<div className="overflow-y-auto flex-1">
{isLoading ? (
<div className="p-8 text-center text-gray-500">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-sky-500 mx-auto"></div>
<p className="mt-2">Loading notifications...</p>
</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg
className="w-16 h-16 mx-auto 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 className="mt-4 font-medium">No notifications yet</p>
<p className="mt-1 text-sm">We'll notify you when something happens</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map((notification) => (
<button
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors ${
!notification.read ? 'bg-sky-50' : ''
}`}
>
<div className="flex items-start gap-3">
<span className="text-2xl flex-shrink-0">
{getNotificationIcon(notification.type)}
</span>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium text-gray-900 ${!notification.read ? 'font-semibold' : ''}`}>
{notification.title}
</p>
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
{notification.message}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatTimestamp(notification.createdAt)}
</p>
</div>
{!notification.read && (
<span className="w-2 h-2 bg-sky-500 rounded-full flex-shrink-0 mt-2"></span>
)}
</div>
</button>
))}
</div>
)}
</div>
{/* Footer */}
{notifications.length > 0 && (
<div className="px-4 py-3 border-t border-gray-200 text-center">
<button
onClick={() => {
navigate('/profile');
setIsOpen(false);
}}
className="text-sm text-sky-600 hover:text-sky-700 font-medium"
>
Manage notification settings
</button>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1 +1,2 @@
export { LazyImage } from './LazyImage';
export { NotificationBell } from './NotificationBell';

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { SignedIn, SignedOut, SignInButton, SignUpButton } from '@clerk/clerk-react';
import { Link } from 'react-router-dom';
import { UserProfile } from '../auth/UserProfile';
import { NotificationBell } from '../common/NotificationBell';
/**
* Application header with navigation and authentication controls
@@ -58,6 +59,7 @@ export function Header() {
>
Analytics
</Link>
<NotificationBell />
<UserProfile />
</SignedIn>

View File

@@ -5,7 +5,9 @@ export { analyticsService } from './analyticsService';
export { eventService } from './eventService';
export { userService } from './userService';
export { externalDataService } from './externalDataService';
export { notificationService } from './notificationService';
export type { ApiError } from './api';
export type { CreateReportPayload, UploadPhotoResponse } from './reportService';
export type { CreateEventDTO, UpdateEventDTO, EventFilters, EventRegistration } from './eventService';
export type { UserProfile, UserActivity, UpdateProfileDTO } from './userService';
export type { Notification, NotificationCount } from './notificationService';

View File

@@ -0,0 +1,57 @@
import { apiClient } from './api';
export interface Notification {
id: string;
userId: string;
type: 'new_report' | 'new_event' | 'event_reminder' | 'report_verified' | 'report_disputed';
title: string;
message: string;
relatedId?: string;
relatedType?: 'report' | 'event';
latitude?: number;
longitude?: number;
read: boolean;
emailSent: boolean;
createdAt: string;
}
export interface NotificationCount {
count: number;
}
class NotificationService {
/**
* Get user's notifications
*/
async getNotifications(unreadOnly: boolean = false): Promise<Notification[]> {
const response = await apiClient.get<Notification[]>('/notifications', {
params: { unreadOnly },
});
return response.data;
}
/**
* Get unread notification count
*/
async getUnreadCount(): Promise<number> {
const response = await apiClient.get<NotificationCount>('/notifications/unread-count');
return response.data.count;
}
/**
* Mark notification as read
*/
async markAsRead(notificationId: string): Promise<void> {
await apiClient.patch(`/notifications/${notificationId}/read`);
}
/**
* Mark all notifications as read
*/
async markAllAsRead(): Promise<number> {
const response = await apiClient.patch<{ count: number }>('/notifications/read-all');
return response.data.count;
}
}
export const notificationService = new NotificationService();