408 lines
12 KiB
TypeScript
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();
|