import Redis from 'ioredis'; import { prisma } from '../config/database'; import { env } from '../config/env'; import { logger } from '../utils/logger'; import { siteSettingsService } from '../modules/settings/settings.service'; import { notificationQueueService } from './notification-queue.service'; import { eventBus } from './event-bus.service'; /** * Volunteer Re-Engagement Scanner * * Queries volunteers whose last activity (ShiftSignup or CanvassSession) * exceeds the configured inactivity threshold. Sends a re-engagement email * with a Redis-based cooldown to prevent spamming. */ class ReengagementService { private redis: Redis | null = null; private getRedis(): Redis { if (!this.redis) { this.redis = new Redis(env.REDIS_URL); } return this.redis; } /** * Run the re-engagement scan. Called daily from server.ts. */ async scan(): Promise<{ scanned: number; sent: number; skipped: number }> { const settings = await siteSettingsService.get(); if (!settings.notifyVolunteerReengagement) { return { scanned: 0, sent: 0, skipped: 0 }; } const inactiveDays = settings.reengagementInactiveDays || 30; const cooldownDays = settings.reengagementCooldownDays || 30; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - inactiveDays); // Find volunteers who have past shift signups or canvass sessions // but whose LAST activity is before the cutoff date. const volunteers = await this.findInactiveVolunteers(cutoffDate); let sent = 0; let skipped = 0; const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`; for (const volunteer of volunteers) { try { // Check Redis cooldown const cooldownKey = `reengagement:${volunteer.email}`; const redis = this.getRedis(); const exists = await redis.exists(cooldownKey); if (exists) { skipped++; continue; } // Enqueue the re-engagement notification await notificationQueueService.enqueue({ type: 'volunteer-reengagement', volunteerEmail: volunteer.email, volunteerName: volunteer.name || volunteer.email, lastActivityDate: volunteer.lastActivityDate.toLocaleDateString('en-CA', { year: 'numeric', month: 'long', day: 'numeric', }), lastActivityType: volunteer.lastActivityType, signupUrl, }); // Set cooldown in Redis (expires after cooldownDays) const cooldownSeconds = cooldownDays * 24 * 60 * 60; await redis.set(cooldownKey, '', 'EX', cooldownSeconds); // Publish re-engagement event eventBus.publish('reengagement.sent', { email: volunteer.email, name: volunteer.name || volunteer.email, }); sent++; } catch (err) { logger.error(`Re-engagement failed for ${volunteer.email}:`, err); skipped++; } } if (sent > 0 || skipped > 0) { logger.info(`Re-engagement scan: ${volunteers.length} inactive volunteers found, ${sent} emails queued, ${skipped} skipped (cooldown/error)`); } return { scanned: volunteers.length, sent, skipped }; } /** * Find volunteers whose last activity is before the cutoff date. * Activity = ShiftSignup (confirmed) OR CanvassSession (completed). */ private async findInactiveVolunteers(cutoffDate: Date): Promise> { // Get users who have at least one signup or canvass session // and whose MOST RECENT activity is before the cutoff. // We use two separate queries and merge results. // 1. Users with shift signups (last signup before cutoff) const shiftVolunteers = await prisma.shiftSignup.groupBy({ by: ['userEmail', 'userName'], _max: { signupDate: true }, where: { status: 'CONFIRMED' }, having: { signupDate: { _max: { lt: cutoffDate } }, }, }); // 2. Users with canvass sessions (last session before cutoff) const canvassVolunteers = await prisma.canvassSession.groupBy({ by: ['userId'], _max: { startedAt: true }, where: { status: 'COMPLETED' }, having: { startedAt: { _max: { lt: cutoffDate } }, }, }); // Look up user details for canvass volunteers const canvassUserIds = canvassVolunteers.map((c) => c.userId); const canvassUsers = canvassUserIds.length > 0 ? await prisma.user.findMany({ where: { id: { in: canvassUserIds } }, select: { id: true, email: true, name: true }, }) : []; const canvassUserMap = new Map(canvassUsers.map((u) => [u.id, u])); // Merge into a single list, taking the most recent activity per email const volunteerMap = new Map(); for (const sv of shiftVolunteers) { const date = sv._max.signupDate; if (!date) continue; const email = sv.userEmail; const existing = volunteerMap.get(email); if (!existing || date > existing.lastActivityDate) { volunteerMap.set(email, { email, name: sv.userName, lastActivityDate: date, lastActivityType: 'Shift Signup', }); } } for (const cv of canvassVolunteers) { const date = cv._max?.startedAt; if (!date) continue; const user = canvassUserMap.get(cv.userId); if (!user) continue; const existing = volunteerMap.get(user.email); if (!existing || date > existing.lastActivityDate) { volunteerMap.set(user.email, { email: user.email, name: user.name, lastActivityDate: date, lastActivityType: 'Canvass Session', }); } } // Filter out entries where the merged last activity is actually after cutoff // (e.g., shift signup was old but canvass session was recent) return Array.from(volunteerMap.values()).filter( (v) => v.lastActivityDate < cutoffDate, ); } async close() { if (this.redis) { this.redis.disconnect(); this.redis = null; } } } export const reengagementService = new ReengagementService();