diff --git a/admin/src/App.tsx b/admin/src/App.tsx
index 7fc6b99b..4b5e9482 100644
--- a/admin/src/App.tsx
+++ b/admin/src/App.tsx
@@ -375,10 +375,10 @@ export default function App() {
} />
} />
} />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
} />
@@ -807,7 +807,9 @@ export default function App() {
path="scheduling/calendar-views/:id"
element={
-
+
+
+
}
/>
@@ -815,7 +817,9 @@ export default function App() {
path="scheduling/calendar"
element={
-
+
+
+
}
/>
diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx
index 06a58bad..37176b2e 100644
--- a/admin/src/components/AppLayout.tsx
+++ b/admin/src/components/AppLayout.tsx
@@ -309,6 +309,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/payments/donation-pages', icon: , label: 'Donation Pages' },
{ key: '/app/payments/donations', icon: , label: 'Donation Orders' },
{ key: '/app/payments/ads', icon: , label: 'Gallery Ads' },
+ { key: '/app/payments/ads/analytics', icon: , label: 'Ad Analytics' },
{ key: '/app/payments/settings', icon: , label: 'Settings' },
],
});
diff --git a/admin/src/pages/AdminCalendarPage.tsx b/admin/src/pages/AdminCalendarPage.tsx
index 47c4bf7f..6ef0f186 100644
--- a/admin/src/pages/AdminCalendarPage.tsx
+++ b/admin/src/pages/AdminCalendarPage.tsx
@@ -17,14 +17,7 @@ import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AdminCalendarView } from '@/types/api';
import type { AppOutletContext } from '@/components/AppLayout';
-
-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' },
-];
+import { ROLE_COLORS, ROLE_OPTIONS } from '@/utils/role-constants';
const LAYER_TYPE_OPTIONS = [
{ label: 'Shifts', value: 'SHIFTS' },
@@ -33,14 +26,6 @@ const LAYER_TYPE_OPTIONS = [
{ label: 'Public Events', value: 'PUBLIC_EVENTS' },
];
-const ROLE_COLORS: Record = {
- SUPER_ADMIN: 'red',
- INFLUENCE_ADMIN: 'blue',
- MAP_ADMIN: 'green',
- USER: 'default',
- TEMP: 'orange',
-};
-
export default function AdminCalendarPage() {
const navigate = useNavigate();
const { setPageHeader } = useOutletContext();
diff --git a/admin/src/pages/AdminCalendarViewPage.tsx b/admin/src/pages/AdminCalendarViewPage.tsx
index 5d98caaf..361fca69 100644
--- a/admin/src/pages/AdminCalendarViewPage.tsx
+++ b/admin/src/pages/AdminCalendarViewPage.tsx
@@ -29,17 +29,10 @@ import type {
AdminCalendarUser,
AdminCalendarItem,
} from '@/types/api';
+import { ROLE_COLORS } from '@/utils/role-constants';
const { Title, Text } = Typography;
-const ROLE_COLORS: Record = {
- SUPER_ADMIN: 'red',
- INFLUENCE_ADMIN: 'blue',
- MAP_ADMIN: 'green',
- USER: 'default',
- TEMP: 'orange',
-};
-
export default function AdminCalendarViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
diff --git a/admin/src/pages/SchedulingCalendarPage.tsx b/admin/src/pages/SchedulingCalendarPage.tsx
index 5e128b01..23ad5506 100644
--- a/admin/src/pages/SchedulingCalendarPage.tsx
+++ b/admin/src/pages/SchedulingCalendarPage.tsx
@@ -24,20 +24,13 @@ import UnifiedCalendar from '@/components/calendar/UnifiedCalendar';
import { api } from '@/lib/api';
import type { UnifiedCalendarItem, AdminCalendarView } from '@/types/api';
import { useNavigate } from 'react-router-dom';
+import { ROLE_COLORS, ROLE_OPTIONS } from '@/utils/role-constants';
const { Title, Text } = Typography;
const VIEWS_PANEL_WIDTH = 480;
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 = [
{ label: 'Shifts', value: 'SHIFTS' },
{ label: 'Tickets', value: 'TICKETS' },
@@ -45,14 +38,6 @@ const LAYER_TYPE_OPTIONS = [
{ label: 'Public Events', value: 'PUBLIC_EVENTS' },
];
-const ROLE_COLORS: Record = {
- SUPER_ADMIN: 'red',
- INFLUENCE_ADMIN: 'blue',
- MAP_ADMIN: 'green',
- USER: 'default',
- TEMP: 'orange',
-};
-
export default function SchedulingCalendarPage() {
const navigate = useNavigate();
const addEventRef = useRef<(() => void) | null>(null);
diff --git a/admin/src/pages/ShiftsPage.tsx b/admin/src/pages/ShiftsPage.tsx
index c278622d..c752aa58 100644
--- a/admin/src/pages/ShiftsPage.tsx
+++ b/admin/src/pages/ShiftsPage.tsx
@@ -121,6 +121,7 @@ export default function ShiftsPage() {
const [activeTab, setActiveTab] = useState<'table' | 'calendar'>('table');
const [editModeModalOpen, setEditModeModalOpen] = useState(false);
const [editingSeriesShift, setEditingSeriesShift] = useState(null);
+ const [seriesShiftCount, setSeriesShiftCount] = useState(0);
const [calendarData, setCalendarData] = useState({});
const [calendarLoading, setCalendarLoading] = useState(false);
const [currentMonth] = useState(dayjs());
@@ -355,6 +356,12 @@ export default function ShiftsPage() {
// Part of a series - show edit mode modal
setEditingSeriesShift(shift);
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 {
// Regular shift or exception - edit normally
openEdit(shift);
@@ -1207,7 +1214,7 @@ export default function ShiftsPage() {
}}
onConfirm={handleEditMode}
shiftDate={editingSeriesShift?.date || ''}
- shiftsCount={0} // TODO: fetch series shifts count
+ shiftsCount={seriesShiftCount}
/>
>
);
diff --git a/admin/src/pages/social/SocialGraphPage.tsx b/admin/src/pages/social/SocialGraphPage.tsx
index 0981b1b0..f850ca1c 100644
--- a/admin/src/pages/social/SocialGraphPage.tsx
+++ b/admin/src/pages/social/SocialGraphPage.tsx
@@ -14,25 +14,10 @@ import { ReactFlowProvider } from '@xyflow/react';
import SocialNetworkGraph, { type GraphData } from '@/components/social/SocialNetworkGraph';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/types/api';
+import { ROLE_COLORS, ROLE_FILTER_OPTIONS } from '@/utils/role-constants';
const { Text, Title } = Typography;
-const ROLE_COLORS: Record = {
- 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';
interface SelectedUser {
@@ -145,7 +130,7 @@ function GraphPageInner() {
diff --git a/admin/src/pages/volunteer/NotificationsPage.tsx b/admin/src/pages/volunteer/NotificationsPage.tsx
index bcbfba6a..7965de4d 100644
--- a/admin/src/pages/volunteer/NotificationsPage.tsx
+++ b/admin/src/pages/volunteer/NotificationsPage.tsx
@@ -18,6 +18,11 @@ const TYPE_LABELS: Record = {
upload_rejected: { label: 'Rejected', color: 'red' },
achievement: { label: 'Achievement', color: 'gold' },
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() {
diff --git a/admin/src/utils/role-constants.ts b/admin/src/utils/role-constants.ts
new file mode 100644
index 00000000..b26f24a1
--- /dev/null
+++ b/admin/src/utils/role-constants.ts
@@ -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 = {
+ 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,
+];
diff --git a/api/prisma/migrations/20260309100000_add_notification_types_and_donation_campaign_attribution/migration.sql b/api/prisma/migrations/20260309100000_add_notification_types_and_donation_campaign_attribution/migration.sql
new file mode 100644
index 00000000..caa9ad09
--- /dev/null
+++ b/api/prisma/migrations/20260309100000_add_notification_types_and_donation_campaign_attribution/migration.sql
@@ -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;
diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma
index 534ef4e5..cd206eaf 100644
--- a/api/prisma/schema.prisma
+++ b/api/prisma/schema.prisma
@@ -290,6 +290,7 @@ model Campaign {
smsCampaigns SmsCampaign[] @relation("SmsCampaigns")
stories ImpactStory[] @relation("CampaignStories")
milestones CampaignMilestone[] @relation("CampaignMilestones")
+ donationOrders Order[] @relation("CampaignDonations")
@@index([moderationStatus])
@@index([isUserGenerated])
@@ -1522,6 +1523,12 @@ enum NotificationType {
shared_view_invite
shared_view_accepted
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])
donationPageId String? @map("donation_page_id")
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")
@@index([userId], map: "idx_orders_user")
@@ -3479,6 +3488,7 @@ model Order {
@@index([status], map: "idx_orders_status")
@@index([type], map: "idx_orders_type")
@@index([donationPageId], map: "idx_orders_donation_page")
+ @@index([influenceCampaignId], map: "idx_orders_influence_campaign")
@@map("orders")
}
diff --git a/api/src/modules/influence/campaign-emails/campaign-emails.service.ts b/api/src/modules/influence/campaign-emails/campaign-emails.service.ts
index 01ea31ba..ac24ee12 100644
--- a/api/src/modules/influence/campaign-emails/campaign-emails.service.ts
+++ b/api/src/modules/influence/campaign-emails/campaign-emails.service.ts
@@ -3,6 +3,7 @@ import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { emailQueueService } from '../../../services/email-queue.service';
import { recordCampaignEmail } from '../../../utils/metrics';
+import { recordCrmActivity } from '../../../utils/crm-activity';
import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service';
import type { SendCampaignEmailInput, TrackMailtoInput, ListCampaignEmailsInput } from './campaign-emails.schemas';
@@ -89,6 +90,14 @@ export const campaignEmailsService = {
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)
groupService.syncCampaignTeam(campaign.id).catch(() => {});
diff --git a/api/src/modules/map/canvass/canvass.service.ts b/api/src/modules/map/canvass/canvass.service.ts
index b939f025..b3c3c017 100644
--- a/api/src/modules/map/canvass/canvass.service.ts
+++ b/api/src/modules/map/canvass/canvass.service.ts
@@ -5,6 +5,7 @@ import { AppError } from '../../../middleware/error-handler';
import { logger } from '../../../utils/logger';
import { recordLocationQuery } from '../../../utils/metrics';
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
+import { recordCrmActivity } from '../../../utils/crm-activity';
import { calculateWalkingRoute } from './canvass-route.service';
import { recordCanvassVisit, setActiveCanvassSessions } from '../../../utils/metrics';
import { notificationQueueService } from '../../../services/notification-queue.service';
@@ -653,6 +654,21 @@ export const canvassService = {
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)
achievementsService.checkAndUnlock(userId, ['canvass']).catch(() => {});
diff --git a/api/src/modules/map/shifts/shift-series.routes.ts b/api/src/modules/map/shifts/shift-series.routes.ts
index cefcccff..94f787da 100644
--- a/api/src/modules/map/shifts/shift-series.routes.ts
+++ b/api/src/modules/map/shifts/shift-series.routes.ts
@@ -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
router.get('/:id', async (req, res, next) => {
try {
diff --git a/api/src/modules/map/shifts/shift-series.service.ts b/api/src/modules/map/shifts/shift-series.service.ts
index 5b95c5c1..2092c604 100644
--- a/api/src/modules/map/shifts/shift-series.service.ts
+++ b/api/src/modules/map/shifts/shift-series.service.ts
@@ -117,6 +117,15 @@ export class ShiftSeriesService {
};
}
+ /**
+ * Get count of non-exception shifts in a series
+ */
+ static async getShiftCount(seriesId: string): Promise {
+ return prisma.shift.count({
+ where: { seriesId, isException: false },
+ });
+ }
+
/**
* Get series with all its shifts
*/
diff --git a/api/src/modules/payments/donations.service.ts b/api/src/modules/payments/donations.service.ts
index 180119ec..fd1095f6 100644
--- a/api/src/modules/payments/donations.service.ts
+++ b/api/src/modules/payments/donations.service.ts
@@ -16,6 +16,7 @@ export const donationsService = {
donationPageId?: string,
donationPageSlug?: string,
donationPageTitle?: string,
+ campaignId?: string,
) {
const settings = await paymentSettingsService.get();
if (!settings.enableDonations) throw new Error('Donations are currently disabled');
@@ -55,6 +56,7 @@ export const donationsService = {
message: message || '',
isAnonymous: isAnonymous ? 'true' : 'false',
donationPageId: donationPageId || '',
+ campaignId: campaignId || '',
},
});
@@ -70,6 +72,7 @@ export const donationsService = {
donorMessage: message || null,
isAnonymous: isAnonymous || false,
donationPageId: donationPageId || null,
+ influenceCampaignId: campaignId || null,
},
});
diff --git a/api/src/modules/payments/payments-public.routes.ts b/api/src/modules/payments/payments-public.routes.ts
index 206c0481..6c04128a 100644
--- a/api/src/modules/payments/payments-public.routes.ts
+++ b/api/src/modules/payments/payments-public.routes.ts
@@ -125,13 +125,17 @@ router.post(
validate(createDonationCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
- const { amountCents, email, name, message, isAnonymous } = req.body;
+ const { amountCents, email, name, message, isAnonymous, campaignId } = req.body;
const result = await donationsService.createDonationCheckout(
amountCents,
email,
name,
message,
isAnonymous,
+ undefined, // donationPageId
+ undefined, // donationPageSlug
+ undefined, // donationPageTitle
+ campaignId,
);
res.json(result);
} catch (err) {
diff --git a/api/src/modules/payments/payments.schemas.ts b/api/src/modules/payments/payments.schemas.ts
index a9fa5b62..413df650 100644
--- a/api/src/modules/payments/payments.schemas.ts
+++ b/api/src/modules/payments/payments.schemas.ts
@@ -89,6 +89,7 @@ export const createDonationCheckoutSchema = z.object({
name: z.string().max(200).optional(),
message: z.string().max(2000).optional(),
isAnonymous: z.boolean().optional(),
+ campaignId: z.string().optional(),
});
// --- Refund ---
diff --git a/api/src/modules/payments/webhook.service.ts b/api/src/modules/payments/webhook.service.ts
index a7d5df66..09d952a4 100644
--- a/api/src/modules/payments/webhook.service.ts
+++ b/api/src/modules/payments/webhook.service.ts
@@ -2,6 +2,7 @@ import Stripe from 'stripe';
import { prisma } from '../../config/database';
import { getStripe, getWebhookSecret } from '../../services/stripe.client';
import { logger } from '../../utils/logger';
+import { recordCrmActivity } from '../../utils/crm-activity';
import { paymentEmailService } from './payment-email.service';
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
@@ -214,6 +215,16 @@ export const webhookService = {
orderId: updatedOrder.id,
}).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 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 campaignId = session.metadata?.campaignId || null;
const updateData: Record = {
status: 'COMPLETED',
stripePaymentIntentId: paymentIntentId,
@@ -241,6 +253,9 @@ export const webhookService = {
if (donationPageId && !order.donationPageId) {
updateData.donationPageId = donationPageId;
}
+ if (campaignId && !order.influenceCampaignId) {
+ updateData.influenceCampaignId = campaignId;
+ }
await prisma.order.update({
where: { id: order.id },
@@ -274,6 +289,16 @@ export const webhookService = {
orderId: order.id,
}).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) {
diff --git a/api/src/modules/social/notification.service.ts b/api/src/modules/social/notification.service.ts
index fda5fec6..807b717b 100644
--- a/api/src/modules/social/notification.service.ts
+++ b/api/src/modules/social/notification.service.ts
@@ -19,6 +19,12 @@ const TYPE_TO_PREF: Record = {
shared_view_invite: 'enableFriendRequests',
shared_view_accepted: '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 = {
diff --git a/api/src/server.ts b/api/src/server.ts
index d122710e..cc1550eb 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -62,7 +62,6 @@ import { notificationQueueService } from './services/notification-queue.service'
import { geocodeQueueService } from './services/geocode-queue.service';
import { startProxy, stopProxy } from './services/listmonk-proxy.service';
import { pagesService } from './modules/pages/pages.service';
-import { listmonkSyncService } from './services/listmonk-sync.service';
import { canvassService } from './modules/map/canvass/canvass.service';
import { trackingService } from './modules/map/tracking/tracking.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 { autoUpgradeService } from './services/auto-upgrade.service';
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
+import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service';
import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service';
@@ -324,6 +324,7 @@ async function start() {
notificationQueueService.startWorker();
geocodeQueueService.startWorker();
calendarFeedQueueService.startWorker();
+ scheduledJobsQueueService.startWorker();
startProxy();
// Load SMS config from DB (env fallback for empty fields)
@@ -341,47 +342,15 @@ async function start() {
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(() => {});
passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
- setInterval(() => {
- verificationTokenService.cleanupExpiredTokens().catch(() => {});
- passwordResetTokenService.cleanupExpiredTokens().catch(() => {});
- }, 60 * 60 * 1000);
-
- // Close abandoned canvass sessions on startup + hourly
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(() => {});
- setInterval(() => trackingService.cleanupOldData(30).catch(() => {}), 24 * 60 * 60 * 1000);
-
- // Close stale tracking sessions (no data for 2h) — hourly
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(() => {});
- setInterval(() => docsAnalyticsService.cleanupOldData(90).catch(() => {}), 24 * 60 * 60 * 1000);
-
- // Volunteer re-engagement scanner — daily
reengagementService.scan().catch(() => {});
- setInterval(() => reengagementService.scan().catch(() => {}), 24 * 60 * 60 * 1000);
-
- // Social digest email scanner — daily
socialDigestService.scan().catch(() => {});
- setInterval(() => socialDigestService.scan().catch(() => {}), 24 * 60 * 60 * 1000);
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
presenceService.markAllOffline().catch(() => {});
@@ -438,7 +407,7 @@ async function start() {
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()
.then(({ validated, repaired, errors }) => {
if (repaired > 0 || errors.length > 0) {
@@ -447,13 +416,6 @@ async function start() {
})
.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, () => {
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(() => {});
- setInterval(() => docsCollabService.cleanupStaleStates().catch(() => {}), 24 * 60 * 60 * 1000);
} catch (err) {
logger.error('Failed to start server:', err);
process.exit(1);
@@ -500,6 +461,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
await geocodeQueueService.close();
await smsQueueService.close();
await calendarFeedQueueService.close();
+ await scheduledJobsQueueService.close();
await prisma.$disconnect();
redis.disconnect();
process.exit(0);
diff --git a/api/src/services/notification-queue.service.ts b/api/src/services/notification-queue.service.ts
index d0ad9847..e7a9ec68 100644
--- a/api/src/services/notification-queue.service.ts
+++ b/api/src/services/notification-queue.service.ts
@@ -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 {
+ 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,
+) {
+ 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);
diff --git a/api/src/services/scheduled-jobs-queue.service.ts b/api/src/services/scheduled-jobs-queue.service.ts
new file mode 100644
index 00000000..55d5e661
--- /dev/null
+++ b/api/src/services/scheduled-jobs-queue.service.ts
@@ -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 {
+ 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) => {
+ 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();
diff --git a/api/src/utils/crm-activity.ts b/api/src/utils/crm-activity.ts
new file mode 100644
index 00000000..d0a49b15
--- /dev/null
+++ b/api/src/utils/crm-activity.ts
@@ -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;
+}
+
+/**
+ * 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 {
+ try {
+ let contactId = params.contactId;
+
+ if (!contactId) {
+ const conditions: Record[] = [];
+ 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 } });
+ }
+}