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:
@@ -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);
|
||||
|
||||
160
api/src/services/scheduled-jobs-queue.service.ts
Normal file
160
api/src/services/scheduled-jobs-queue.service.ts
Normal 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();
|
||||
Reference in New Issue
Block a user