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:
227
src/components/common/NotificationBell.tsx
Normal file
227
src/components/common/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { LazyImage } from './LazyImage';
|
||||
export { NotificationBell } from './NotificationBell';
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
57
src/services/notificationService.ts
Normal file
57
src/services/notificationService.ts
Normal 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();
|
||||
Reference in New Issue
Block a user