Switch Listmonk integration to direct PostgreSQL access

- Replaced HTTP API calls with direct database insertions
- More reliable than API auth which requires session tokens in Listmonk v5+
- Added pg package for PostgreSQL connectivity
- Handles both new subscribers and existing ones (updates attributes)
- Merges WORLDPLAY attributes with any existing subscriber data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-30 20:15:07 +00:00
parent f0641cc04a
commit c32070806a
3 changed files with 77 additions and 72 deletions

View File

@ -13,10 +13,12 @@ services:
- RESEND_API_KEY=${RESEND_API_KEY} - RESEND_API_KEY=${RESEND_API_KEY}
- GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID} - GOOGLE_SHEET_ID=${GOOGLE_SHEET_ID}
- GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS} - GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS}
# Listmonk newsletter integration # Listmonk newsletter integration (direct DB access)
- LISTMONK_URL=http://listmonk:9000 - LISTMONK_DB_HOST=listmonk-db
- LISTMONK_USER=worldplay-api - LISTMONK_DB_PORT=5432
- LISTMONK_PASS=worldplay-api-2026 - LISTMONK_DB_NAME=listmonk
- LISTMONK_DB_USER=listmonk
- LISTMONK_DB_PASS=listmonk_secure_2025
- LISTMONK_LIST_ID=20 - LISTMONK_LIST_ID=20
volumes: volumes:
- worldplay-data:/app/data - worldplay-data:/app/data

View File

@ -19,6 +19,7 @@
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"pg": "^8.11.3",
"resend": "^4.0.0" "resend": "^4.0.0"
}, },
"engines": { "engines": {

114
server.js
View File

@ -3,6 +3,7 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const { google } = require('googleapis'); const { google } = require('googleapis');
const { Resend } = require('resend'); const { Resend } = require('resend');
const { Pool } = require('pg');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@ -12,11 +13,15 @@ const REGISTRATIONS_FILE = path.join(DATA_DIR, 'registrations.json');
// Initialize Resend for emails // Initialize Resend for emails
const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
// Listmonk configuration for newsletter management // Listmonk PostgreSQL configuration for newsletter management
const LISTMONK_URL = process.env.LISTMONK_URL || 'http://listmonk:9000';
const LISTMONK_USER = process.env.LISTMONK_USER || 'worldplay-api';
const LISTMONK_PASS = process.env.LISTMONK_PASS || 'worldplay-api-2026';
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 20; // WORLDPLAY list const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 20; // WORLDPLAY list
const listmonkPool = process.env.LISTMONK_DB_HOST ? new Pool({
host: process.env.LISTMONK_DB_HOST || 'listmonk-db',
port: parseInt(process.env.LISTMONK_DB_PORT) || 5432,
database: process.env.LISTMONK_DB_NAME || 'listmonk',
user: process.env.LISTMONK_DB_USER || 'listmonk',
password: process.env.LISTMONK_DB_PASS || 'listmonk_secure_2025',
}) : null;
// Google Sheets configuration // Google Sheets configuration
const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID; const GOOGLE_SHEET_ID = process.env.GOOGLE_SHEET_ID;
@ -248,29 +253,23 @@ async function sendConfirmationEmail(registration) {
} }
} }
// Add subscriber to Listmonk for newsletter management // Add subscriber to Listmonk for newsletter management (direct DB access)
async function addToListmonk(registration) { async function addToListmonk(registration) {
if (!registration.newsletter) { if (!registration.newsletter) {
console.log('Newsletter not opted in, skipping Listmonk...'); console.log('Newsletter not opted in, skipping Listmonk...');
return; return;
} }
try { if (!listmonkPool) {
const auth = Buffer.from(`${LISTMONK_USER}:${LISTMONK_PASS}`).toString('base64'); console.log('Listmonk database not configured, skipping...');
return;
}
// First, create or update the subscriber const client = await listmonkPool.connect();
const subscriberResponse = await fetch(`${LISTMONK_URL}/api/subscribers`, { try {
method: 'POST', const fullName = `${registration.firstName} ${registration.lastName}`;
headers: { const attribs = {
'Authorization': `Basic ${auth}`, worldplay: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: registration.email,
name: `${registration.firstName} ${registration.lastName}`,
status: 'enabled',
lists: [LISTMONK_LIST_ID],
attribs: {
firstName: registration.firstName, firstName: registration.firstName,
lastName: registration.lastName, lastName: registration.lastName,
location: registration.location || '', location: registration.location || '',
@ -280,49 +279,52 @@ async function addToListmonk(registration) {
registrationId: registration.id, registrationId: registration.id,
registeredAt: registration.registeredAt registeredAt: registration.registeredAt
} }
}), };
});
if (subscriberResponse.ok) { // Check if subscriber exists
const data = await subscriberResponse.json(); const existingResult = await client.query(
console.log(`Added to Listmonk WORLDPLAY list: ${registration.email} (ID: ${data.data?.id})`); 'SELECT id, attribs FROM subscribers WHERE email = $1',
} else if (subscriberResponse.status === 409) { [registration.email]
// Subscriber already exists, add to list );
console.log(`Subscriber exists in Listmonk, updating list membership: ${registration.email}`);
// Get existing subscriber let subscriberId;
const searchResponse = await fetch(`${LISTMONK_URL}/api/subscribers?query=email='${registration.email}'`, {
headers: { 'Authorization': `Basic ${auth}` },
});
if (searchResponse.ok) { if (existingResult.rows.length > 0) {
const searchData = await searchResponse.json(); // Subscriber exists - update attributes and get ID
if (searchData.data?.results?.length > 0) { subscriberId = existingResult.rows[0].id;
const subscriberId = searchData.data.results[0].id; const existingAttribs = existingResult.rows[0].attribs || {};
const mergedAttribs = { ...existingAttribs, ...attribs };
// Add to WORLDPLAY list await client.query(
await fetch(`${LISTMONK_URL}/api/subscribers/lists`, { 'UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3',
method: 'PUT', [fullName, JSON.stringify(mergedAttribs), subscriberId]
headers: { );
'Authorization': `Basic ${auth}`, console.log(`Updated existing Listmonk subscriber: ${registration.email} (ID: ${subscriberId})`);
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: [subscriberId],
action: 'add',
target_list_ids: [LISTMONK_LIST_ID],
status: 'confirmed'
}),
});
console.log(`Added existing subscriber to WORLDPLAY list: ${registration.email}`);
}
}
} else { } else {
const errorText = await subscriberResponse.text(); // Create new subscriber
console.error(`Listmonk API error (${subscriberResponse.status}): ${errorText}`); const insertResult = await client.query(
`INSERT INTO subscribers (uuid, email, name, status, attribs, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, 'enabled', $3, NOW(), NOW())
RETURNING id`,
[registration.email, fullName, JSON.stringify(attribs)]
);
subscriberId = insertResult.rows[0].id;
console.log(`Created new Listmonk subscriber: ${registration.email} (ID: ${subscriberId})`);
} }
// Add to WORLDPLAY list if not already a member
await client.query(
`INSERT INTO subscriber_lists (subscriber_id, list_id, status, created_at, updated_at)
VALUES ($1, $2, 'confirmed', NOW(), NOW())
ON CONFLICT (subscriber_id, list_id) DO UPDATE SET status = 'confirmed', updated_at = NOW()`,
[subscriberId, LISTMONK_LIST_ID]
);
console.log(`Added to Listmonk WORLDPLAY list: ${registration.email}`);
} catch (error) { } catch (error) {
console.error('Error adding to Listmonk:', error.message); console.error('Error adding to Listmonk:', error.message);
} finally {
client.release();
} }
} }
@ -512,6 +514,6 @@ ensureDataDir().then(() => {
console.log(`Admin token: ${process.env.ADMIN_TOKEN || 'worldplay-admin-2026'}`); console.log(`Admin token: ${process.env.ADMIN_TOKEN || 'worldplay-admin-2026'}`);
console.log(`Google Sheets: ${sheets ? 'enabled' : 'disabled'}`); console.log(`Google Sheets: ${sheets ? 'enabled' : 'disabled'}`);
console.log(`Email notifications: ${resend ? 'enabled' : 'disabled'}`); console.log(`Email notifications: ${resend ? 'enabled' : 'disabled'}`);
console.log(`Listmonk newsletter sync: enabled (list ID: ${LISTMONK_LIST_ID})`); console.log(`Listmonk newsletter sync: ${listmonkPool ? 'enabled' : 'disabled'} (list ID: ${LISTMONK_LIST_ID})`);
}); });
}); });