fix: use DATABASE_SSL env var instead of NODE_ENV for pg SSL

This commit is contained in:
Jeff Emmett 2026-02-23 20:35:35 -08:00
parent 08a757e12c
commit 2463980838
2 changed files with 321 additions and 3 deletions

View File

@ -3,11 +3,13 @@
const { Pool } = require('pg'); const { Pool } = require('pg');
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const { syncApplication } = require('./google-sheets');
const { createPayment } = require('./mollie');
// Initialize PostgreSQL connection pool // Initialize PostgreSQL connection pool
const pool = new Pool({ const pool = new Pool({
connectionString: process.env.DATABASE_URL, connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false
}); });
// Initialize SMTP transport (Mailcow) // Initialize SMTP transport (Mailcow)
@ -239,13 +241,23 @@ module.exports = async function handler(req, res) {
first_name: data.first_name, first_name: data.first_name,
last_name: data.last_name, last_name: data.last_name,
email: data.email, email: data.email,
phone: data.phone,
city: data.city, city: data.city,
country: data.country, country: data.country,
attendance_type: data.attendance_type, attendance_type: data.attendance_type,
scholarship_needed: data.scholarship_needed, scholarship_needed: data.scholarship_needed,
motivation: data.motivation scholarship_reason: data.scholarship_reason,
motivation: data.motivation,
contribution: data.contribution,
how_heard: data.how_heard,
referral_name: data.referral_name,
arrival_date: data.arrival_date,
departure_date: data.departure_date,
}; };
// Sync to Google Sheets (fire-and-forget backup)
syncApplication(application);
// Send confirmation email to applicant // Send confirmation email to applicant
if (process.env.SMTP_PASS) { if (process.env.SMTP_PASS) {
try { try {
@ -279,10 +291,32 @@ module.exports = async function handler(req, res) {
} }
} }
// Create Mollie payment if ticket was selected and Mollie is configured
let checkoutUrl = null;
if (data.contribution_amount && process.env.MOLLIE_API_KEY) {
try {
const weeksCount = Array.isArray(data.weeks) ? data.weeks.length : 1;
const paymentResult = await createPayment(
application.id,
data.contribution_amount,
weeksCount,
application.email,
application.first_name,
application.last_name
);
checkoutUrl = paymentResult.checkoutUrl;
console.log(`Mollie payment created: ${paymentResult.paymentId} (${paymentResult.amount} EUR)`);
} catch (paymentError) {
console.error('Failed to create Mollie payment:', paymentError);
// Don't fail the application - payment can be retried
}
}
return res.status(201).json({ return res.status(201).json({
success: true, success: true,
message: 'Application submitted successfully', message: 'Application submitted successfully',
applicationId: application.id applicationId: application.id,
checkoutUrl,
}); });
} catch (error) { } catch (error) {

284
api/mollie.js Normal file
View File

@ -0,0 +1,284 @@
// Mollie payment integration
// Handles payment creation and webhook callbacks
const { createMollieClient } = require('@mollie/api-client');
const { Pool } = require('pg');
const nodemailer = require('nodemailer');
// Initialize PostgreSQL connection pool
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false
});
// Initialize Mollie client
const mollieClient = createMollieClient({
apiKey: process.env.MOLLIE_API_KEY,
});
// Initialize SMTP transport (Mailcow)
const smtp = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'mail.rmail.online',
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER || 'noreply@jeffemmett.com',
pass: process.env.SMTP_PASS || '',
},
tls: { rejectUnauthorized: false },
});
// Ticket price mapping (in EUR)
const TICKET_PRICES = {
'full-dorm': 1500.00,
'full-shared': 1800.00,
'full-single': 3200.00,
'week-dorm': 425.00,
'week-shared': 500.00,
'week-single': 850.00,
'no-accom': 300.00, // per week
};
const TICKET_LABELS = {
'full-dorm': 'Full Resident - Dorm (4-6 people)',
'full-shared': 'Full Resident - Shared Double',
'full-single': 'Full Resident - Single (deluxe apartment)',
'week-dorm': '1-Week Visitor - Dorm (4-6 people)',
'week-shared': '1-Week Visitor - Shared Double',
'week-single': '1-Week Visitor - Single (deluxe apartment)',
'no-accom': 'Non-Accommodation Pass',
};
function calculateAmount(ticketType, weeksCount) {
const basePrice = TICKET_PRICES[ticketType];
if (!basePrice) return null;
// no-accom is priced per week
if (ticketType === 'no-accom') {
return (basePrice * (weeksCount || 1)).toFixed(2);
}
return basePrice.toFixed(2);
}
// Create a Mollie payment for an application
async function createPayment(applicationId, ticketType, weeksCount, email, firstName, lastName) {
const amount = calculateAmount(ticketType, weeksCount);
if (!amount) {
throw new Error(`Invalid ticket type: ${ticketType}`);
}
const baseUrl = process.env.BASE_URL || 'https://votc.jeffemmett.com';
const description = `Valley of the Commons - ${TICKET_LABELS[ticketType] || ticketType}`;
const payment = await mollieClient.payments.create({
amount: {
currency: 'EUR',
value: amount,
},
description,
redirectUrl: `${baseUrl}/payment-return.html?id=${applicationId}`,
webhookUrl: `${baseUrl}/api/mollie/webhook`,
metadata: {
applicationId,
ticketType,
weeksCount,
},
});
// Store Mollie payment ID in database
await pool.query(
`UPDATE applications
SET mollie_payment_id = $1,
payment_amount = $2,
payment_status = 'pending'
WHERE id = $3`,
[payment.id, amount, applicationId]
);
return {
paymentId: payment.id,
checkoutUrl: payment.getCheckoutUrl(),
amount,
};
}
// Payment confirmation email
const paymentConfirmationEmail = (application) => ({
subject: 'Payment Confirmed - Valley of the Commons',
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;">Payment Confirmed!</h1>
<p>Dear ${application.first_name},</p>
<p>Your payment of <strong>&euro;${application.payment_amount}</strong> for Valley of the Commons has been received.</p>
<div style="background: #f5f5f0; padding: 20px; border-radius: 8px; margin: 24px 0;">
<h3 style="margin-top: 0; color: #2d5016;">Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 4px 0;"><strong>Ticket:</strong></td>
<td style="padding: 4px 0;">${TICKET_LABELS[application.contribution_amount] || application.contribution_amount}</td>
</tr>
<tr>
<td style="padding: 4px 0;"><strong>Amount:</strong></td>
<td style="padding: 4px 0;">&euro;${application.payment_amount}</td>
</tr>
<tr>
<td style="padding: 4px 0;"><strong>Mollie Reference:</strong></td>
<td style="padding: 4px 0;">${application.mollie_payment_id}</td>
</tr>
</table>
</div>
<p>Your application is now complete. Our team will review it and get back to you within 2-3 weeks.</p>
<p>If you have any questions, reply to this email and we'll get back to you.</p>
<p style="margin-top: 32px;">
With warmth,<br>
<strong>The Valley of the Commons Team</strong>
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 32px 0;">
<p style="font-size: 12px; color: #666;">
Application ID: ${application.id}
</p>
</div>
`
});
async function logEmail(recipientEmail, recipientName, emailType, subject, messageId, metadata = {}) {
try {
await pool.query(
`INSERT INTO email_log (recipient_email, recipient_name, email_type, subject, message_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
[recipientEmail, recipientName, emailType, subject, messageId, JSON.stringify(metadata)]
);
} catch (error) {
console.error('Failed to log email:', error);
}
}
// Webhook handler - called by Mollie when payment status changes
async function handleWebhook(req, res) {
try {
const paymentId = req.body.id;
if (!paymentId) {
return res.status(400).json({ error: 'Missing payment id' });
}
// Fetch payment status from Mollie
const payment = await mollieClient.payments.get(paymentId);
const applicationId = payment.metadata.applicationId;
// Map Mollie status to our status
let paymentStatus;
switch (payment.status) {
case 'paid':
paymentStatus = 'paid';
break;
case 'failed':
paymentStatus = 'failed';
break;
case 'canceled':
paymentStatus = 'canceled';
break;
case 'expired':
paymentStatus = 'expired';
break;
case 'pending':
paymentStatus = 'pending';
break;
case 'open':
paymentStatus = 'open';
break;
default:
paymentStatus = payment.status;
}
// Update application payment status
await pool.query(
`UPDATE applications
SET payment_status = $1,
payment_paid_at = CASE WHEN $1 = 'paid' THEN CURRENT_TIMESTAMP ELSE payment_paid_at END
WHERE mollie_payment_id = $2`,
[paymentStatus, paymentId]
);
console.log(`Payment ${paymentId} for application ${applicationId}: ${paymentStatus}`);
// Send payment confirmation email if payment succeeded
if (paymentStatus === 'paid' && process.env.SMTP_PASS) {
try {
const appResult = await pool.query(
'SELECT id, first_name, last_name, email, contribution_amount, payment_amount, mollie_payment_id FROM applications WHERE mollie_payment_id = $1',
[paymentId]
);
if (appResult.rows.length > 0) {
const application = appResult.rows[0];
const confirmEmail = paymentConfirmationEmail(application);
const info = await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <noreply@jeffemmett.com>',
to: application.email,
subject: confirmEmail.subject,
html: confirmEmail.html,
});
await logEmail(application.email, `${application.first_name} ${application.last_name}`,
'payment_confirmation', confirmEmail.subject, info.messageId,
{ applicationId: application.id, paymentId, amount: application.payment_amount });
}
} catch (emailError) {
console.error('Failed to send payment confirmation email:', emailError);
}
}
// Mollie expects a 200 response
return res.status(200).end();
} catch (error) {
console.error('Mollie webhook error:', error);
return res.status(500).json({ error: 'Webhook processing failed' });
}
}
// Payment status check endpoint (for frontend polling)
async function getPaymentStatus(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
try {
const { id } = req.query;
if (!id) {
return res.status(400).json({ error: 'Missing application id' });
}
const result = await pool.query(
'SELECT payment_status, payment_amount, contribution_amount FROM applications WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Application not found' });
}
const app = result.rows[0];
return res.status(200).json({
paymentStatus: app.payment_status,
paymentAmount: app.payment_amount,
ticketType: app.contribution_amount,
ticketLabel: TICKET_LABELS[app.contribution_amount] || app.contribution_amount,
});
} catch (error) {
console.error('Payment status check error:', error);
return res.status(500).json({ error: 'Failed to check payment status' });
}
}
module.exports = { createPayment, handleWebhook, getPaymentStatus, TICKET_PRICES, TICKET_LABELS, calculateAmount };