// Application form API endpoint
// Handles full event applications with PostgreSQL storage and Mailcow SMTP emails
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({
connectionString: process.env.DATABASE_URL,
ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false
});
// 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 || 'contact@valleyofthecommons.com',
pass: process.env.SMTP_PASS || '',
},
tls: { rejectUnauthorized: false },
});
// Week labels for email display
const WEEK_LABELS = {
week1: 'Week 1: Return to the Commons (Aug 24-30)',
week2: 'Week 2: Post-Capitalist Production (Aug 31-Sep 6)',
week3: 'Week 3: Future Living (Sep 7-13)',
week4: 'Week 4: Governance & Funding Models (Sep 14-20)',
};
// Email templates
const confirmationEmail = (application) => {
const ticketLabel = TICKET_LABELS[application.contribution_amount] || application.contribution_amount || 'Not selected';
const amount = application.contribution_amount ? calculateAmount(application.contribution_amount, (application.weeks || []).length) : null;
const weeksHtml = (application.weeks || []).map(w => `
${WEEK_LABELS[w] || w}`).join('');
return {
subject: 'Application Received - Valley of the Commons',
html: `
Thank You for Applying!
Dear ${application.first_name},
We've received your application to join Valley of the Commons (August 24 - September 20, 2026).
Your Booking Summary
| Ticket: |
${ticketLabel} |
${amount ? `
| Amount: |
€${amount} |
` : ''}
| Attendance: |
${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'} |
${weeksHtml ? `
Weeks selected:
` : ''}
What happens next?
- Complete your payment (if you haven't already)
- Our team will review your application
- We may reach out with follow-up questions
- You'll receive a decision within 2-3 weeks
In the meantime, feel free to explore more about the Commons Hub and our community:
If you have any questions, reply to this email and we'll get back to you.
With warmth,
The Valley of the Commons Team
Application ID: ${application.id}
Submitted: ${new Date(application.submitted_at).toLocaleDateString('en-US', { dateStyle: 'long' })}
`
};
};
const adminNotificationEmail = (application) => ({
subject: `New Application: ${application.first_name} ${application.last_name}`,
html: `
New Application Received
| Name: |
${application.first_name} ${application.last_name} |
| Email: |
${application.email} |
| Location: |
${application.city || ''}, ${application.country || ''} |
| Attendance: |
${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'} |
| Scholarship: |
${application.scholarship_needed ? 'Yes' : 'No'} |
Motivation
${application.motivation}
Review Application
Application ID: ${application.id}
`
});
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);
}
}
module.exports = async function handler(req, res) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
// POST - Submit new application
if (req.method === 'POST') {
try {
const data = req.body;
// Validate required fields
const required = ['first_name', 'last_name', 'email', 'motivation', 'code_of_conduct_accepted', 'privacy_policy_accepted'];
for (const field of required) {
if (!data[field]) {
return res.status(400).json({ error: `Missing required field: ${field}` });
}
}
// Validate email format
if (!data.email.includes('@')) {
return res.status(400).json({ error: 'Invalid email address' });
}
// Check for duplicate application
const existing = await pool.query(
'SELECT id FROM applications WHERE email = $1',
[data.email.toLowerCase().trim()]
);
if (existing.rows.length > 0) {
return res.status(409).json({
error: 'An application with this email already exists',
applicationId: existing.rows[0].id
});
}
// Prepare arrays for PostgreSQL
const skills = Array.isArray(data.skills) ? data.skills : (data.skills ? [data.skills] : null);
const languages = Array.isArray(data.languages) ? data.languages : (data.languages ? [data.languages] : null);
const dietary = Array.isArray(data.dietary_requirements) ? data.dietary_requirements : (data.dietary_requirements ? [data.dietary_requirements] : null);
const governance = Array.isArray(data.governance_interest) ? data.governance_interest : (data.governance_interest ? [data.governance_interest] : null);
const previousEvents = Array.isArray(data.previous_events) ? data.previous_events : (data.previous_events ? [data.previous_events] : null);
// Insert application
const result = await pool.query(
`INSERT INTO applications (
first_name, last_name, email, phone, country, city, pronouns, date_of_birth,
occupation, organization, skills, languages, website, social_links,
attendance_type, arrival_date, departure_date, accommodation_preference,
dietary_requirements, dietary_notes, motivation, contribution, projects,
workshops_offer, commons_experience, community_experience, governance_interest,
how_heard, referral_name, previous_events, emergency_name, emergency_phone,
emergency_relationship, code_of_conduct_accepted, privacy_policy_accepted,
photo_consent, scholarship_needed, scholarship_reason, contribution_amount,
ip_address, user_agent
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32,
$33, $34, $35, $36, $37, $38, $39, $40, $41
) RETURNING id, submitted_at`,
[
data.first_name?.trim(),
data.last_name?.trim(),
data.email?.toLowerCase().trim(),
data.phone?.trim() || null,
data.country?.trim() || null,
data.city?.trim() || null,
data.pronouns?.trim() || null,
data.date_of_birth || null,
data.occupation?.trim() || null,
data.organization?.trim() || null,
skills,
languages,
data.website?.trim() || null,
data.social_links ? JSON.stringify(data.social_links) : null,
data.attendance_type || 'full',
data.arrival_date || null,
data.departure_date || null,
data.accommodation_preference || null,
dietary,
data.dietary_notes?.trim() || null,
data.motivation?.trim(),
data.contribution?.trim() || null,
data.projects?.trim() || null,
data.workshops_offer?.trim() || null,
data.commons_experience?.trim() || null,
data.community_experience?.trim() || null,
governance,
data.how_heard?.trim() || null,
data.referral_name?.trim() || null,
previousEvents,
data.emergency_name?.trim() || null,
data.emergency_phone?.trim() || null,
data.emergency_relationship?.trim() || null,
data.code_of_conduct_accepted || false,
data.privacy_policy_accepted || false,
data.photo_consent || false,
data.scholarship_needed || false,
data.scholarship_reason?.trim() || null,
data.contribution_amount || null,
req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
req.headers['user-agent'] || null
]
);
const weeksSelected = Array.isArray(data.weeks) ? data.weeks : [];
const application = {
id: result.rows[0].id,
submitted_at: result.rows[0].submitted_at,
first_name: data.first_name,
last_name: data.last_name,
email: data.email,
phone: data.phone,
city: data.city,
country: data.country,
attendance_type: data.attendance_type,
scholarship_needed: data.scholarship_needed,
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,
weeks: weeksSelected,
contribution_amount: data.contribution_amount,
};
// 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 ',
to: application.email,
subject: confirmEmail.subject,
html: confirmEmail.html,
});
await logEmail(application.email, `${application.first_name} ${application.last_name}`,
'application_confirmation', confirmEmail.subject, info.messageId, { applicationId: application.id });
} catch (emailError) {
console.error('Failed to send confirmation email:', emailError);
}
// Send notification to admins
try {
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 ',
to: adminRecipients.join(', '),
subject: adminEmail.subject,
html: adminEmail.html,
});
await logEmail(adminRecipients[0], 'Admin', 'admin_notification',
adminEmail.subject, info.messageId, { applicationId: application.id });
} catch (emailError) {
console.error('Failed to send admin notification:', emailError);
}
}
// 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({
success: true,
message: 'Application submitted successfully',
applicationId: application.id,
checkoutUrl,
});
} catch (error) {
console.error('Application submission error:', error);
return res.status(500).json({ error: 'Failed to submit application. Please try again.' });
}
}
// GET - Retrieve applications (admin only)
if (req.method === 'GET') {
// Simple token-based auth for admin access
const authHeader = req.headers.authorization;
if (!authHeader || authHeader !== `Bearer ${process.env.ADMIN_API_KEY}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const { status, limit = 50, offset = 0 } = req.query;
let query = 'SELECT * FROM applications';
const params = [];
if (status) {
query += ' WHERE status = $1';
params.push(status);
}
query += ` ORDER BY submitted_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
params.push(parseInt(limit), parseInt(offset));
const result = await pool.query(query, params);
// Get total count
let countQuery = 'SELECT COUNT(*) FROM applications';
if (status) {
countQuery += ' WHERE status = $1';
}
const countResult = await pool.query(countQuery, status ? [status] : []);
return res.status(200).json({
applications: result.rows,
total: parseInt(countResult.rows[0].count),
limit: parseInt(limit),
offset: parseInt(offset)
});
} catch (error) {
console.error('Failed to fetch applications:', error);
return res.status(500).json({ error: 'Failed to fetch applications' });
}
}
return res.status(405).json({ error: 'Method not allowed' });
};