Addresses 11 original findings (1 critical, 3 high, 4 medium, 3 low) plus 4 additional findings from security review: - Mask secrets in PUT /settings response (was leaking decrypted keys) - Add paymentCheckoutRateLimit (10/hr/IP) to all 5 checkout endpoints - Implement durable audit logging to payment_audit_log table - Pin Stripe API version to 2026-01-28.clover (SDK v20.3.1) - Add charge.dispute.created/closed webhook handlers with DISPUTED status - Restore tickets on dispute won, handle charge_refunded closure - Guard against sentinel passthrough corrupting stored Stripe keys - Wrap refund DB updates in try/catch with webhook reconciliation fallback - Add $transaction for product maxPurchases race condition - Remove dead Payment model lookup from handleChargeRefunded - Cap donation amount at $100k in both schemas - Add requirePaymentsEnabled middleware on all checkout routes - Remove Stripe internal IDs from CSV exports - Add Cache-Control: no-store on admin settings responses Bunker Admin
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
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<Array<{
|
|
email: string;
|
|
name: string | null;
|
|
lastActivityDate: Date;
|
|
lastActivityType: string;
|
|
}>> {
|
|
// 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<string, {
|
|
email: string;
|
|
name: string | null;
|
|
lastActivityDate: Date;
|
|
lastActivityType: string;
|
|
}>();
|
|
|
|
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();
|