changemaker.lite/api/src/services/reengagement.service.ts
bunker-admin 0c2ffe754e Harden Stripe payment integration: 15 security fixes from audit
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
2026-03-31 08:34:23 -06:00

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