changemaker.lite/api/src/services/listmonk-event-sync.service.ts

408 lines
12 KiB
TypeScript

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<void> {
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<string, number>;
}): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();