changemaker.lite/api/dist/services/listmonk-sync.service.js

297 lines
12 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.listmonkSyncService = exports.SUPPORT_LEVEL_LIST_MAP = void 0;
const env_1 = require("../config/env");
const database_1 = require("../config/database");
const logger_1 = require("../utils/logger");
const listmonk_client_1 = require("./listmonk.client");
// --- List definitions ---
const LIST_DEFINITIONS = [
{ name: 'All Contacts', tags: ['v2'] },
{ name: 'Campaign Participants', tags: ['v2', 'influence'] },
{ name: 'Locations - All', tags: ['v2', 'map'] },
{ name: 'Support Level 1 (Strong)', tags: ['v2', 'map', 'support'] },
{ name: 'Support Level 2 (Likely)', tags: ['v2', 'map', 'support'] },
{ name: 'Support Level 3 (Unsure)', tags: ['v2', 'map', 'support'] },
{ name: 'Support Level 4 (Opposition)', tags: ['v2', 'map', 'support'] },
{ name: 'Has Campaign Sign', tags: ['v2', 'map', 'signs'] },
{ name: 'Users', tags: ['v2', 'users'] },
{ name: 'Volunteers', tags: ['v2', 'map', 'shifts'] },
{ name: 'Canvassers', tags: ['v2', 'map', 'canvass'] },
{ name: 'Subscribers', tags: ['v2', 'payments'] },
{ name: 'Donors', tags: ['v2', 'payments'] },
];
exports.SUPPORT_LEVEL_LIST_MAP = {
LEVEL_1: 'Support Level 1 (Strong)',
LEVEL_2: 'Support Level 2 (Likely)',
LEVEL_3: 'Support Level 3 (Unsure)',
LEVEL_4: 'Support Level 4 (Opposition)',
};
class ListmonkSyncService {
listIds = {};
initialized = false;
lastSyncAt = null;
lastError = null;
async initializeLists() {
const existingLists = await listmonk_client_1.listmonkClient.getLists();
const existingByName = new Map(existingLists.map(l => [l.name, l]));
for (const def of LIST_DEFINITIONS) {
const existing = existingByName.get(def.name);
if (existing) {
this.listIds[def.name] = existing.id;
}
else {
const created = await listmonk_client_1.listmonkClient.createList(def.name, 'private', def.tags);
this.listIds[def.name] = created.id;
logger_1.logger.info(`Created Listmonk list: ${def.name} (id=${created.id})`);
}
}
this.initialized = true;
logger_1.logger.info('Listmonk lists initialized', { listIds: this.listIds });
}
async ensureInitialized() {
if (!this.initialized) {
await this.initializeLists();
}
}
getListId(name) {
return this.listIds[name];
}
async syncCampaignParticipants() {
await this.ensureInitialized();
const result = { total: 0, success: 0, failed: 0, errors: [] };
// Get distinct senders from campaign emails
const emails = await database_1.prisma.campaignEmail.findMany({
where: { userEmail: { not: null } },
distinct: ['userEmail'],
select: {
userEmail: true,
userName: true,
userPostalCode: true,
campaignSlug: true,
recipientName: true,
sentAt: true,
},
orderBy: { sentAt: 'desc' },
});
result.total = emails.length;
const allContactsId = this.listIds['All Contacts'];
const participantsId = this.listIds['Campaign Participants'];
for (const email of emails) {
if (!email.userEmail)
continue;
try {
await listmonk_client_1.listmonkClient.upsertSubscriber(email.userEmail, email.userName || '', [allContactsId, participantsId], {
source: 'campaign_participant',
campaign_slug: email.campaignSlug,
postal_code: email.userPostalCode || null,
last_sent: email.sentAt.toISOString(),
recipient_name: email.recipientName || null,
});
result.success++;
}
catch (err) {
result.failed++;
const msg = `Failed to sync participant ${email.userEmail}: ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
logger_1.logger.warn(msg);
}
}
return result;
}
async syncLocations() {
await this.ensureInitialized();
const result = { total: 0, success: 0, failed: 0, errors: [] };
// Query addresses (unit-level) with email, include location for street address
const addresses = await database_1.prisma.address.findMany({
where: { email: { not: null } },
select: {
email: true,
firstName: true,
lastName: true,
supportLevel: true,
sign: true,
signSize: true,
phone: true,
location: {
select: {
address: true,
},
},
},
});
result.total = addresses.length;
const allContactsId = this.listIds['All Contacts'];
const locationsAllId = this.listIds['Locations - All'];
for (const addr of addresses) {
if (!addr.email)
continue;
try {
const listIds = [allContactsId, locationsAllId];
// Add support level list
if (addr.supportLevel && exports.SUPPORT_LEVEL_LIST_MAP[addr.supportLevel]) {
const levelListId = this.listIds[exports.SUPPORT_LEVEL_LIST_MAP[addr.supportLevel]];
if (levelListId)
listIds.push(levelListId);
}
// Add sign list
if (addr.sign) {
const signListId = this.listIds['Has Campaign Sign'];
if (signListId)
listIds.push(signListId);
}
const name = [addr.firstName, addr.lastName].filter(Boolean).join(' ');
await listmonk_client_1.listmonkClient.upsertSubscriber(addr.email, name, listIds, {
source: 'location',
address: addr.location.address || null,
support_level: addr.supportLevel || null,
sign: addr.sign,
sign_size: addr.signSize || null,
phone: addr.phone || null,
});
result.success++;
}
catch (err) {
result.failed++;
const msg = `Failed to sync address ${addr.email}: ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
logger_1.logger.warn(msg);
}
}
return result;
}
async syncUsers() {
await this.ensureInitialized();
const result = { total: 0, success: 0, failed: 0, errors: [] };
const users = await database_1.prisma.user.findMany({
where: {
status: 'ACTIVE',
role: { not: 'TEMP' },
},
select: {
email: true,
name: true,
role: true,
createdAt: true,
},
});
result.total = users.length;
const allContactsId = this.listIds['All Contacts'];
const usersListId = this.listIds['Users'];
for (const user of users) {
try {
await listmonk_client_1.listmonkClient.upsertSubscriber(user.email, user.name || '', [allContactsId, usersListId], {
source: 'user',
role: user.role,
created_at: user.createdAt.toISOString(),
});
result.success++;
}
catch (err) {
result.failed++;
const msg = `Failed to sync user ${user.email}: ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
logger_1.logger.warn(msg);
}
}
return result;
}
async syncCrmTags() {
await this.ensureInitialized();
const result = { total: 0, success: 0, failed: 0, errors: [] };
// Find all CRM tags that have a Listmonk list linked
const crmTags = await database_1.prisma.crmTag.findMany({
where: { listmonkListId: { not: null } },
});
for (const tag of crmTags) {
try {
// Find all contacts with this tag using raw SQL (JSONB query)
const contacts = await database_1.prisma.$queryRaw `
SELECT email, "displayName"
FROM contacts
WHERE "mergedIntoId" IS NULL
AND email IS NOT NULL
AND tags @> ${JSON.stringify([tag.name])}::jsonb
`;
result.total += contacts.length;
for (const contact of contacts) {
try {
await listmonk_client_1.listmonkClient.upsertSubscriber(contact.email, contact.displayName || '', [tag.listmonkListId], { source: 'crm_tag', tag_name: tag.name, last_synced: new Date().toISOString() });
result.success++;
}
catch (err) {
result.failed++;
const msg = `Failed to sync tag "${tag.name}" for ${contact.email}: ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
logger_1.logger.warn(msg);
}
}
// Update denormalized count
const countResult = await database_1.prisma.$queryRaw `
SELECT COUNT(*) as count
FROM contacts
WHERE "mergedIntoId" IS NULL
AND tags @> ${JSON.stringify([tag.name])}::jsonb
`;
await database_1.prisma.crmTag.update({
where: { id: tag.id },
data: { contactCount: Number(countResult[0]?.count ?? 0) },
});
}
catch (err) {
const msg = `Failed to sync CRM tag "${tag.name}": ${err instanceof Error ? err.message : String(err)}`;
result.errors.push(msg);
logger_1.logger.warn(msg);
}
}
return result;
}
async syncAll() {
this.lastError = null;
try {
const participants = await this.syncCampaignParticipants();
const locations = await this.syncLocations();
const users = await this.syncUsers();
const crmTags = await this.syncCrmTags();
this.lastSyncAt = new Date();
return { participants, locations, users, crmTags };
}
catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
throw err;
}
}
getStatus() {
return {
enabled: env_1.env.LISTMONK_SYNC_ENABLED === 'true',
connected: false, // Caller should use testConnection for live check
initialized: this.initialized,
lastSyncAt: this.lastSyncAt?.toISOString() || null,
lastError: this.lastError,
};
}
async getStats() {
await this.ensureInitialized();
const lists = await listmonk_client_1.listmonkClient.getLists();
// Only return lists matching our definitions
const ourListNames = new Set(LIST_DEFINITIONS.map(d => d.name));
return {
lists: lists
.filter(l => ourListNames.has(l.name))
.map(l => ({ name: l.name, subscriberCount: l.subscriber_count })),
};
}
async reinitialize() {
this.initialized = false;
this.listIds = {};
await this.initializeLists();
}
setLastSyncAt(date) {
this.lastSyncAt = date;
}
setLastError(error) {
this.lastError = error;
}
}
exports.listmonkSyncService = new ListmonkSyncService();
//# sourceMappingURL=listmonk-sync.service.js.map