474 lines
21 KiB
JavaScript
474 lines
21 KiB
JavaScript
// 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, REGISTRATION_PRICING, ACCOMMODATION_PRICES, ACCOMMODATION_LABELS, PROCESSING_FEE_PERCENT, calculateAmount, getPricingTier } = 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 weeksCount = (application.weeks || []).length;
|
||
const accomType = application.accommodation_type || null;
|
||
const pricing = calculateAmount('registration', weeksCount, accomType);
|
||
const weeksHtml = (application.weeks || []).map(w => `<li>${WEEK_LABELS[w] || w}</li>`).join('');
|
||
|
||
// Accommodation row
|
||
let accomHtml = '';
|
||
if (accomType && ACCOMMODATION_PRICES[accomType]) {
|
||
const label = ACCOMMODATION_LABELS[accomType] || accomType;
|
||
const accomPrices = ACCOMMODATION_PRICES[accomType];
|
||
const accomDisplay = weeksCount === 4
|
||
? `${label} — €${accomPrices.perMonth} (full month)`
|
||
: `${label} — €${accomPrices.perWeek}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${pricing.accommodation}`;
|
||
accomHtml = `
|
||
<tr>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Accommodation:</strong></td>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${accomDisplay}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
// Food note
|
||
const foodNote = application.want_food
|
||
? '<tr><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Food:</strong></td><td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">Interest registered — we are exploring co-producing meals as a community. More details and costs coming soon.</td></tr>'
|
||
: '';
|
||
|
||
return {
|
||
subject: 'Welcome to the Process - 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;">We're glad you're here, ${application.first_name}!</h1>
|
||
|
||
<p>Your application to <strong>Valley of the Commons</strong> (August 24 – September 20, 2026) has been received. We're excited to read about what you'll bring to the village.</p>
|
||
|
||
<div style="background: #f5f5f0; padding: 20px; border-radius: 8px; margin: 24px 0;">
|
||
<h3 style="margin-top: 0; color: #2d5016;">Your Booking Summary</h3>
|
||
<table style="width: 100%; border-collapse: collapse;">
|
||
<tr>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Registration:</strong></td>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${weeksCount === 4 ? `€${pricing.registration} (full month)` : `€${REGISTRATION_PRICING[pricing.tier].perWeek}/week × ${weeksCount} week${weeksCount > 1 ? 's' : ''} = €${pricing.registration}`} (${pricing.tier === 'early' ? 'Early Bird' : pricing.tier === 'standard' ? 'Standard' : 'Last Minute'} rate)</td>
|
||
</tr>
|
||
${accomHtml}
|
||
<tr>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Processing fee (2%):</strong></td>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">€${pricing.processingFee}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Total:</strong></td>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>€${pricing.total}</strong></td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Attendance:</strong></td>
|
||
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
|
||
</tr>
|
||
${foodNote}
|
||
</table>
|
||
${weeksHtml ? `<p style="margin-top: 12px; margin-bottom: 0;"><strong>Weeks selected:</strong></p><ul style="margin-top: 4px; margin-bottom: 0;">${weeksHtml}</ul>` : ''}
|
||
</div>
|
||
|
||
<div style="background: #f5f5f0; padding: 20px; border-radius: 8px; margin: 24px 0;">
|
||
<h3 style="margin-top: 0; color: #2d5016;">What happens next?</h3>
|
||
<ol style="margin-bottom: 0;">
|
||
<li><a href="${process.env.BASE_URL || 'https://valleyofthecommons.com'}/api/mollie/resume?id=${application.id}" style="color: #2d5016; font-weight: 600;">Complete your registration payment</a> (if you haven't already)</li>
|
||
<li>Our team will review your application within <strong>1 week</strong></li>
|
||
<li>We may reach out with follow-up questions</li>
|
||
${accomType ? '<li>Your accommodation will be allocated and details sent to you shortly after payment is confirmed</li>' : ''}
|
||
</ol>
|
||
</div>
|
||
|
||
<p>In the meantime, feel free to explore more about the Commons Hub and our community:</p>
|
||
<ul>
|
||
<li><a href="https://www.commons-hub.at/">Commons Hub Website</a></li>
|
||
<li><a href="https://valleyofthecommons.com/">Valley of the Commons</a></li>
|
||
</ul>
|
||
|
||
<p>If you have any questions, just reply to this email — we'd love to hear from 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}<br>
|
||
Submitted: ${new Date(application.submitted_at).toLocaleDateString('en-US', { dateStyle: 'long' })}
|
||
</p>
|
||
</div>
|
||
`
|
||
};
|
||
};
|
||
|
||
const adminNotificationEmail = (application) => ({
|
||
subject: `New Application: ${application.first_name} ${application.last_name}`,
|
||
html: `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||
<h2 style="color: #2d5016;">New Application Received</h2>
|
||
|
||
<table style="width: 100%; border-collapse: collapse;">
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Name:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.first_name} ${application.last_name}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Email:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><a href="mailto:${application.email}">${application.email}</a></td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Location:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.city || ''}, ${application.country || ''}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Attendance:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Accommodation:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.accommodation_type ? (ACCOMMODATION_LABELS[application.accommodation_type] || application.accommodation_type) : (application.need_accommodation ? 'Yes (no type selected)' : 'No')}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Food interest:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.want_food ? 'Yes — wants to co-produce meals' : 'No'}</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;"><strong>Scholarship:</strong></td>
|
||
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${application.scholarship_needed ? 'Yes' : 'No'}</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<div style="background: #f5f5f0; padding: 16px; border-radius: 8px; margin: 24px 0;">
|
||
<h3 style="margin-top: 0;">Motivation</h3>
|
||
<p style="margin-bottom: 0; white-space: pre-wrap;">${application.motivation}</p>
|
||
</div>
|
||
|
||
<p>
|
||
<a href="https://valleyofthecommons.com/admin.html" style="display: inline-block; background: #2d5016; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
|
||
Review Application
|
||
</a>
|
||
</p>
|
||
|
||
<p style="font-size: 12px; color: #666; margin-top: 24px;">
|
||
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);
|
||
}
|
||
}
|
||
|
||
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', 'belief_update', '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);
|
||
|
||
// Prepare new array fields
|
||
const selectedWeeks = Array.isArray(data.weeks) ? data.weeks : (data.weeks ? [data.weeks] : []);
|
||
const topThemes = Array.isArray(data.top_themes) ? data.top_themes : (data.top_themes ? [data.top_themes] : 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, need_accommodation, want_food, accommodation_type,
|
||
selected_weeks, top_themes, belief_update, volunteer_interest, coupon_code,
|
||
food_preference, accessibility_needs
|
||
) 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, $42, $43, $44,
|
||
$45, $46, $47, $48, $49, $50, $51
|
||
) 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,
|
||
'registration',
|
||
req.headers['x-forwarded-for'] || req.connection?.remoteAddress || null,
|
||
req.headers['user-agent'] || null,
|
||
data.need_accommodation || false,
|
||
data.want_food || false,
|
||
data.accommodation_type || null,
|
||
selectedWeeks.length > 0 ? selectedWeeks : null,
|
||
topThemes,
|
||
data.belief_update?.trim() || null,
|
||
data.volunteer_interest || false,
|
||
data.coupon_code?.trim() || null,
|
||
data.food_preference?.trim() || null,
|
||
data.accessibility_needs?.trim() || null
|
||
]
|
||
);
|
||
|
||
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: selectedWeeks,
|
||
need_accommodation: data.need_accommodation || false,
|
||
accommodation_preference: data.accommodation_preference || null,
|
||
accommodation_type: data.accommodation_type || null,
|
||
want_food: data.want_food || false,
|
||
contribution_amount: 'registration',
|
||
};
|
||
|
||
// 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: selectedWeeks,
|
||
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 <contact@valleyofthecommons.com>',
|
||
to: application.email,
|
||
bcc: 'team@valleyofthecommons.com',
|
||
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 <contact@valleyofthecommons.com>',
|
||
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 for registration + accommodation fee
|
||
let checkoutUrl = null;
|
||
if (selectedWeeks.length > 0 && process.env.MOLLIE_API_KEY) {
|
||
try {
|
||
const paymentResult = await createPayment(
|
||
application.id,
|
||
'registration',
|
||
selectedWeeks.length,
|
||
application.email,
|
||
application.first_name,
|
||
application.last_name,
|
||
application.accommodation_type,
|
||
selectedWeeks
|
||
);
|
||
checkoutUrl = paymentResult.checkoutUrl;
|
||
console.log(`Mollie payment created: ${paymentResult.paymentId} (€${paymentResult.amount})`);
|
||
} 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' });
|
||
};
|