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,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();

View File

@@ -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) => {

View 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;

View 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();

View 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();

View File

@@ -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) {

View 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();

View File

@@ -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;
}

View File

@@ -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 {