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:
97
server/src/controllers/notificationController.ts
Normal file
97
server/src/controllers/notificationController.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { notificationService } from '../services/notificationService';
|
||||
|
||||
export class NotificationController {
|
||||
/**
|
||||
* Get user's notifications
|
||||
* GET /api/notifications
|
||||
*/
|
||||
async getNotifications(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.auth?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const unreadOnly = req.query.unreadOnly === 'true';
|
||||
|
||||
const notifications = await notificationService.getUserNotifications(
|
||||
userId,
|
||||
unreadOnly
|
||||
);
|
||||
|
||||
res.json(notifications);
|
||||
} catch (error) {
|
||||
console.error('Error fetching notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count
|
||||
* GET /api/notifications/unread-count
|
||||
*/
|
||||
async getUnreadCount(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.auth?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const count = await notificationService.getUnreadCount(userId);
|
||||
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
console.error('Error fetching unread count:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch unread count' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
* PATCH /api/notifications/:id/read
|
||||
*/
|
||||
async markAsRead(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.auth?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
const success = await notificationService.markAsRead(id, userId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Notification not found' });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as read:', error);
|
||||
res.status(500).json({ error: 'Failed to mark notification as read' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
* PATCH /api/notifications/read-all
|
||||
*/
|
||||
async markAllAsRead(req: Request, res: Response) {
|
||||
try {
|
||||
const userId = req.auth?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const count = await notificationService.markAllAsRead(userId);
|
||||
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
console.error('Error marking all notifications as read:', error);
|
||||
res.status(500).json({ error: 'Failed to mark all notifications as read' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationController = new NotificationController();
|
||||
@@ -8,6 +8,7 @@ import eventRoutes from './routes/eventRoutes';
|
||||
import userRoutes from './routes/userRoutes';
|
||||
import externalDataRoutes from './routes/externalDataRoutes';
|
||||
import analyticsRoutes from './routes/analyticsRoutes';
|
||||
import notificationRoutes from './routes/notificationRoutes';
|
||||
import { initializeScheduledJobs } from './services/scheduledJobs';
|
||||
|
||||
// Load environment variables
|
||||
@@ -47,6 +48,7 @@ app.use('/api/events', eventRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/external', externalDataRoutes);
|
||||
app.use('/api/analytics', analyticsRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
||||
22
server/src/routes/notificationRoutes.ts
Normal file
22
server/src/routes/notificationRoutes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express';
|
||||
import { notificationController } from '../controllers/notificationController';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All notification routes require authentication
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get user's notifications
|
||||
router.get('/', notificationController.getNotifications.bind(notificationController));
|
||||
|
||||
// Get unread notification count
|
||||
router.get('/unread-count', notificationController.getUnreadCount.bind(notificationController));
|
||||
|
||||
// Mark notification as read
|
||||
router.patch('/:id/read', notificationController.markAsRead.bind(notificationController));
|
||||
|
||||
// Mark all notifications as read
|
||||
router.patch('/read-all', notificationController.markAllAsRead.bind(notificationController));
|
||||
|
||||
export default router;
|
||||
204
server/src/scripts/testNotifications.ts
Normal file
204
server/src/scripts/testNotifications.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Test script for notification system
|
||||
* Tests notification creation, area-of-interest matching, and email delivery
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import { notificationService } from '../services/notificationService';
|
||||
import { emailService } from '../services/emailService';
|
||||
import prisma from '../config/database';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testNotificationSystem() {
|
||||
console.log('🔔 Testing Notification System\n');
|
||||
|
||||
try {
|
||||
// Test 1: Email Service Connection
|
||||
console.log('1️⃣ Testing email service connection...');
|
||||
const emailConnected = await emailService.testConnection();
|
||||
console.log(emailConnected ? '✅ Email service connected' : '❌ Email service failed');
|
||||
console.log('');
|
||||
|
||||
// Test 2: Get a test user
|
||||
console.log('2️⃣ Finding test user...');
|
||||
const testUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: { not: '' },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
firstName: true,
|
||||
notificationEmail: true,
|
||||
notificationInApp: true,
|
||||
areasOfInterest: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!testUser) {
|
||||
console.log('❌ No users found in database');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ Found user: ${testUser.username} (${testUser.email})`);
|
||||
console.log(` Email notifications: ${testUser.notificationEmail ? 'enabled' : 'disabled'}`);
|
||||
console.log(` In-app notifications: ${testUser.notificationInApp ? 'enabled' : 'disabled'}`);
|
||||
console.log('');
|
||||
|
||||
// Test 3: Create a test notification
|
||||
console.log('3️⃣ Creating test notification...');
|
||||
const notification = await notificationService.createNotification({
|
||||
userId: testUser.id,
|
||||
type: 'new_report',
|
||||
title: 'Test Notification',
|
||||
message: 'This is a test notification from the notification system.',
|
||||
latitude: 53.3498,
|
||||
longitude: -6.2603,
|
||||
});
|
||||
|
||||
console.log(`✅ Notification created: ${notification.id}`);
|
||||
console.log('');
|
||||
|
||||
// Test 4: Get user notifications
|
||||
console.log('4️⃣ Fetching user notifications...');
|
||||
const notifications = await notificationService.getUserNotifications(testUser.id);
|
||||
console.log(`✅ Found ${notifications.length} notifications for user`);
|
||||
|
||||
if (notifications.length > 0) {
|
||||
console.log(' Latest notifications:');
|
||||
notifications.slice(0, 3).forEach((n, i) => {
|
||||
console.log(` ${i + 1}. ${n.title} - ${n.read ? 'Read' : 'Unread'}`);
|
||||
});
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Test 5: Get unread count
|
||||
console.log('5️⃣ Getting unread notification count...');
|
||||
const unreadCount = await notificationService.getUnreadCount(testUser.id);
|
||||
console.log(`✅ Unread notifications: ${unreadCount}`);
|
||||
console.log('');
|
||||
|
||||
// Test 6: Mark notification as read
|
||||
console.log('6️⃣ Marking notification as read...');
|
||||
const marked = await notificationService.markAsRead(notification.id, testUser.id);
|
||||
console.log(marked ? '✅ Notification marked as read' : '❌ Failed to mark as read');
|
||||
console.log('');
|
||||
|
||||
// Test 7: Test area-of-interest matching
|
||||
console.log('7️⃣ Testing area-of-interest matching...');
|
||||
|
||||
// Update user with test area of interest (Dublin area)
|
||||
await prisma.user.update({
|
||||
where: { id: testUser.id },
|
||||
data: {
|
||||
areasOfInterest: JSON.stringify([
|
||||
{ lat: 53.3498, lng: -6.2603, radius: 10 }, // Dublin, 10km radius
|
||||
]),
|
||||
notificationEmail: true,
|
||||
notificationInApp: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Test location within area (Dublin city center)
|
||||
const interestedUsers1 = await notificationService.findInterestedUsers(53.3498, -6.2603);
|
||||
console.log(` Location within area (Dublin): ${interestedUsers1.length} interested users`);
|
||||
console.log(` ${interestedUsers1.includes(testUser.id) ? '✅' : '❌'} Test user is interested`);
|
||||
|
||||
// Test location outside area (Cork)
|
||||
const interestedUsers2 = await notificationService.findInterestedUsers(51.8985, -8.4756);
|
||||
console.log(` Location outside area (Cork): ${interestedUsers2.length} interested users`);
|
||||
console.log(` ${!interestedUsers2.includes(testUser.id) ? '✅' : '❌'} Test user is not interested`);
|
||||
console.log('');
|
||||
|
||||
// Test 8: Test report notification
|
||||
console.log('8️⃣ Testing report notification...');
|
||||
const testReport = await prisma.report.findFirst({
|
||||
where: {
|
||||
userId: { not: testUser.id }, // Different user
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (testReport) {
|
||||
// Temporarily update report location to be within user's area of interest
|
||||
await prisma.$executeRaw`
|
||||
UPDATE reports
|
||||
SET latitude = 53.3498, longitude = -6.2603,
|
||||
location = ST_GeomFromText('POINT(-6.2603 53.3498)', 4326)
|
||||
WHERE id = ${testReport.id}
|
||||
`;
|
||||
|
||||
const reportNotifications = await notificationService.notifyNewReport(testReport.id);
|
||||
console.log(`✅ Created ${reportNotifications.length} report notifications`);
|
||||
} else {
|
||||
console.log('⚠️ No reports found for testing');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Test 9: Test event notification
|
||||
console.log('9️⃣ Testing event notification...');
|
||||
const testEvent = await prisma.event.findFirst({
|
||||
where: {
|
||||
organizerId: { not: testUser.id }, // Different organizer
|
||||
status: 'upcoming',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (testEvent) {
|
||||
// Temporarily update event location to be within user's area of interest
|
||||
await prisma.$executeRaw`
|
||||
UPDATE events
|
||||
SET latitude = 53.3498, longitude = -6.2603,
|
||||
location = ST_GeomFromText('POINT(-6.2603 53.3498)', 4326)
|
||||
WHERE id = ${testEvent.id}
|
||||
`;
|
||||
|
||||
const eventNotifications = await notificationService.notifyNewEvent(testEvent.id);
|
||||
console.log(`✅ Created ${eventNotifications.length} event notifications`);
|
||||
} else {
|
||||
console.log('⚠️ No events found for testing');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Test 10: Test event reminders
|
||||
console.log('🔟 Testing event reminders...');
|
||||
const reminders = await notificationService.sendEventReminders();
|
||||
console.log(`✅ Sent ${reminders.length} event reminders`);
|
||||
console.log('');
|
||||
|
||||
// Test 11: Mark all as read
|
||||
console.log('1️⃣1️⃣ Testing mark all as read...');
|
||||
const markedCount = await notificationService.markAllAsRead(testUser.id);
|
||||
console.log(`✅ Marked ${markedCount} notifications as read`);
|
||||
console.log('');
|
||||
|
||||
// Test 12: Send test email directly
|
||||
console.log('1️⃣2️⃣ Sending test email...');
|
||||
const emailSent = await emailService.sendNotificationEmail(
|
||||
testUser.email,
|
||||
testUser.firstName,
|
||||
'Test Email from Beach Litter Platform',
|
||||
'This is a test email to verify that email notifications are working correctly. If you received this, the notification system is functioning properly!',
|
||||
'report',
|
||||
'test-report-id'
|
||||
);
|
||||
console.log(emailSent ? '✅ Test email sent successfully' : '❌ Failed to send test email');
|
||||
console.log('');
|
||||
|
||||
console.log('✅ All notification tests completed!\n');
|
||||
console.log('📧 Check your email inbox for test notifications');
|
||||
console.log(` Email: ${testUser.email}`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ Test failed:', error.message);
|
||||
console.error(error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testNotificationSystem();
|
||||
170
server/src/services/emailService.ts
Normal file
170
server/src/services/emailService.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import Mailgun from 'mailgun.js';
|
||||
import formData from 'form-data';
|
||||
|
||||
const mailgun = new Mailgun(formData);
|
||||
|
||||
// Initialize Mailgun client
|
||||
const mg = mailgun.client({
|
||||
username: 'api',
|
||||
key: process.env.MAILGUN_API_KEY || 'dd27be7a81dd7b0980ada31fec9a9bd9-e80d8b76-fd80c63b',
|
||||
});
|
||||
|
||||
const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN || 'sandbox-123.mailgun.org';
|
||||
const FROM_EMAIL = process.env.FROM_EMAIL || 'Beach Litter Platform <noreply@beachlitter.ie>';
|
||||
|
||||
interface EmailData {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export class EmailService {
|
||||
/**
|
||||
* Send an email using Mailgun
|
||||
*/
|
||||
async sendEmail(data: EmailData): Promise<boolean> {
|
||||
try {
|
||||
const messageData = {
|
||||
from: FROM_EMAIL,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
text: data.text,
|
||||
html: data.html || this.generateHtmlFromText(data.text),
|
||||
};
|
||||
|
||||
const response = await mg.messages.create(MAILGUN_DOMAIN, messageData);
|
||||
|
||||
console.log(`[Email] Successfully sent to ${data.to}:`, response.id);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error(`[Email] Failed to send to ${data.to}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification email
|
||||
*/
|
||||
async sendNotificationEmail(
|
||||
to: string,
|
||||
firstName: string | null,
|
||||
title: string,
|
||||
message: string,
|
||||
relatedType?: 'report' | 'event',
|
||||
relatedId?: string
|
||||
): Promise<boolean> {
|
||||
const greeting = firstName ? `Hi ${firstName}` : 'Hi';
|
||||
|
||||
const text = `
|
||||
${greeting},
|
||||
|
||||
${title}
|
||||
|
||||
${message}
|
||||
|
||||
${relatedType && relatedId ? `View details: ${this.getDetailsUrl(relatedType, relatedId)}` : ''}
|
||||
|
||||
---
|
||||
Beach Litter Mapping Platform
|
||||
To manage your notification preferences, visit your profile settings.
|
||||
`.trim();
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background-color: #0ea5e9; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background-color: #f9fafb; }
|
||||
.message { background-color: white; padding: 15px; border-radius: 5px; margin: 15px 0; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #0ea5e9; color: white; text-decoration: none; border-radius: 5px; margin: 15px 0; }
|
||||
.footer { text-align: center; padding: 20px; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🏖️ Beach Litter Platform</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>${greeting},</p>
|
||||
<div class="message">
|
||||
<h2>${title}</h2>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
${relatedType && relatedId ? `
|
||||
<a href="${this.getDetailsUrl(relatedType, relatedId)}" class="button">View Details</a>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Beach Litter Mapping Platform</p>
|
||||
<p>To manage your notification preferences, visit your profile settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
|
||||
return this.sendEmail({
|
||||
to,
|
||||
subject: title,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate basic HTML from plain text
|
||||
*/
|
||||
private generateHtmlFromText(text: string): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
${text.split('\n').map(line => `<p>${line}</p>`).join('')}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL for viewing details
|
||||
*/
|
||||
private getDetailsUrl(type: 'report' | 'event', id: string): string {
|
||||
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
if (type === 'report') {
|
||||
return `${baseUrl}/map?reportId=${id}`;
|
||||
} else {
|
||||
return `${baseUrl}/events?eventId=${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test email configuration
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
// Send a test email to verify configuration
|
||||
const testEmail = process.env.TEST_EMAIL || 'test@example.com';
|
||||
|
||||
await this.sendEmail({
|
||||
to: testEmail,
|
||||
subject: 'Beach Litter Platform - Email Test',
|
||||
text: 'This is a test email from the Beach Litter Mapping Platform. If you received this, email notifications are working correctly!',
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('[Email] Connection test failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const emailService = new EmailService();
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { notificationService } from './notificationService';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -180,7 +181,7 @@ export const eventService = {
|
||||
throw new Error('Failed to retrieve created event');
|
||||
}
|
||||
|
||||
return {
|
||||
const eventResponse = {
|
||||
id: createdEvent.id,
|
||||
organizerId: createdEvent.organizerId,
|
||||
title: createdEvent.title,
|
||||
@@ -197,6 +198,13 @@ export const eventService = {
|
||||
createdAt: createdEvent.createdAt,
|
||||
updatedAt: createdEvent.updatedAt,
|
||||
};
|
||||
|
||||
// Trigger notifications asynchronously (don't wait for completion)
|
||||
notificationService.notifyNewEvent(createdEvent.id).catch(error => {
|
||||
console.error('Error sending event notifications:', error);
|
||||
});
|
||||
|
||||
return eventResponse;
|
||||
},
|
||||
|
||||
async updateEvent(id: string, data: UpdateEventData) {
|
||||
|
||||
468
server/src/services/notificationService.ts
Normal file
468
server/src/services/notificationService.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import prisma from '../config/database';
|
||||
import { calculateDistance } from '../utils/spatial';
|
||||
import { emailService } from './emailService';
|
||||
|
||||
interface AreaOfInterest {
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius: number; // in kilometers
|
||||
}
|
||||
|
||||
interface CreateNotificationData {
|
||||
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;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Create a notification for a specific user
|
||||
*/
|
||||
async createNotification(data: CreateNotificationData) {
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
relatedId: data.relatedId,
|
||||
relatedType: data.relatedType,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
read: false,
|
||||
emailSent: false,
|
||||
},
|
||||
});
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find users interested in a specific location
|
||||
* Returns users who have areas of interest that include the given location
|
||||
*/
|
||||
async findInterestedUsers(latitude: number, longitude: number): Promise<string[]> {
|
||||
// Get all users with notification preferences enabled and areas of interest
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ notificationEmail: true },
|
||||
{ notificationInApp: true },
|
||||
],
|
||||
areasOfInterest: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
areasOfInterest: true,
|
||||
},
|
||||
});
|
||||
|
||||
const interestedUserIds: string[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.areasOfInterest) continue;
|
||||
|
||||
try {
|
||||
const areasOfInterest: AreaOfInterest[] =
|
||||
typeof user.areasOfInterest === 'string'
|
||||
? JSON.parse(user.areasOfInterest)
|
||||
: user.areasOfInterest as any;
|
||||
|
||||
// Check if the location falls within any of the user's areas of interest
|
||||
for (const area of areasOfInterest) {
|
||||
const distance = calculateDistance(
|
||||
latitude,
|
||||
longitude,
|
||||
area.lat,
|
||||
area.lng
|
||||
);
|
||||
|
||||
// If within radius, add user to interested list
|
||||
if (distance <= area.radius) {
|
||||
interestedUserIds.push(user.id);
|
||||
break; // No need to check other areas for this user
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing areasOfInterest for user ${user.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return interestedUserIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify users about a new report
|
||||
*/
|
||||
async notifyNewReport(reportId: string) {
|
||||
const report = await prisma.report.findUnique({
|
||||
where: { id: reportId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!report) {
|
||||
throw new Error('Report not found');
|
||||
}
|
||||
|
||||
const latitude = Number(report.latitude);
|
||||
const longitude = Number(report.longitude);
|
||||
|
||||
// Find interested users
|
||||
const interestedUserIds = await this.findInterestedUsers(latitude, longitude);
|
||||
|
||||
// Filter out the report creator
|
||||
const recipientIds = interestedUserIds.filter(id => id !== report.userId);
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create notifications for all interested users
|
||||
const notifications = await Promise.all(
|
||||
recipientIds.map(userId =>
|
||||
this.createNotification({
|
||||
userId,
|
||||
type: 'new_report',
|
||||
title: 'New Litter Report in Your Area',
|
||||
message: `${report.user.username} reported ${report.litterType} litter near your area of interest.`,
|
||||
relatedId: reportId,
|
||||
relatedType: 'report',
|
||||
latitude,
|
||||
longitude,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Send email notifications asynchronously
|
||||
this.sendEmailNotifications(notifications).catch(error => {
|
||||
console.error('Error sending email notifications:', error);
|
||||
});
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify users about a new event
|
||||
*/
|
||||
async notifyNewEvent(eventId: string) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: {
|
||||
organizer: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const latitude = Number(event.latitude);
|
||||
const longitude = Number(event.longitude);
|
||||
|
||||
// Find interested users
|
||||
const interestedUserIds = await this.findInterestedUsers(latitude, longitude);
|
||||
|
||||
// Filter out the event organizer
|
||||
const recipientIds = interestedUserIds.filter(id => id !== event.organizerId);
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create notifications for all interested users
|
||||
const notifications = await Promise.all(
|
||||
recipientIds.map(userId =>
|
||||
this.createNotification({
|
||||
userId,
|
||||
type: 'new_event',
|
||||
title: 'New Cleanup Event in Your Area',
|
||||
message: `${event.organizer.username} organized "${event.title}" at ${event.locationName}.`,
|
||||
relatedId: eventId,
|
||||
relatedType: 'event',
|
||||
latitude,
|
||||
longitude,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Send email notifications asynchronously
|
||||
this.sendEmailNotifications(notifications).catch(error => {
|
||||
console.error('Error sending email notifications:', error);
|
||||
});
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify user about report verification
|
||||
*/
|
||||
async notifyReportVerification(reportId: string, verificationType: 'verify' | 'dispute') {
|
||||
const report = await prisma.report.findUnique({
|
||||
where: { id: reportId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
notificationEmail: true,
|
||||
notificationInApp: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!report || !report.user.notificationInApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = verificationType === 'verify' ? 'report_verified' : 'report_disputed';
|
||||
const title = verificationType === 'verify'
|
||||
? 'Your Report Was Verified'
|
||||
: 'Your Report Was Disputed';
|
||||
const message = verificationType === 'verify'
|
||||
? 'A community member verified your litter report.'
|
||||
: 'A community member disputed your litter report.';
|
||||
|
||||
const notification = await this.createNotification({
|
||||
userId: report.userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
relatedId: reportId,
|
||||
relatedType: 'report',
|
||||
latitude: Number(report.latitude),
|
||||
longitude: Number(report.longitude),
|
||||
});
|
||||
|
||||
// Send email notification asynchronously
|
||||
this.sendEmailNotifications([notification]).catch(error => {
|
||||
console.error('Error sending email notification:', error);
|
||||
});
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event reminders for upcoming events (within 24 hours)
|
||||
*/
|
||||
async sendEventReminders() {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(tomorrow.getHours() + 24);
|
||||
|
||||
const upcomingEvents = await prisma.event.findMany({
|
||||
where: {
|
||||
status: 'upcoming',
|
||||
scheduledDate: {
|
||||
gte: new Date(),
|
||||
lte: tomorrow,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
registrations: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
notificationEmail: true,
|
||||
notificationInApp: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const notifications = [];
|
||||
|
||||
for (const event of upcomingEvents) {
|
||||
for (const registration of event.registrations) {
|
||||
if (!registration.user.notificationInApp) continue;
|
||||
|
||||
// Check if reminder already sent
|
||||
const existingReminder = await prisma.notification.findFirst({
|
||||
where: {
|
||||
userId: registration.userId,
|
||||
type: 'event_reminder',
|
||||
relatedId: event.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingReminder) continue;
|
||||
|
||||
const notification = await this.createNotification({
|
||||
userId: registration.userId,
|
||||
type: 'event_reminder',
|
||||
title: 'Event Reminder',
|
||||
message: `"${event.title}" is happening soon at ${event.locationName}!`,
|
||||
relatedId: event.id,
|
||||
relatedType: 'event',
|
||||
latitude: Number(event.latitude),
|
||||
longitude: Number(event.longitude),
|
||||
});
|
||||
|
||||
notifications.push(notification);
|
||||
}
|
||||
}
|
||||
|
||||
// Send email notifications asynchronously
|
||||
if (notifications.length > 0) {
|
||||
this.sendEmailNotifications(notifications).catch(error => {
|
||||
console.error('Error sending email reminders:', error);
|
||||
});
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's notifications
|
||||
*/
|
||||
async getUserNotifications(userId: string, unreadOnly: boolean = false) {
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(unreadOnly ? { read: false } : {}),
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
async markAsRead(notificationId: string, userId: string) {
|
||||
const notification = await prisma.notification.updateMany({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId, // Ensure user owns the notification
|
||||
},
|
||||
data: {
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
return notification.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
async markAllAsRead(userId: string) {
|
||||
const result = await prisma.notification.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
read: false,
|
||||
},
|
||||
data: {
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count
|
||||
*/
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
const count = await prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
read: false,
|
||||
},
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old notifications (older than 30 days)
|
||||
*/
|
||||
async cleanupOldNotifications() {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const result = await prisma.notification.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: thirtyDaysAgo,
|
||||
},
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notifications using Mailgun
|
||||
*/
|
||||
private async sendEmailNotifications(notifications: any[]) {
|
||||
// Get user emails for notifications
|
||||
const userIds = [...new Set(notifications.map(n => n.userId))];
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: userIds },
|
||||
notificationEmail: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
for (const notification of notifications) {
|
||||
const user = userMap.get(notification.userId);
|
||||
if (!user) continue;
|
||||
|
||||
try {
|
||||
// Send email using Mailgun
|
||||
const success = await emailService.sendNotificationEmail(
|
||||
user.email,
|
||||
user.firstName,
|
||||
notification.title,
|
||||
notification.message,
|
||||
notification.relatedType,
|
||||
notification.relatedId
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Mark as email sent
|
||||
await prisma.notification.update({
|
||||
where: { id: notification.id },
|
||||
data: { emailSent: true },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sending email to ${user.email}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
@@ -1,5 +1,6 @@
|
||||
import prisma from '../config/database';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { notificationService } from './notificationService';
|
||||
|
||||
export interface CreateReportData {
|
||||
userId: string;
|
||||
@@ -69,6 +70,13 @@ export class ReportService {
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger notifications asynchronously (don't wait for completion)
|
||||
if (report) {
|
||||
notificationService.notifyNewReport(report.id).catch(error => {
|
||||
console.error('Error sending report notifications:', error);
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import cron, { ScheduledTask } from 'node-cron';
|
||||
import { analyticsService } from './analyticsService';
|
||||
import { notificationService } from './notificationService';
|
||||
|
||||
/**
|
||||
* Initialize all scheduled jobs
|
||||
@@ -28,9 +29,38 @@ export function initializeScheduledJobs() {
|
||||
|
||||
console.log('[Scheduled Jobs] ✓ Hotspot calculation job scheduled (every 5 minutes)');
|
||||
|
||||
// Event reminder job - runs every 10 minutes
|
||||
// Requirement 10.2: Send notifications within 10 minutes
|
||||
const eventReminderJob = cron.schedule('*/10 * * * *', async () => {
|
||||
try {
|
||||
console.log('[Scheduled Jobs] Checking for event reminders...');
|
||||
const reminders = await notificationService.sendEventReminders();
|
||||
console.log(`[Scheduled Jobs] Event reminders sent: ${reminders.length} notifications`);
|
||||
} catch (error: any) {
|
||||
console.error('[Scheduled Jobs] Error in event reminder job:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Scheduled Jobs] ✓ Event reminder job scheduled (every 10 minutes)');
|
||||
|
||||
// Notification cleanup job - runs daily at 2 AM
|
||||
const notificationCleanupJob = cron.schedule('0 2 * * *', async () => {
|
||||
try {
|
||||
console.log('[Scheduled Jobs] Cleaning up old notifications...');
|
||||
const count = await notificationService.cleanupOldNotifications();
|
||||
console.log(`[Scheduled Jobs] Notification cleanup completed: ${count} notifications deleted`);
|
||||
} catch (error: any) {
|
||||
console.error('[Scheduled Jobs] Error in notification cleanup job:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Scheduled Jobs] ✓ Notification cleanup job scheduled (daily at 2 AM)');
|
||||
|
||||
// Return job instances for potential management
|
||||
return {
|
||||
hotspotJob,
|
||||
eventReminderJob,
|
||||
notificationCleanupJob,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,13 +68,27 @@ export function initializeScheduledJobs() {
|
||||
* Stop all scheduled jobs
|
||||
* Useful for graceful shutdown
|
||||
*/
|
||||
export function stopScheduledJobs(jobs: { hotspotJob: ScheduledTask }) {
|
||||
export function stopScheduledJobs(jobs: {
|
||||
hotspotJob: ScheduledTask;
|
||||
eventReminderJob: ScheduledTask;
|
||||
notificationCleanupJob: ScheduledTask;
|
||||
}) {
|
||||
console.log('[Scheduled Jobs] Stopping all scheduled jobs...');
|
||||
|
||||
if (jobs.hotspotJob) {
|
||||
jobs.hotspotJob.stop();
|
||||
console.log('[Scheduled Jobs] ✓ Hotspot calculation job stopped');
|
||||
}
|
||||
|
||||
if (jobs.eventReminderJob) {
|
||||
jobs.eventReminderJob.stop();
|
||||
console.log('[Scheduled Jobs] ✓ Event reminder job stopped');
|
||||
}
|
||||
|
||||
if (jobs.notificationCleanupJob) {
|
||||
jobs.notificationCleanupJob.stop();
|
||||
console.log('[Scheduled Jobs] ✓ Notification cleanup job stopped');
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
Reference in New Issue
Block a user