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:
bunker-admin 2026-03-09 14:15:30 -06:00
parent c192c04c79
commit 900a0affe5
24 changed files with 438 additions and 110 deletions

View File

@ -375,10 +375,10 @@ export default function App() {
<Route path="/volunteer/challenges" element={<ChallengesPage />} /> <Route path="/volunteer/challenges" element={<ChallengesPage />} />
<Route path="/volunteer/challenges/:id" element={<ChallengeDetailPage />} /> <Route path="/volunteer/challenges/:id" element={<ChallengeDetailPage />} />
<Route path="/volunteer/tickets" element={<MyTicketsPage />} /> <Route path="/volunteer/tickets" element={<MyTicketsPage />} />
<Route path="/volunteer/calendar/shared/:id" element={<SharedCalendarViewPage />} /> <Route path="/volunteer/calendar/shared/:id" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarViewPage /></FeatureGate>} />
<Route path="/volunteer/calendar/shared" element={<SharedCalendarsPage />} /> <Route path="/volunteer/calendar/shared" element={<FeatureGate feature="enableSocialCalendar"><SharedCalendarsPage /></FeatureGate>} />
<Route path="/volunteer/calendar/friend/:userId" element={<FriendCalendarPage />} /> <Route path="/volunteer/calendar/friend/:userId" element={<FeatureGate feature="enableSocialCalendar"><FriendCalendarPage /></FeatureGate>} />
<Route path="/volunteer/calendar" element={<MyCalendarPage />} /> <Route path="/volunteer/calendar" element={<FeatureGate feature="enableSocialCalendar"><MyCalendarPage /></FeatureGate>} />
<Route path="/volunteer/*" element={<NotFoundPage />} /> <Route path="/volunteer/*" element={<NotFoundPage />} />
</Route> </Route>
@ -807,7 +807,9 @@ export default function App() {
path="scheduling/calendar-views/:id" path="scheduling/calendar-views/:id"
element={ element={
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}> <ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
<AdminCalendarViewPage /> <FeatureGate feature="enableSocialCalendar">
<AdminCalendarViewPage />
</FeatureGate>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@ -815,7 +817,9 @@ export default function App() {
path="scheduling/calendar" path="scheduling/calendar"
element={ element={
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}> <ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
<SchedulingCalendarPage /> <FeatureGate feature="enableSocialCalendar">
<SchedulingCalendarPage />
</FeatureGate>
</ProtectedRoute> </ProtectedRoute>
} }
/> />

View File

@ -309,6 +309,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/payments/donation-pages', icon: <HeartOutlined />, label: 'Donation Pages' }, { key: '/app/payments/donation-pages', icon: <HeartOutlined />, label: 'Donation Pages' },
{ key: '/app/payments/donations', icon: <DollarOutlined />, label: 'Donation Orders' }, { key: '/app/payments/donations', icon: <DollarOutlined />, label: 'Donation Orders' },
{ key: '/app/payments/ads', icon: <PictureOutlined />, label: 'Gallery Ads' }, { key: '/app/payments/ads', icon: <PictureOutlined />, label: 'Gallery Ads' },
{ key: '/app/payments/ads/analytics', icon: <BarChartOutlined />, label: 'Ad Analytics' },
{ key: '/app/payments/settings', icon: <SettingOutlined />, label: 'Settings' }, { key: '/app/payments/settings', icon: <SettingOutlined />, label: 'Settings' },
], ],
}); });

View File

@ -17,14 +17,7 @@ import dayjs from 'dayjs';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { AdminCalendarView } from '@/types/api'; import type { AdminCalendarView } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout'; import type { AppOutletContext } from '@/components/AppLayout';
import { ROLE_COLORS, ROLE_OPTIONS } from '@/utils/role-constants';
const ROLE_OPTIONS = [
{ label: 'Super Admin', value: 'SUPER_ADMIN' },
{ label: 'Influence Admin', value: 'INFLUENCE_ADMIN' },
{ label: 'Map Admin', value: 'MAP_ADMIN' },
{ label: 'User', value: 'USER' },
{ label: 'Temp', value: 'TEMP' },
];
const LAYER_TYPE_OPTIONS = [ const LAYER_TYPE_OPTIONS = [
{ label: 'Shifts', value: 'SHIFTS' }, { label: 'Shifts', value: 'SHIFTS' },
@ -33,14 +26,6 @@ const LAYER_TYPE_OPTIONS = [
{ label: 'Public Events', value: 'PUBLIC_EVENTS' }, { label: 'Public Events', value: 'PUBLIC_EVENTS' },
]; ];
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function AdminCalendarPage() { export default function AdminCalendarPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setPageHeader } = useOutletContext<AppOutletContext>(); const { setPageHeader } = useOutletContext<AppOutletContext>();

View File

@ -29,17 +29,10 @@ import type {
AdminCalendarUser, AdminCalendarUser,
AdminCalendarItem, AdminCalendarItem,
} from '@/types/api'; } from '@/types/api';
import { ROLE_COLORS } from '@/utils/role-constants';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function AdminCalendarViewPage() { export default function AdminCalendarViewPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -24,20 +24,13 @@ import UnifiedCalendar from '@/components/calendar/UnifiedCalendar';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { UnifiedCalendarItem, AdminCalendarView } from '@/types/api'; import type { UnifiedCalendarItem, AdminCalendarView } from '@/types/api';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ROLE_COLORS, ROLE_OPTIONS } from '@/utils/role-constants';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const VIEWS_PANEL_WIDTH = 480; const VIEWS_PANEL_WIDTH = 480;
const FORM_PANEL_WIDTH = 380; const FORM_PANEL_WIDTH = 380;
const ROLE_OPTIONS = [
{ label: 'Super Admin', value: 'SUPER_ADMIN' },
{ label: 'Influence Admin', value: 'INFLUENCE_ADMIN' },
{ label: 'Map Admin', value: 'MAP_ADMIN' },
{ label: 'User', value: 'USER' },
{ label: 'Temp', value: 'TEMP' },
];
const LAYER_TYPE_OPTIONS = [ const LAYER_TYPE_OPTIONS = [
{ label: 'Shifts', value: 'SHIFTS' }, { label: 'Shifts', value: 'SHIFTS' },
{ label: 'Tickets', value: 'TICKETS' }, { label: 'Tickets', value: 'TICKETS' },
@ -45,14 +38,6 @@ const LAYER_TYPE_OPTIONS = [
{ label: 'Public Events', value: 'PUBLIC_EVENTS' }, { label: 'Public Events', value: 'PUBLIC_EVENTS' },
]; ];
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function SchedulingCalendarPage() { export default function SchedulingCalendarPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const addEventRef = useRef<(() => void) | null>(null); const addEventRef = useRef<(() => void) | null>(null);

View File

@ -121,6 +121,7 @@ export default function ShiftsPage() {
const [activeTab, setActiveTab] = useState<'table' | 'calendar'>('table'); const [activeTab, setActiveTab] = useState<'table' | 'calendar'>('table');
const [editModeModalOpen, setEditModeModalOpen] = useState(false); const [editModeModalOpen, setEditModeModalOpen] = useState(false);
const [editingSeriesShift, setEditingSeriesShift] = useState<Shift | null>(null); const [editingSeriesShift, setEditingSeriesShift] = useState<Shift | null>(null);
const [seriesShiftCount, setSeriesShiftCount] = useState(0);
const [calendarData, setCalendarData] = useState<CalendarData['dates']>({}); const [calendarData, setCalendarData] = useState<CalendarData['dates']>({});
const [calendarLoading, setCalendarLoading] = useState(false); const [calendarLoading, setCalendarLoading] = useState(false);
const [currentMonth] = useState(dayjs()); const [currentMonth] = useState(dayjs());
@ -355,6 +356,12 @@ export default function ShiftsPage() {
// Part of a series - show edit mode modal // Part of a series - show edit mode modal
setEditingSeriesShift(shift); setEditingSeriesShift(shift);
setEditModeModalOpen(true); setEditModeModalOpen(true);
// Fetch series shift count
if (shift.seriesId) {
api.get(`/api/map/shifts/series/${shift.seriesId}/count`)
.then((res) => setSeriesShiftCount(res.data.count ?? 0))
.catch(() => setSeriesShiftCount(0));
}
} else { } else {
// Regular shift or exception - edit normally // Regular shift or exception - edit normally
openEdit(shift); openEdit(shift);
@ -1207,7 +1214,7 @@ export default function ShiftsPage() {
}} }}
onConfirm={handleEditMode} onConfirm={handleEditMode}
shiftDate={editingSeriesShift?.date || ''} shiftDate={editingSeriesShift?.date || ''}
shiftsCount={0} // TODO: fetch series shifts count shiftsCount={seriesShiftCount}
/> />
</> </>
); );

View File

@ -14,25 +14,10 @@ import { ReactFlowProvider } from '@xyflow/react';
import SocialNetworkGraph, { type GraphData } from '@/components/social/SocialNetworkGraph'; import SocialNetworkGraph, { type GraphData } from '@/components/social/SocialNetworkGraph';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api'; import type { AppOutletContext } from '@/types/api';
import { ROLE_COLORS, ROLE_FILTER_OPTIONS } from '@/utils/role-constants';
const { Text, Title } = Typography; const { Text, Title } = Typography;
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
const ROLE_OPTIONS = [
{ label: 'All Roles', value: '' },
{ label: 'Super Admin', value: 'SUPER_ADMIN' },
{ label: 'Influence Admin', value: 'INFLUENCE_ADMIN' },
{ label: 'Map Admin', value: 'MAP_ADMIN' },
{ label: 'User', value: 'USER' },
];
type LayoutMode = 'force' | 'radial'; type LayoutMode = 'force' | 'radial';
interface SelectedUser { interface SelectedUser {
@ -145,7 +130,7 @@ function GraphPageInner() {
<Select <Select
value={roleFilter} value={roleFilter}
onChange={setRoleFilter} onChange={setRoleFilter}
options={ROLE_OPTIONS} options={ROLE_FILTER_OPTIONS}
style={{ width: 150 }} style={{ width: 150 }}
size="small" size="small"
/> />

View File

@ -18,6 +18,11 @@ const TYPE_LABELS: Record<string, { label: string; color: string }> = {
upload_rejected: { label: 'Rejected', color: 'red' }, upload_rejected: { label: 'Rejected', color: 'red' },
achievement: { label: 'Achievement', color: 'gold' }, achievement: { label: 'Achievement', color: 'gold' },
system: { label: 'System', color: 'default' }, system: { label: 'System', color: 'default' },
shift_signup_confirmed: { label: 'Shift Signup', color: 'geekblue' },
shift_reminder: { label: 'Shift Reminder', color: 'purple' },
shift_cancelled: { label: 'Shift Cancelled', color: 'red' },
canvass_session_summary: { label: 'Canvass Summary', color: 'volcano' },
reengagement: { label: 'We Miss You', color: 'magenta' },
}; };
export default function NotificationsPage() { export default function NotificationsPage() {

View File

@ -0,0 +1,37 @@
import type { UserRole } from '@/types/api';
/** Tag color for each role (Ant Design Tag color prop values) */
export const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
BROADCAST_ADMIN: 'cyan',
CONTENT_ADMIN: 'geekblue',
MEDIA_ADMIN: 'purple',
PAYMENTS_ADMIN: 'gold',
EVENTS_ADMIN: 'magenta',
SOCIAL_ADMIN: 'volcano',
USER: 'default',
TEMP: 'orange',
};
/** Role options for Select components (no "All" entry) */
export const ROLE_OPTIONS: { label: string; value: UserRole }[] = [
{ label: 'Super Admin', value: 'SUPER_ADMIN' },
{ label: 'Influence Admin', value: 'INFLUENCE_ADMIN' },
{ label: 'Map Admin', value: 'MAP_ADMIN' },
{ label: 'Broadcast Admin', value: 'BROADCAST_ADMIN' },
{ label: 'Content Admin', value: 'CONTENT_ADMIN' },
{ label: 'Media Admin', value: 'MEDIA_ADMIN' },
{ label: 'Payments Admin', value: 'PAYMENTS_ADMIN' },
{ label: 'Events Admin', value: 'EVENTS_ADMIN' },
{ label: 'Social Admin', value: 'SOCIAL_ADMIN' },
{ label: 'User', value: 'USER' },
{ label: 'Temp', value: 'TEMP' },
];
/** Role options with a leading "All Roles" entry for filter dropdowns */
export const ROLE_FILTER_OPTIONS: { label: string; value: string }[] = [
{ label: 'All Roles', value: '' },
...ROLE_OPTIONS,
];

View File

@ -0,0 +1,16 @@
-- AlterEnum
-- Add operational notification types for shift/canvass/reengagement notifications
ALTER TYPE "NotificationType" ADD VALUE 'shift_signup_confirmed';
ALTER TYPE "NotificationType" ADD VALUE 'shift_reminder';
ALTER TYPE "NotificationType" ADD VALUE 'shift_cancelled';
ALTER TYPE "NotificationType" ADD VALUE 'canvass_session_summary';
ALTER TYPE "NotificationType" ADD VALUE 'reengagement';
-- AlterTable: Add campaign attribution to donation orders
ALTER TABLE "orders" ADD COLUMN "influence_campaign_id" TEXT;
-- CreateIndex
CREATE INDEX "idx_orders_influence_campaign" ON "orders"("influence_campaign_id");
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_influence_campaign_id_fkey" FOREIGN KEY ("influence_campaign_id") REFERENCES "campaigns"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -290,6 +290,7 @@ model Campaign {
smsCampaigns SmsCampaign[] @relation("SmsCampaigns") smsCampaigns SmsCampaign[] @relation("SmsCampaigns")
stories ImpactStory[] @relation("CampaignStories") stories ImpactStory[] @relation("CampaignStories")
milestones CampaignMilestone[] @relation("CampaignMilestones") milestones CampaignMilestone[] @relation("CampaignMilestones")
donationOrders Order[] @relation("CampaignDonations")
@@index([moderationStatus]) @@index([moderationStatus])
@@index([isUserGenerated]) @@index([isUserGenerated])
@ -1522,6 +1523,12 @@ enum NotificationType {
shared_view_invite shared_view_invite
shared_view_accepted shared_view_accepted
calendar_event_invite calendar_event_invite
// Operational notification types
shift_signup_confirmed
shift_reminder
shift_cancelled
canvass_session_summary
reengagement
} }
// ============================================================================ // ============================================================================
@ -3472,6 +3479,8 @@ model Order {
product Product? @relation(fields: [productId], references: [id]) product Product? @relation(fields: [productId], references: [id])
donationPageId String? @map("donation_page_id") donationPageId String? @map("donation_page_id")
donationPage DonationPage? @relation("DonationPageOrders", fields: [donationPageId], references: [id], onDelete: SetNull) donationPage DonationPage? @relation("DonationPageOrders", fields: [donationPageId], references: [id], onDelete: SetNull)
influenceCampaignId String? @map("influence_campaign_id")
influenceCampaign Campaign? @relation("CampaignDonations", fields: [influenceCampaignId], references: [id], onDelete: SetNull)
tickets Ticket[] @relation("TicketOrder") tickets Ticket[] @relation("TicketOrder")
@@index([userId], map: "idx_orders_user") @@index([userId], map: "idx_orders_user")
@ -3479,6 +3488,7 @@ model Order {
@@index([status], map: "idx_orders_status") @@index([status], map: "idx_orders_status")
@@index([type], map: "idx_orders_type") @@index([type], map: "idx_orders_type")
@@index([donationPageId], map: "idx_orders_donation_page") @@index([donationPageId], map: "idx_orders_donation_page")
@@index([influenceCampaignId], map: "idx_orders_influence_campaign")
@@map("orders") @@map("orders")
} }

View File

@ -3,6 +3,7 @@ import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler'; import { AppError } from '../../../middleware/error-handler';
import { emailQueueService } from '../../../services/email-queue.service'; import { emailQueueService } from '../../../services/email-queue.service';
import { recordCampaignEmail } from '../../../utils/metrics'; import { recordCampaignEmail } from '../../../utils/metrics';
import { recordCrmActivity } from '../../../utils/crm-activity';
import { groupService } from '../../social/group.service'; import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service'; import { achievementsService } from '../../social/achievements.service';
import type { SendCampaignEmailInput, TrackMailtoInput, ListCampaignEmailsInput } from './campaign-emails.schemas'; import type { SendCampaignEmailInput, TrackMailtoInput, ListCampaignEmailsInput } from './campaign-emails.schemas';
@ -89,6 +90,14 @@ export const campaignEmailsService = {
recordCampaignEmail(campaign.id); recordCampaignEmail(campaign.id);
// CRM activity (fire-and-forget)
recordCrmActivity({
email: data.userEmail,
activityType: 'EMAIL_SENT',
title: `Sent campaign email: ${campaign.title}`,
metadata: { campaignId: campaign.id, campaignSlug: campaign.slug, recipientEmail: data.recipientEmail, emailMethod: data.emailMethod },
}).catch(() => {});
// Social group sync (fire-and-forget) // Social group sync (fire-and-forget)
groupService.syncCampaignTeam(campaign.id).catch(() => {}); groupService.syncCampaignTeam(campaign.id).catch(() => {});

View File

@ -5,6 +5,7 @@ import { AppError } from '../../../middleware/error-handler';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { recordLocationQuery } from '../../../utils/metrics'; import { recordLocationQuery } from '../../../utils/metrics';
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial'; import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
import { recordCrmActivity } from '../../../utils/crm-activity';
import { calculateWalkingRoute } from './canvass-route.service'; import { calculateWalkingRoute } from './canvass-route.service';
import { recordCanvassVisit, setActiveCanvassSessions } from '../../../utils/metrics'; import { recordCanvassVisit, setActiveCanvassSessions } from '../../../utils/metrics';
import { notificationQueueService } from '../../../services/notification-queue.service'; import { notificationQueueService } from '../../../services/notification-queue.service';
@ -653,6 +654,21 @@ export const canvassService = {
recordCanvassVisit(data.outcome); recordCanvassVisit(data.outcome);
// CRM activity via ContactAddress lookup (fire-and-forget)
prisma.contactAddress.findFirst({
where: { addressId: data.addressId },
select: { contactId: true },
}).then((ca) => {
if (ca) {
recordCrmActivity({
contactId: ca.contactId,
activityType: 'CANVASS_VISIT',
title: `Canvass visit: ${data.outcome}`,
metadata: { addressId: data.addressId, outcome: data.outcome, visitId: visit.id },
}).catch(() => {});
}
}).catch(() => {});
// Achievement check (fire-and-forget) // Achievement check (fire-and-forget)
achievementsService.checkAndUnlock(userId, ['canvass']).catch(() => {}); achievementsService.checkAndUnlock(userId, ['canvass']).catch(() => {});

View File

@ -28,6 +28,16 @@ router.post(
} }
); );
// Get series shift count
router.get('/:id/count', async (req, res, next) => {
try {
const count = await ShiftSeriesService.getShiftCount(req.params.id as string);
res.json({ count });
} catch (error) {
next(error);
}
});
// Get series // Get series
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
try { try {

View File

@ -117,6 +117,15 @@ export class ShiftSeriesService {
}; };
} }
/**
* Get count of non-exception shifts in a series
*/
static async getShiftCount(seriesId: string): Promise<number> {
return prisma.shift.count({
where: { seriesId, isException: false },
});
}
/** /**
* Get series with all its shifts * Get series with all its shifts
*/ */

View File

@ -16,6 +16,7 @@ export const donationsService = {
donationPageId?: string, donationPageId?: string,
donationPageSlug?: string, donationPageSlug?: string,
donationPageTitle?: string, donationPageTitle?: string,
campaignId?: string,
) { ) {
const settings = await paymentSettingsService.get(); const settings = await paymentSettingsService.get();
if (!settings.enableDonations) throw new Error('Donations are currently disabled'); if (!settings.enableDonations) throw new Error('Donations are currently disabled');
@ -55,6 +56,7 @@ export const donationsService = {
message: message || '', message: message || '',
isAnonymous: isAnonymous ? 'true' : 'false', isAnonymous: isAnonymous ? 'true' : 'false',
donationPageId: donationPageId || '', donationPageId: donationPageId || '',
campaignId: campaignId || '',
}, },
}); });
@ -70,6 +72,7 @@ export const donationsService = {
donorMessage: message || null, donorMessage: message || null,
isAnonymous: isAnonymous || false, isAnonymous: isAnonymous || false,
donationPageId: donationPageId || null, donationPageId: donationPageId || null,
influenceCampaignId: campaignId || null,
}, },
}); });

View File

@ -125,13 +125,17 @@ router.post(
validate(createDonationCheckoutSchema), validate(createDonationCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { amountCents, email, name, message, isAnonymous } = req.body; const { amountCents, email, name, message, isAnonymous, campaignId } = req.body;
const result = await donationsService.createDonationCheckout( const result = await donationsService.createDonationCheckout(
amountCents, amountCents,
email, email,
name, name,
message, message,
isAnonymous, isAnonymous,
undefined, // donationPageId
undefined, // donationPageSlug
undefined, // donationPageTitle
campaignId,
); );
res.json(result); res.json(result);
} catch (err) { } catch (err) {

View File

@ -89,6 +89,7 @@ export const createDonationCheckoutSchema = z.object({
name: z.string().max(200).optional(), name: z.string().max(200).optional(),
message: z.string().max(2000).optional(), message: z.string().max(2000).optional(),
isAnonymous: z.boolean().optional(), isAnonymous: z.boolean().optional(),
campaignId: z.string().optional(),
}); });
// --- Refund --- // --- Refund ---

View File

@ -2,6 +2,7 @@ import Stripe from 'stripe';
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import { getStripe, getWebhookSecret } from '../../services/stripe.client'; import { getStripe, getWebhookSecret } from '../../services/stripe.client';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { recordCrmActivity } from '../../utils/crm-activity';
import { paymentEmailService } from './payment-email.service'; import { paymentEmailService } from './payment-email.service';
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service'; import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
@ -214,6 +215,16 @@ export const webhookService = {
orderId: updatedOrder.id, orderId: updatedOrder.id,
}).catch(() => {}); }).catch(() => {});
} }
// CRM activity (fire-and-forget)
if (updatedOrder.buyerEmail) {
recordCrmActivity({
email: updatedOrder.buyerEmail,
activityType: 'PURCHASE',
title: `Purchased: ${updatedOrder.product?.title || 'Product'}`,
metadata: { orderId: updatedOrder.id, productId: updatedOrder.product ? order.productId : null, amountCents: updatedOrder.amountCAD },
}).catch(() => {});
}
} }
}, },
@ -231,8 +242,9 @@ export const webhookService = {
? session.payment_intent ? session.payment_intent
: (session.payment_intent as { id: string } | null)?.id || null; : (session.payment_intent as { id: string } | null)?.id || null;
// Link to donation page if metadata contains donationPageId (from page-specific checkout) // Link to donation page and/or campaign if metadata contains them
const donationPageId = session.metadata?.donationPageId || null; const donationPageId = session.metadata?.donationPageId || null;
const campaignId = session.metadata?.campaignId || null;
const updateData: Record<string, unknown> = { const updateData: Record<string, unknown> = {
status: 'COMPLETED', status: 'COMPLETED',
stripePaymentIntentId: paymentIntentId, stripePaymentIntentId: paymentIntentId,
@ -241,6 +253,9 @@ export const webhookService = {
if (donationPageId && !order.donationPageId) { if (donationPageId && !order.donationPageId) {
updateData.donationPageId = donationPageId; updateData.donationPageId = donationPageId;
} }
if (campaignId && !order.influenceCampaignId) {
updateData.influenceCampaignId = campaignId;
}
await prisma.order.update({ await prisma.order.update({
where: { id: order.id }, where: { id: order.id },
@ -274,6 +289,16 @@ export const webhookService = {
orderId: order.id, orderId: order.id,
}).catch(() => {}); }).catch(() => {});
} }
// CRM activity (fire-and-forget)
if (order.buyerEmail) {
recordCrmActivity({
email: order.buyerEmail,
activityType: 'DONATION',
title: `Donation: $${(order.amountCAD / 100).toFixed(2)}`,
metadata: { orderId: order.id, amountCents: order.amountCAD },
}).catch(() => {});
}
}, },
async handleInvoicePaid(invoice: Stripe.Invoice) { async handleInvoicePaid(invoice: Stripe.Invoice) {

View File

@ -19,6 +19,12 @@ const TYPE_TO_PREF: Record<string, string> = {
shared_view_invite: 'enableFriendRequests', shared_view_invite: 'enableFriendRequests',
shared_view_accepted: 'enableFriendRequests', shared_view_accepted: 'enableFriendRequests',
calendar_event_invite: 'enableFriendRequests', calendar_event_invite: 'enableFriendRequests',
// Operational notification types
shift_signup_confirmed: 'enableSystemUpdates',
shift_reminder: 'enableSystemUpdates',
shift_cancelled: 'enableSystemUpdates',
canvass_session_summary: 'enableSystemUpdates',
reengagement: 'enableSystemUpdates',
}; };
export const notificationService = { export const notificationService = {

View File

@ -62,7 +62,6 @@ import { notificationQueueService } from './services/notification-queue.service'
import { geocodeQueueService } from './services/geocode-queue.service'; import { geocodeQueueService } from './services/geocode-queue.service';
import { startProxy, stopProxy } from './services/listmonk-proxy.service'; import { startProxy, stopProxy } from './services/listmonk-proxy.service';
import { pagesService } from './modules/pages/pages.service'; import { pagesService } from './modules/pages/pages.service';
import { listmonkSyncService } from './services/listmonk-sync.service';
import { canvassService } from './modules/map/canvass/canvass.service'; import { canvassService } from './modules/map/canvass/canvass.service';
import { trackingService } from './modules/map/tracking/tracking.service'; import { trackingService } from './modules/map/tracking/tracking.service';
import { verificationTokenService } from './services/verification-token.service'; import { verificationTokenService } from './services/verification-token.service';
@ -115,6 +114,7 @@ import { presenceService } from './modules/social/presence.service';
import { upgradeService } from './modules/upgrade/upgrade.service'; import { upgradeService } from './modules/upgrade/upgrade.service';
import { autoUpgradeService } from './services/auto-upgrade.service'; import { autoUpgradeService } from './services/auto-upgrade.service';
import { calendarFeedQueueService } from './services/calendar-feed-queue.service'; import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service'; import { docsCollabService } from './modules/docs/docs-collab.service';
@ -324,6 +324,7 @@ async function start() {
notificationQueueService.startWorker(); notificationQueueService.startWorker();
geocodeQueueService.startWorker(); geocodeQueueService.startWorker();
calendarFeedQueueService.startWorker(); calendarFeedQueueService.startWorker();
scheduledJobsQueueService.startWorker();
startProxy(); startProxy();
// Load SMS config from DB (env fallback for empty fields) // Load SMS config from DB (env fallback for empty fields)
@ -341,47 +342,15 @@ async function start() {
logger.info('SMS integration enabled (Termux API)'); logger.info('SMS integration enabled (Termux API)');
} }
// Clean expired verification/reset tokens on startup + hourly // One-time startup calls (recurring runs handled by scheduled-jobs queue)
verificationTokenService.cleanupExpiredTokens().catch(() => {}); verificationTokenService.cleanupExpiredTokens().catch(() => {});
passwordResetTokenService.cleanupExpiredTokens().catch(() => {}); passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
setInterval(() => {
verificationTokenService.cleanupExpiredTokens().catch(() => {});
passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
}, 60 * 60 * 1000);
// Close abandoned canvass sessions on startup + hourly
canvassService.closeAbandonedSessions().catch(() => {}); canvassService.closeAbandonedSessions().catch(() => {});
setInterval(() => {
canvassService.closeAbandonedSessions().catch(() => {});
}, 60 * 60 * 1000);
// Listmonk scheduled full sync (every 6h)
if (env.LISTMONK_SYNC_ENABLED === 'true') {
setInterval(() => {
listmonkSyncService.syncAll().catch(() => {});
}, 6 * 60 * 60 * 1000);
logger.info('Listmonk scheduled full sync enabled (every 6h)');
}
// Clean old tracking data on startup + daily
trackingService.cleanupOldData(30).catch(() => {}); trackingService.cleanupOldData(30).catch(() => {});
setInterval(() => trackingService.cleanupOldData(30).catch(() => {}), 24 * 60 * 60 * 1000);
// Close stale tracking sessions (no data for 2h) — hourly
trackingService.closeStaleTrackingSessions(120).catch(() => {}); trackingService.closeStaleTrackingSessions(120).catch(() => {});
setInterval(() => trackingService.closeStaleTrackingSessions(120).catch(() => {}), 60 * 60 * 1000);
// Clean old docs analytics data on startup + daily (90-day retention)
docsAnalyticsService.cleanupOldData(90).catch(() => {}); docsAnalyticsService.cleanupOldData(90).catch(() => {});
setInterval(() => docsAnalyticsService.cleanupOldData(90).catch(() => {}), 24 * 60 * 60 * 1000);
// Volunteer re-engagement scanner — daily
reengagementService.scan().catch(() => {}); reengagementService.scan().catch(() => {});
setInterval(() => reengagementService.scan().catch(() => {}), 24 * 60 * 60 * 1000);
// Social digest email scanner — daily
socialDigestService.scan().catch(() => {}); socialDigestService.scan().catch(() => {});
setInterval(() => socialDigestService.scan().catch(() => {}), 24 * 60 * 60 * 1000);
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup // SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
presenceService.markAllOffline().catch(() => {}); presenceService.markAllOffline().catch(() => {});
@ -438,7 +407,7 @@ async function start() {
logger.warn('Startup sync of MkDocs overrides failed:', err); logger.warn('Startup sync of MkDocs overrides failed:', err);
}); });
// Validate MkDocs exports on startup // Validate MkDocs exports on startup (recurring runs handled by scheduled-jobs queue)
pagesService.validateExports() pagesService.validateExports()
.then(({ validated, repaired, errors }) => { .then(({ validated, repaired, errors }) => {
if (repaired > 0 || errors.length > 0) { if (repaired > 0 || errors.length > 0) {
@ -447,13 +416,6 @@ async function start() {
}) })
.catch((err) => logger.warn('Validation failed:', err)); .catch((err) => logger.warn('Validation failed:', err));
// Schedule daily validation
setInterval(() => {
pagesService.validateExports().catch((err) => {
logger.warn('Scheduled validation failed:', err);
});
}, 24 * 60 * 60 * 1000);
const server = app.listen(env.PORT, () => { const server = app.listen(env.PORT, () => {
logger.info(`API server running on port ${env.PORT} [${env.NODE_ENV}]`); logger.info(`API server running on port ${env.PORT} [${env.NODE_ENV}]`);
}); });
@ -477,9 +439,8 @@ async function start() {
}); });
}); });
// Clean stale collab states on startup + daily // Clean stale collab states on startup (recurring runs handled by scheduled-jobs queue)
docsCollabService.cleanupStaleStates().catch(() => {}); docsCollabService.cleanupStaleStates().catch(() => {});
setInterval(() => docsCollabService.cleanupStaleStates().catch(() => {}), 24 * 60 * 60 * 1000);
} catch (err) { } catch (err) {
logger.error('Failed to start server:', err); logger.error('Failed to start server:', err);
process.exit(1); process.exit(1);
@ -500,6 +461,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
await geocodeQueueService.close(); await geocodeQueueService.close();
await smsQueueService.close(); await smsQueueService.close();
await calendarFeedQueueService.close(); await calendarFeedQueueService.close();
await scheduledJobsQueueService.close();
await prisma.$disconnect(); await prisma.$disconnect();
redis.disconnect(); redis.disconnect();
process.exit(0); process.exit(0);

View File

@ -2,6 +2,8 @@ import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env'; import { env } from '../config/env';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { emailService } from './email.service'; import { emailService } from './email.service';
import { prisma } from '../config/database';
import { notificationService } from '../modules/social/notification.service';
// ─── Job Data Types ──────────────────────────────────────────────── // ─── Job Data Types ────────────────────────────────────────────────
@ -117,6 +119,26 @@ type NotificationJobData =
// ─── Queue Service ───────────────────────────────────────────────── // ─── 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 { class NotificationQueueService {
private queue: Queue; private queue: Queue;
private worker: Worker | null = null; private worker: Worker | null = null;
@ -155,9 +177,21 @@ class NotificationQueueService {
break; break;
case 'volunteer-session-summary': case 'volunteer-session-summary':
await emailService.sendVolunteerSessionSummary(data); 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; break;
case 'volunteer-cancellation': case 'volunteer-cancellation':
await emailService.sendVolunteerCancellationAck(data); 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; break;
case 'volunteer-shift-reminder': case 'volunteer-shift-reminder':
await emailService.sendShiftDetailsEmail({ await emailService.sendShiftDetailsEmail({
@ -173,6 +207,12 @@ class NotificationQueueService {
maxVolunteers: data.maxVolunteers, maxVolunteers: data.maxVolunteers,
shiftStatus: data.shiftStatus, 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; break;
case 'volunteer-shift-thank-you': case 'volunteer-shift-thank-you':
await emailService.sendVolunteerShiftThankYou(data); 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();

View File

@ -0,0 +1,55 @@
import { ContactActivityType } from '@prisma/client';
import { prisma } from '../config/database';
import { logger } from './logger';
interface RecordActivityParams {
userId?: string;
email?: string;
contactId?: string;
activityType: ContactActivityType;
title: string;
description?: string;
metadata?: Record<string, unknown>;
}
/**
* Fire-and-forget CRM activity recorder.
* Resolves a Contact by userId or email, then writes a ContactActivity row.
* Skips silently if no matching Contact is found (anonymous users).
*/
export async function recordCrmActivity(params: RecordActivityParams): Promise<void> {
try {
let contactId = params.contactId;
if (!contactId) {
const conditions: Record<string, unknown>[] = [];
if (params.userId) conditions.push({ userId: params.userId });
if (params.email) conditions.push({ email: params.email });
if (conditions.length === 0) return;
const contact = await prisma.contact.findFirst({
where: {
mergedIntoId: null,
OR: conditions,
},
select: { id: true },
});
if (!contact) return;
contactId = contact.id;
}
await prisma.contactActivity.create({
data: {
contactId,
type: params.activityType,
title: params.title,
description: params.description,
metadata: params.metadata as unknown as import('@prisma/client').Prisma.InputJsonValue,
},
});
} catch (err) {
logger.error('Failed to record CRM activity', { error: err instanceof Error ? err.message : String(err), params: { activityType: params.activityType } });
}
}