Add CRM activity enrichment, notification bridging, crash-safe scheduled jobs, and quick wins

Workstream A — CRM & Notifications:
- Add fire-and-forget CRM activity helper (api/src/utils/crm-activity.ts) hooked into
  campaign email, canvass visit, donation, and purchase write sites
- Add 5 operational NotificationType enum values (shift_signup_confirmed, shift_reminder,
  shift_cancelled, canvass_session_summary, reengagement) via Prisma migration
- Bridge notification email queue to in-app notifications for volunteer-facing events
- Extend TYPE_TO_PREF map and NotificationsPage labels for new types

Workstream B — Quick Wins:
- Extract shared role constants (11 roles) to admin/src/utils/role-constants.ts,
  update 4 consuming pages
- Add Ad Analytics sidebar entry in payments submenu
- Gate 6 calendar routes with enableSocialCalendar feature flag
- Add GET /series/:id/count endpoint and fix hardcoded shiftsCount={0} in ShiftsPage
- Add influenceCampaignId to Order model for donation-campaign attribution,
  wire through Stripe checkout metadata

Workstream C — Crash-Safe Scheduled Jobs:
- Create BullMQ scheduled-jobs queue with 10 repeatable job types replacing
  setInterval blocks in server.ts (dynamic imports, concurrency: 2)
- Keep presenceService (1min) and challengeScoringService (5min) as setInterval

Bunker Admin
This commit is contained in:
2026-03-09 14:15:30 -06:00
parent c192c04c79
commit 900a0affe5
24 changed files with 438 additions and 110 deletions

View File

@@ -2,6 +2,8 @@ import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { emailService } from './email.service';
import { prisma } from '../config/database';
import { notificationService } from '../modules/social/notification.service';
// ─── Job Data Types ────────────────────────────────────────────────
@@ -117,6 +119,26 @@ type NotificationJobData =
// ─── Queue Service ─────────────────────────────────────────────────
/** Resolve userId from email for in-app notification bridging */
async function resolveUserId(email: string): Promise<string | null> {
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } });
return user?.id ?? null;
}
/** Fire-and-forget in-app notification creation */
function bridgeToInApp(
email: string,
type: 'shift_signup_confirmed' | 'shift_reminder' | 'shift_cancelled' | 'canvass_session_summary' | 'reengagement',
title: string,
message: string,
metadata?: Record<string, unknown>,
) {
resolveUserId(email).then((userId) => {
if (!userId) return;
notificationService.createNotification(userId, type, title, message, metadata);
}).catch((err) => logger.warn('Failed to bridge in-app notification', err));
}
class NotificationQueueService {
private queue: Queue;
private worker: Worker | null = null;
@@ -155,9 +177,21 @@ class NotificationQueueService {
break;
case 'volunteer-session-summary':
await emailService.sendVolunteerSessionSummary(data);
bridgeToInApp(
data.volunteerEmail, 'canvass_session_summary',
'Canvass Session Complete',
`You visited ${data.visitCount} addresses in ${data.cutName}`,
{ cutName: data.cutName, visitCount: data.visitCount, durationMinutes: data.durationMinutes },
);
break;
case 'volunteer-cancellation':
await emailService.sendVolunteerCancellationAck(data);
bridgeToInApp(
data.volunteerEmail, 'shift_cancelled',
'Shift Cancelled',
`Your shift "${data.shiftTitle}" on ${data.shiftDate} has been cancelled`,
{ shiftTitle: data.shiftTitle, shiftDate: data.shiftDate },
);
break;
case 'volunteer-shift-reminder':
await emailService.sendShiftDetailsEmail({
@@ -173,6 +207,12 @@ class NotificationQueueService {
maxVolunteers: data.maxVolunteers,
shiftStatus: data.shiftStatus,
});
bridgeToInApp(
data.recipientEmail, 'shift_reminder',
'Shift Reminder',
`Reminder: "${data.shiftTitle}" on ${data.shiftDate} at ${data.shiftStartTime}`,
{ shiftTitle: data.shiftTitle, shiftDate: data.shiftDate, shiftLocation: data.shiftLocation },
);
break;
case 'volunteer-shift-thank-you':
await emailService.sendVolunteerShiftThankYou(data);

View File

@@ -0,0 +1,160 @@
import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env';
import { logger } from '../utils/logger';
const QUEUE_NAME = 'scheduled-jobs';
type ScheduledJobType =
| 'reengagement-scan'
| 'social-digest-scan'
| 'close-abandoned-canvass-sessions'
| 'close-stale-tracking-sessions'
| 'cleanup-tracking-data'
| 'cleanup-docs-analytics'
| 'cleanup-verification-tokens'
| 'listmonk-full-sync'
| 'validate-mkdocs-exports'
| 'cleanup-docs-collab-states';
interface ScheduledJobData {
type: ScheduledJobType;
}
const HOUR = 60 * 60 * 1000;
const JOB_DEFINITIONS: Array<{ type: ScheduledJobType; every: number; conditional?: boolean }> = [
{ type: 'reengagement-scan', every: 24 * HOUR },
{ type: 'social-digest-scan', every: 24 * HOUR },
{ type: 'close-abandoned-canvass-sessions', every: HOUR },
{ type: 'close-stale-tracking-sessions', every: HOUR },
{ type: 'cleanup-tracking-data', every: 24 * HOUR },
{ type: 'cleanup-docs-analytics', every: 24 * HOUR },
{ type: 'cleanup-verification-tokens', every: HOUR },
{ type: 'listmonk-full-sync', every: 6 * HOUR, conditional: true },
{ type: 'validate-mkdocs-exports', every: 24 * HOUR },
{ type: 'cleanup-docs-collab-states', every: 24 * HOUR },
];
async function executeJob(type: ScheduledJobType): Promise<void> {
switch (type) {
case 'reengagement-scan': {
const { reengagementService } = await import('./reengagement.service');
await reengagementService.scan();
break;
}
case 'social-digest-scan': {
const { socialDigestService } = await import('./social-digest.service');
await socialDigestService.scan();
break;
}
case 'close-abandoned-canvass-sessions': {
const { canvassService } = await import('../modules/map/canvass/canvass.service');
await canvassService.closeAbandonedSessions();
break;
}
case 'close-stale-tracking-sessions': {
const { trackingService } = await import('../modules/map/tracking/tracking.service');
await trackingService.closeStaleTrackingSessions(120);
break;
}
case 'cleanup-tracking-data': {
const { trackingService } = await import('../modules/map/tracking/tracking.service');
await trackingService.cleanupOldData(30);
break;
}
case 'cleanup-docs-analytics': {
const { docsAnalyticsService } = await import('../modules/docs-analytics/docs-analytics.service');
await docsAnalyticsService.cleanupOldData(90);
break;
}
case 'cleanup-verification-tokens': {
const { verificationTokenService } = await import('./verification-token.service');
const { passwordResetTokenService } = await import('./password-reset-token.service');
await verificationTokenService.cleanupExpiredTokens();
await passwordResetTokenService.cleanupExpiredTokens();
break;
}
case 'listmonk-full-sync': {
const { listmonkSyncService } = await import('./listmonk-sync.service');
await listmonkSyncService.syncAll();
break;
}
case 'validate-mkdocs-exports': {
const { pagesService } = await import('../modules/pages/pages.service');
await pagesService.validateExports();
break;
}
case 'cleanup-docs-collab-states': {
const { docsCollabService } = await import('../modules/docs/docs-collab.service');
await docsCollabService.cleanupStaleStates();
break;
}
}
}
class ScheduledJobsQueueService {
private queue: Queue;
private worker: Worker | null = null;
constructor() {
this.queue = new Queue(QUEUE_NAME, {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
removeOnComplete: { age: 60 * 60, count: 200 },
removeOnFail: { age: 24 * 60 * 60 },
},
});
}
startWorker() {
// Register repeatable jobs
for (const def of JOB_DEFINITIONS) {
// Skip conditional jobs when their feature is disabled
if (def.type === 'listmonk-full-sync' && env.LISTMONK_SYNC_ENABLED !== 'true') {
continue;
}
this.queue.add(
def.type,
{ type: def.type } satisfies ScheduledJobData,
{
repeat: { every: def.every },
jobId: `scheduled-${def.type}`,
}
);
}
this.worker = new Worker(
QUEUE_NAME,
async (job: Job<ScheduledJobData>) => {
const { type } = job.data;
logger.debug(`Scheduled job starting: ${type}`);
await executeJob(type);
},
{
connection: { url: env.REDIS_URL },
concurrency: 2,
}
);
this.worker.on('completed', (job) => {
logger.debug(`Scheduled job ${job.name} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`Scheduled job ${job?.name} failed: ${err.message}`);
});
logger.info('Scheduled jobs queue worker started (10 job types)');
}
async close() {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
logger.info('Scheduled jobs queue closed');
}
}
export const scheduledJobsQueueService = new ScheduledJobsQueueService();