297 lines
12 KiB
JavaScript
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
|