411 lines
17 KiB
JavaScript
411 lines
17 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, calculateAmount } = require('./mollie');
|
|
|
|
// 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 || 'noreply@jeffemmett.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 => `<li>${WEEK_LABELS[w] || w}</li>`).join('');
|
|
|
|
return {
|
|
subject: 'Application Received - 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;">Thank You for Applying!</h1>
|
|
|
|
<p>Dear ${application.first_name},</p>
|
|
|
|
<p>We've received your application to join <strong>Valley of the Commons</strong> (August 24 - September 20, 2026).</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>Ticket:</strong></td>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">${ticketLabel}</td>
|
|
</tr>
|
|
${amount ? `<tr>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;"><strong>Amount:</strong></td>
|
|
<td style="padding: 6px 0; border-bottom: 1px solid #e0e0e0;">€${amount}</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>
|
|
</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>Complete your payment (if you haven't already)</li>
|
|
<li>Our team will review your application</li>
|
|
<li>We may reach out with follow-up questions</li>
|
|
<li>You'll receive a decision within 2-3 weeks</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, 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}<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>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', '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);
|
|
|
|
// 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 <noreply@jeffemmett.com>',
|
|
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 <noreply@jeffemmett.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 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' });
|
|
};
|