import { env } from '../config/env'; import { logger } from '../utils/logger'; import { listmonkClient } from './listmonk.client'; import { listmonkSyncService, SUPPORT_LEVEL_LIST_MAP } from './listmonk-sync.service'; /** * Event-driven Listmonk sync — fire-and-forget subscriber upserts * triggered by application events (shift signups, canvass completions, campaign emails). * * All methods silently fail if LISTMONK_SYNC_ENABLED is false or Listmonk is unreachable. */ class ListmonkEventSyncService { private _lastSyncAt: Date | null = null; private _todaySyncCount = 0; private _todayDate = ''; private get enabled(): boolean { return env.LISTMONK_SYNC_ENABLED === 'true'; } private incrementCounter(): void { const today = new Date().toISOString().split('T')[0]; if (today !== this._todayDate) { this._todayDate = today; this._todaySyncCount = 0; } this._todaySyncCount++; this._lastSyncAt = new Date(); } /** * Sync a shift signup to Listmonk "All Contacts" + "Volunteers" lists. */ async onShiftSignup(data: { email: string; name: string; shiftTitle: string; shiftDate: string; cutName?: string; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const volunteersId = listmonkSyncService.getListId('Volunteers'); if (!allContactsId || !volunteersId) return; await listmonkClient.upsertSubscriber( data.email, data.name, [allContactsId, volunteersId], { source: 'shift_signup', last_shift_title: data.shiftTitle, last_shift_date: data.shiftDate, cut_name: data.cutName || null, last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: shift signup for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onShiftSignup):', err); } } /** * Sync a completed canvass session to Listmonk "All Contacts" + "Canvassers" lists. */ async onCanvassSessionCompleted(data: { email: string; name: string; cutName: string; visitCount: number; outcomes: Record; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const canvassersId = listmonkSyncService.getListId('Canvassers'); if (!allContactsId || !canvassersId) return; await listmonkClient.upsertSubscriber( data.email, data.name, [allContactsId, canvassersId], { source: 'canvasser', last_cut: data.cutName, last_visit_count: data.visitCount, last_outcomes: data.outcomes, last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: canvass session for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onCanvassSessionCompleted):', err); } } /** * Sync a sent campaign email to Listmonk "All Contacts" + "Campaign Participants" lists. */ async onCampaignEmailSent(data: { email: string; name: string; campaignSlug: string; postalCode?: string; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const participantsId = listmonkSyncService.getListId('Campaign Participants'); if (!allContactsId || !participantsId) return; await listmonkClient.upsertSubscriber( data.email, data.name, [allContactsId, participantsId], { source: 'campaign_participant', campaign_slug: data.campaignSlug, postal_code: data.postalCode || null, last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: campaign email for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onCampaignEmailSent):', err); } } /** * Sync an activated subscription to Listmonk "All Contacts" + "Subscribers" lists. */ async onSubscriptionActivated(data: { email: string; name: string; planName: string; subscriptionId: string; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const subscribersId = listmonkSyncService.getListId('Subscribers'); if (!allContactsId || !subscribersId) return; await listmonkClient.upsertSubscriber( data.email, data.name, [allContactsId, subscribersId], { source: 'subscription', plan_name: data.planName, subscription_id: data.subscriptionId, last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: subscription activated for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onSubscriptionActivated):', err); } } /** * Sync a completed donation to Listmonk "All Contacts" + "Donors" lists. */ async onDonationCompleted(data: { email: string; name: string; amountCents: number; orderId: string; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const donorsId = listmonkSyncService.getListId('Donors'); if (!allContactsId || !donorsId) return; await listmonkClient.upsertSubscriber( data.email, data.name, [allContactsId, donorsId], { source: 'donation', last_donation_amount: data.amountCents, last_order_id: data.orderId, last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: donation completed for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onDonationCompleted):', err); } } /** * Sync a product purchase to Listmonk "All Contacts" + "Donors" lists. */ async onProductPurchased(data: { email: string; name: string; productTitle: string; amountCents: number; orderId: string; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const donorsId = listmonkSyncService.getListId('Donors'); if (!allContactsId || !donorsId) return; await listmonkClient.upsertSubscriber( data.email, data.name, [allContactsId, donorsId], { source: 'product_purchase', last_product: data.productTitle, last_purchase_amount: data.amountCents, last_order_id: data.orderId, last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: product purchased for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onProductPurchased):', err); } } /** * Sync an address update (e.g. support level change from canvass visit) to Listmonk lists. */ async onAddressUpdated(data: { email: string; name: string; supportLevel?: string | null; sign?: boolean; address?: string | null; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const locationsAllId = listmonkSyncService.getListId('Locations - All'); if (!allContactsId || !locationsAllId) return; const listIds = [allContactsId, locationsAllId]; // Add support level list if (data.supportLevel && SUPPORT_LEVEL_LIST_MAP[data.supportLevel]) { const levelListId = listmonkSyncService.getListId(SUPPORT_LEVEL_LIST_MAP[data.supportLevel]); if (levelListId) listIds.push(levelListId); } // Add sign list if (data.sign) { const signListId = listmonkSyncService.getListId('Has Campaign Sign'); if (signListId) listIds.push(signListId); } await listmonkClient.upsertSubscriber( data.email, data.name, listIds, { source: 'canvass_visit', address: data.address || null, support_level: data.supportLevel || null, sign: data.sign ?? false, last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: address updated for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onAddressUpdated):', err); } } /** * Sync a re-engagement email send to Listmonk "All Contacts" + "Volunteers" lists. */ async onReengagementSent(data: { email: string; name: string; }): Promise { if (!this.enabled) return; try { await listmonkSyncService.ensureInitialized(); const allContactsId = listmonkSyncService.getListId('All Contacts'); const volunteersId = listmonkSyncService.getListId('Volunteers'); if (!allContactsId || !volunteersId) return; await listmonkClient.upsertSubscriber( data.email, data.name, [allContactsId, volunteersId], { source: 'reengagement', last_reengagement_sent: new Date().toISOString(), last_synced: new Date().toISOString(), }, ); this.incrementCounter(); logger.debug(`Listmonk event sync: reengagement sent for ${data.email}`); } catch (err) { logger.debug('Listmonk event sync failed (onReengagementSent):', err); } } /** * Sync tag changes on a contact to Listmonk lists associated with CRM tags. * For added tags: upsert subscriber to the corresponding Listmonk list. * For removed tags: remove subscriber from the corresponding Listmonk list. */ async onContactTagsChanged(data: { email: string; name: string; addedTags: string[]; removedTags: string[]; }): Promise { if (!this.enabled) return; try { // Lazy import to avoid circular dependency const { prisma } = await import('../config/database'); // Find CRM tags that have listmonkListId set const allTagNames = [...data.addedTags, ...data.removedTags]; if (allTagNames.length === 0) return; const crmTags = await prisma.crmTag.findMany({ where: { name: { in: allTagNames }, listmonkListId: { not: null } }, }); const tagMap = new Map(crmTags.map(t => [t.name, t.listmonkListId!])); // Add to lists for added tags const addListIds = data.addedTags .map(t => tagMap.get(t)) .filter((id): id is number => id != null); if (addListIds.length > 0) { await listmonkClient.upsertSubscriber( data.email, data.name, addListIds, { source: 'crm_tag', last_synced: new Date().toISOString() }, ); this.incrementCounter(); } // Remove from lists for removed tags const removeListIds = data.removedTags .map(t => tagMap.get(t)) .filter((id): id is number => id != null); if (removeListIds.length > 0) { const subscriber = await listmonkClient.findSubscriberByEmail(data.email); if (subscriber) { const currentListIds = subscriber.lists.map(l => l.id); await listmonkClient.removeSubscriberFromLists( subscriber.id, removeListIds, data.email, currentListIds, ); this.incrementCounter(); } } logger.debug(`Listmonk event sync: tags changed for ${data.email} (+${data.addedTags.length}/-${data.removedTags.length})`); } catch (err) { logger.debug('Listmonk event sync failed (onContactTagsChanged):', err); } } getStats(): { enabled: boolean; lastSyncAt: string | null; todaySyncCount: number; } { return { enabled: this.enabled, lastSyncAt: this._lastSyncAt?.toISOString() || null, todaySyncCount: this._todaySyncCount, }; } } export const listmonkEventSyncService = new ListmonkEventSyncService();