feat: add Listmonk newsletter integration for VotC
Add direct PostgreSQL Listmonk integration to sync waitlist signups and applications to the Valley of the Commons list (ID 24). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
59b37fb018
commit
42062a2467
|
|
@ -5,6 +5,7 @@ const { Pool } = require('pg');
|
|||
const nodemailer = require('nodemailer');
|
||||
const { syncApplication } = require('./google-sheets');
|
||||
const { createPayment, TICKET_LABELS, calculateAmount } = require('./mollie');
|
||||
const { addToListmonk } = require('./listmonk');
|
||||
|
||||
// Initialize PostgreSQL connection pool
|
||||
const pool = new Pool({
|
||||
|
|
@ -18,7 +19,7 @@ const smtp = nodemailer.createTransport({
|
|||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com',
|
||||
user: process.env.SMTP_USER || 'contact@valleyofthecommons.com',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
tls: { rejectUnauthorized: false },
|
||||
|
|
@ -295,12 +296,19 @@ module.exports = async function handler(req, res) {
|
|||
// Sync to Google Sheets (fire-and-forget backup)
|
||||
syncApplication(application);
|
||||
|
||||
// Add to Listmonk newsletter
|
||||
addToListmonk(application.email, `${application.first_name} ${application.last_name}`, {
|
||||
source: 'application',
|
||||
weeks: weeksSelected,
|
||||
contributionAmount: data.contribution_amount,
|
||||
}).catch(err => console.error('[Listmonk] Application sync failed:', err.message));
|
||||
|
||||
// Send confirmation email to applicant
|
||||
if (process.env.SMTP_PASS) {
|
||||
try {
|
||||
const confirmEmail = confirmationEmail(application);
|
||||
const info = await smtp.sendMail({
|
||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <newsletter@valleyofthecommons.com>',
|
||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
||||
to: application.email,
|
||||
subject: confirmEmail.subject,
|
||||
html: confirmEmail.html,
|
||||
|
|
@ -316,7 +324,7 @@ module.exports = async function handler(req, res) {
|
|||
const adminEmail = adminNotificationEmail(application);
|
||||
const adminRecipients = (process.env.ADMIN_EMAILS || 'jeff@jeffemmett.com').split(',');
|
||||
const info = await smtp.sendMail({
|
||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <newsletter@valleyofthecommons.com>',
|
||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
||||
to: adminRecipients.join(', '),
|
||||
subject: adminEmail.subject,
|
||||
html: adminEmail.html,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
// Listmonk newsletter integration via direct PostgreSQL access
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const LISTMONK_LIST_ID = parseInt(process.env.LISTMONK_LIST_ID) || 24; // Valley of the Commons 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 || '',
|
||||
}) : null;
|
||||
|
||||
async function addToListmonk(email, name, attribs = {}) {
|
||||
if (!listmonkPool) {
|
||||
console.log('[Listmonk] Database not configured, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
const client = await listmonkPool.connect();
|
||||
try {
|
||||
const mergeAttribs = {
|
||||
votc: {
|
||||
...attribs,
|
||||
registeredAt: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
|
||||
// Check if subscriber exists
|
||||
const existing = await client.query(
|
||||
'SELECT id, attribs FROM subscribers WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
let subscriberId;
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
subscriberId = existing.rows[0].id;
|
||||
const existingAttribs = existing.rows[0].attribs || {};
|
||||
const merged = { ...existingAttribs, ...mergeAttribs };
|
||||
await client.query(
|
||||
'UPDATE subscribers SET name = $1, attribs = $2, updated_at = NOW() WHERE id = $3',
|
||||
[name, JSON.stringify(merged), subscriberId]
|
||||
);
|
||||
console.log(`[Listmonk] Updated existing subscriber: ${email} (ID: ${subscriberId})`);
|
||||
} else {
|
||||
const result = 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`,
|
||||
[email, name, JSON.stringify(mergeAttribs)]
|
||||
);
|
||||
subscriberId = result.rows[0].id;
|
||||
console.log(`[Listmonk] Created new subscriber: ${email} (ID: ${subscriberId})`);
|
||||
}
|
||||
|
||||
// Add to VotC list
|
||||
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(`[Listmonk] Added to VotC list: ${email}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Listmonk] Error:', error.message);
|
||||
return false;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function isConfigured() {
|
||||
return !!listmonkPool;
|
||||
}
|
||||
|
||||
module.exports = { addToListmonk, isConfigured };
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
const { Pool } = require('pg');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { syncWaitlistSignup } = require('./google-sheets');
|
||||
const { addToListmonk } = require('./listmonk');
|
||||
|
||||
// Initialize PostgreSQL connection pool
|
||||
const pool = new Pool({
|
||||
|
|
@ -17,38 +18,57 @@ const smtp = nodemailer.createTransport({
|
|||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'newsletter@valleyofthecommons.com',
|
||||
user: process.env.SMTP_USER || 'contact@valleyofthecommons.com',
|
||||
pass: process.env.SMTP_PASS || '',
|
||||
},
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
const welcomeEmail = (signup) => ({
|
||||
subject: 'Welcome to Valley of the Commons',
|
||||
subject: 'Welcome to the Valley — A Village Built on Common Ground',
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h1 style="color: #2d5016; margin-bottom: 24px;">Welcome to the Valley!</h1>
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||
<h1 style="color: #2d5016; margin-bottom: 8px;">Welcome to the Valley!</h1>
|
||||
<p style="font-size: 15px; color: #5a7a3a; margin-top: 0; margin-bottom: 28px; font-style: italic;">A village built on common ground</p>
|
||||
|
||||
<p>Dear ${signup.name},</p>
|
||||
|
||||
<p>Thank you for your interest in <strong>Valley of the Commons</strong> - a four-week pop-up village in the Austrian Alps (August 24 - September 20, 2026).</p>
|
||||
<p>Thank you for stepping toward something different. <strong>Valley of the Commons</strong> is a four-week pop-up village in Austria's Höllental Valley (August 24 – September 20, 2026) — a living commons shared in work and study, in making and care, in governance and everyday life.</p>
|
||||
|
||||
<p>You've been added to our community list. We'll keep you updated on:</p>
|
||||
<ul>
|
||||
<li>Application opening and deadlines</li>
|
||||
<li>Event announcements and updates</li>
|
||||
<li>Ways to get involved</li>
|
||||
</ul>
|
||||
<p>For four weeks, we'll come together to lay the foundations for life beyond extractive systems. Each week explores a different dimension of what a commons-based society can look like:</p>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<tr style="border-bottom: 1px solid #e8e8e0;">
|
||||
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 1</td>
|
||||
<td style="padding: 10px 12px;">Return of the Commons</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e8e8e0; background: #fafaf5;">
|
||||
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 2</td>
|
||||
<td style="padding: 10px 12px;">Cosmo-local Production & Open Value Accounting</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e8e8e0;">
|
||||
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 3</td>
|
||||
<td style="padding: 10px 12px;">Future Living</td>
|
||||
</tr>
|
||||
<tr style="background: #fafaf5;">
|
||||
<td style="padding: 10px 12px; font-weight: bold; color: #2d5016; white-space: nowrap;">Week 4</td>
|
||||
<td style="padding: 10px 12px;">Governance & Funding</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Mornings are structured learning paths. Afternoons host workshops, field visits, and working groups. And in between — shared meals, hikes into the Alps, river swimming, mushroom foraging, fire circles, and the kind of conversations that only happen when people live and build together.</p>
|
||||
|
||||
${signup.involvement ? `
|
||||
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0;">
|
||||
<strong>Your interests:</strong>
|
||||
<p style="margin-bottom: 0;">${signup.involvement}</p>
|
||||
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0; border-left: 3px solid #2d5016;">
|
||||
<strong style="color: #2d5016;">What you're bringing:</strong>
|
||||
<p style="margin-bottom: 0; margin-top: 8px;">${signup.involvement}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<p>
|
||||
<a href="https://valleyofthecommons.com/apply.html" style="display: inline-block; background: #2d5016; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
|
||||
<p>We'll be in touch with application details, event updates, and ways to get involved as the village takes shape.</p>
|
||||
|
||||
<p style="text-align: center; margin: 28px 0;">
|
||||
<a href="https://valleyofthecommons.com/apply.html" style="display: inline-block; background: #2d5016; color: white; padding: 14px 32px; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
|
||||
Apply Now
|
||||
</a>
|
||||
</p>
|
||||
|
|
@ -149,12 +169,18 @@ module.exports = async function handler(req, res) {
|
|||
// Sync to Google Sheets (fire-and-forget backup)
|
||||
syncWaitlistSignup(signup);
|
||||
|
||||
// Add to Listmonk newsletter
|
||||
addToListmonk(signup.email, signup.name, {
|
||||
involvement: signup.involvement,
|
||||
source: 'waitlist',
|
||||
}).catch(err => console.error('[Listmonk] Waitlist sync failed:', err.message));
|
||||
|
||||
// Send welcome email
|
||||
if (process.env.SMTP_PASS) {
|
||||
try {
|
||||
const email = welcomeEmail(signup);
|
||||
const info = await smtp.sendMail({
|
||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <newsletter@valleyofthecommons.com>',
|
||||
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
|
||||
to: signup.email,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ services:
|
|||
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
|
||||
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
|
||||
- INFISICAL_PROJECT_SLUG=valley-commons
|
||||
- LISTMONK_DB_HOST=listmonk-db
|
||||
- LISTMONK_DB_PORT=5432
|
||||
- LISTMONK_DB_NAME=listmonk
|
||||
- LISTMONK_DB_USER=listmonk
|
||||
- LISTMONK_DB_PASS=${LISTMONK_DB_PASS:-listmonk_secure_2025}
|
||||
- LISTMONK_LIST_ID=24
|
||||
depends_on:
|
||||
votc-db:
|
||||
condition: service_healthy
|
||||
|
|
@ -16,6 +22,7 @@ services:
|
|||
- "traefik.http.services.votc.loadbalancer.server.port=3000"
|
||||
networks:
|
||||
- traefik-public
|
||||
- listmonk-internal
|
||||
|
||||
votc-db:
|
||||
image: postgres:16-alpine
|
||||
|
|
@ -42,3 +49,6 @@ volumes:
|
|||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
listmonk-internal:
|
||||
external:
|
||||
name: listmonk_listmonk-internal
|
||||
|
|
|
|||
Loading…
Reference in New Issue