diff --git a/.env.example b/.env.example index 68a3b56..56747a1 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,47 @@ +# Valley of the Commons Environment Variables +# Copy to .env and fill in values + +# ============================================ +# PostgreSQL Database (for applications & waitlist) +# ============================================ +# DATABASE_URL is set automatically in docker-compose.yml +# Only needed for local development +DATABASE_URL=postgresql://votc:votc_password@localhost:5432/votc + +# ============================================ +# Resend API for emails +# ============================================ +# Get from: https://resend.com/api-keys +RESEND_API_KEY=re_xxxxxxxxxxxxx + +# Email sender address (must be verified in Resend) +EMAIL_FROM=Valley of the Commons + +# ============================================ +# Admin Configuration +# ============================================ +# Admin API key for accessing application data +# Generate a secure random string +ADMIN_API_KEY=your_secure_admin_key_here + +# Admin email addresses (comma-separated) +ADMIN_EMAILS=jeff@jeffemmett.com + +# ============================================ +# Node Environment +# ============================================ +NODE_ENV=production + +# ============================================ +# Legacy: Google Sheets (optional, for old waitlist) +# ============================================ GOOGLE_SERVICE_ACCOUNT=your_service_account_json_here GOOGLE_SHEET_ID=your_sheet_id_here GOOGLE_SHEET_NAME=Waitlist -AI_GATEWAY_API_KEY=your_vercel_ai_gateway_key_here -GAME_MODEL=mistral/mistral-large-latest +# ============================================ # AI Gateway Configuration for Game Chat +# ============================================ # Vercel AI Gateway API key (get from Vercel dashboard) AI_GATEWAY_API_KEY=your_vercel_ai_gateway_key_here @@ -15,7 +52,9 @@ AI_GATEWAY_API_KEY=your_vercel_ai_gateway_key_here # anthropic/claude-3-5-sonnet-20241022 GAME_MODEL=mistral/mistral-large-latest +# ============================================ # GitHub Configuration for Idea Sharing +# ============================================ # GitHub Personal Access Token (fine-grained token with repo write access) # Create at: https://github.com/settings/tokens GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/.gitignore b/.gitignore index 5c0a362..e5dc4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ refs/ +node_modules/ # OS files .DS_Store diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..c39ea63 --- /dev/null +++ b/admin.html @@ -0,0 +1,947 @@ + + + + + + Admin - Valley of the Commons + + + + + + +
+
+

Admin Login

+ + + +
+
+ + +
+
+

Valley of the Commons - Applications

+
+ + +
+
+ +
+
+
+
-
+
Total Applications
+
+
+
-
+
Pending
+
+
+
-
+
Reviewing
+
+
+
-
+
Accepted
+
+
+
-
+
Waitlisted
+
+
+
-
+
Declined
+
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + +
ApplicantLocationAttendanceSubmittedStatus
Loading applications...
+
+
+
+ + + + + + + diff --git a/api/application.js b/api/application.js new file mode 100644 index 0000000..f6c3b95 --- /dev/null +++ b/api/application.js @@ -0,0 +1,330 @@ +// Application form API endpoint +// Handles full event applications with PostgreSQL storage and Resend emails + +const { Pool } = require('pg'); +const { Resend } = require('resend'); + +// Initialize PostgreSQL connection pool +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false +}); + +// Initialize Resend +const resend = new Resend(process.env.RESEND_API_KEY); + +// Email templates +const confirmationEmail = (application) => ({ + 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).

+ +
+

What happens next?

+
    +
  1. Our team will review your application
  2. +
  3. We may reach out with follow-up questions
  4. +
  5. You'll receive a decision within 2-3 weeks
  6. +
+
+ +

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, resendId, metadata = {}) { + try { + await pool.query( + `INSERT INTO email_log (recipient_email, recipient_name, email_type, subject, resend_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6)`, + [recipientEmail, recipientName, emailType, subject, resendId, 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 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, + city: data.city, + country: data.country, + attendance_type: data.attendance_type, + scholarship_needed: data.scholarship_needed, + motivation: data.motivation + }; + + // Send confirmation email to applicant + if (process.env.RESEND_API_KEY) { + try { + const confirmEmail = confirmationEmail(application); + const { data: emailData } = await resend.emails.send({ + 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, emailData?.id, { 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 { data: emailData } = await resend.emails.send({ + from: process.env.EMAIL_FROM || 'Valley of the Commons ', + to: adminRecipients, + subject: adminEmail.subject, + html: adminEmail.html + }); + await logEmail(adminRecipients[0], 'Admin', 'admin_notification', + adminEmail.subject, emailData?.id, { applicationId: application.id }); + } catch (emailError) { + console.error('Failed to send admin notification:', emailError); + } + } + + return res.status(201).json({ + success: true, + message: 'Application submitted successfully', + applicationId: application.id + }); + + } 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' }); +}; diff --git a/api/waitlist-db.js b/api/waitlist-db.js new file mode 100644 index 0000000..0283f79 --- /dev/null +++ b/api/waitlist-db.js @@ -0,0 +1,165 @@ +// Waitlist API endpoint using PostgreSQL +// Simple interest signups with email confirmation via Resend + +const { Pool } = require('pg'); +const { Resend } = require('resend'); + +// Initialize PostgreSQL connection pool +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false +}); + +// Initialize Resend +const resend = new Resend(process.env.RESEND_API_KEY); + +const welcomeEmail = (signup) => ({ + subject: 'Welcome to Valley of the Commons', + html: ` +
+

Welcome to the Valley!

+ +

Dear ${signup.name},

+ +

Thank you for your interest in Valley of the Commons - a four-week pop-up village in the Austrian Alps (August 24 - September 20, 2026).

+ +

You've been added to our community list. We'll keep you updated on:

+
    +
  • Application opening and deadlines
  • +
  • Event announcements and updates
  • +
  • Ways to get involved
  • +
+ + ${signup.involvement ? ` +
+ Your interests: +

${signup.involvement}

+
+ ` : ''} + +

+ + Apply Now + +

+ +

+ See you in the valley,
+ The Valley of the Commons Team +

+ +
+

+ You received this email because you signed up at votc.jeffemmett.com.
+ Unsubscribe +

+
+ ` +}); + +async function logEmail(recipientEmail, recipientName, emailType, subject, resendId, metadata = {}) { + try { + await pool.query( + `INSERT INTO email_log (recipient_email, recipient_name, email_type, subject, resend_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6)`, + [recipientEmail, recipientName, emailType, subject, resendId, 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', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + return res.status(200).end(); + } + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { email, name, involvement } = req.body; + + // Validate email + if (!email || !email.includes('@')) { + return res.status(400).json({ error: 'Valid email is required' }); + } + + // Validate name + if (!name || name.trim() === '') { + return res.status(400).json({ error: 'Name is required' }); + } + + // Validate involvement + if (!involvement || involvement.trim() === '') { + return res.status(400).json({ error: 'Please describe your desired involvement' }); + } + + const emailLower = email.toLowerCase().trim(); + const nameTrimmed = name.trim(); + const involvementTrimmed = involvement.trim(); + + // Check if email already exists + const existing = await pool.query( + 'SELECT id FROM waitlist WHERE email = $1', + [emailLower] + ); + + if (existing.rows.length > 0) { + // Update existing entry + await pool.query( + 'UPDATE waitlist SET name = $1, involvement = $2 WHERE email = $3', + [nameTrimmed, involvementTrimmed, emailLower] + ); + return res.status(200).json({ + success: true, + message: 'Your information has been updated!' + }); + } + + // Insert new signup + const result = await pool.query( + `INSERT INTO waitlist (email, name, involvement) VALUES ($1, $2, $3) RETURNING id`, + [emailLower, nameTrimmed, involvementTrimmed] + ); + + const signup = { + id: result.rows[0].id, + email: emailLower, + name: nameTrimmed, + involvement: involvementTrimmed + }; + + // Send welcome email + if (process.env.RESEND_API_KEY) { + try { + const email = welcomeEmail(signup); + const { data: emailData } = await resend.emails.send({ + from: process.env.EMAIL_FROM || 'Valley of the Commons ', + to: signup.email, + subject: email.subject, + html: email.html + }); + await logEmail(signup.email, signup.name, 'waitlist_welcome', email.subject, emailData?.id); + } catch (emailError) { + console.error('Failed to send welcome email:', emailError); + // Don't fail the request if email fails + } + } + + return res.status(200).json({ + success: true, + message: 'Successfully joined the waitlist!' + }); + + } catch (error) { + console.error('Waitlist error:', error); + return res.status(500).json({ error: 'Failed to join waitlist. Please try again later.' }); + } +}; diff --git a/apply.html b/apply.html new file mode 100644 index 0000000..45ba7f9 --- /dev/null +++ b/apply.html @@ -0,0 +1,963 @@ + + + + + + Apply - Valley of the Commons + + + + + +
+ +
+ +
+
+ August 24 – September 20, 2026 +

Application Form

+

Valley of the Commons is a four-week pop-up village exploring housing, production, decision-making and ownership in community. We ask that you be thoughtful in your answers to help us understand if you are the right fit. We will not penalize you for unfamiliarity with any topic; please be honest.

+
+ +
+
+
0% complete
+
+ +
+ +
+
Question 1 of 13
+

Contact Information

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+
+ + +
+
Question 2 of 13
+

How did you hear about Valley of the Commons? *

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 3 of 13
+

Referral name(s) (optional)

+

Who can vouch for you?

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 4 of 13
+

What are your affiliations? *

+

What projects or groups are you affiliated with?

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 5 of 13
+

Why would you like to join Valley of the Commons, and why are you a good fit? *

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 6 of 13
+

What are you currently building, researching, or working on? *

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 7 of 13
+

How will you contribute to Valley of the Commons? *

+

Villagers co-create their experience. You can start an interest club, lead a discussion or workshop, teach a cooking class, or more.

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 8 of 13
+

Please rank your interest in our themes *

+

Drag to reorder from most interested (top) to least interested (bottom).

+ +
+
+ 1 + 🏞️ + Developing the Future of the Valley +
+
+ 2 + 🌐 + Cosmo-localism +
+
+ 3 + 🪙 + Funding Models & Token Engineering +
+
+ 4 + 👾 + Fablabs & Hackerspaces +
+
+ 5 + 🌌 + Future Living Design & Development +
+
+ 6 + 🧑‍⚖️ + Network Societies & Decentralized Governance +
+
+ 7 + ⛲️ + Commons Theory & Practice +
+
+ 8 + 👤 + Privacy & Digital Sovereignty +
+
+ 9 + 🌏 + d/acc: defensive accelerationism +
+
+ 10 + 💭 + (Meta)rationality & Cognitive Sovereignty +
+
+ +
+ + +
+
+ + +
+
Question 9 of 13
+

Please explain your familiarity and interest in our themes and event overall *

+

🏞️ Valley Future · 🌐 Cosmo-localism · 🪙 Funding & Token Engineering · 👾 Fablabs · 🌌 Future Living · 🧑‍⚖️ Network Governance · ⛲️ Commons · 👤 Privacy · 🌏 d/acc · 💭 (Meta)rationality

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 10 of 13
+

Describe a belief you have updated within the last 1-2 years *

+

What was the belief and why did it change?

+ +
+ +
+ +
+ + +
+
+ + +
+
Question 11 of 13
+

Which week(s) would you like to attend? *

+ +
+ + + + + + + +
+ +
+ + +
+
+ + +
+
Question 12 of 13
+

Which ticket option would you prefer? *

+

Prices and options subject to change.

+ +
+

🏡 Full Resident (4 weeks)

+
+ + + +
+
+ +
+

🗓 1-Week Visitor (max 20 per week)

+
+ + + +
+
+ +
+

🎟 Non-Accommodation Pass

+
+ +
+
+ +
+ Included: Accommodation (if applicable), venue access, event ticket
+ Not included: Food (~€10/day)
+ Note: +10% after June 1 (goes to event org costs) +
+ +
+ + +
+
+ + +
+
Question 13 of 13
+

Anything else you'd like to add? (optional)

+ +
+ +
+ +
+ +
+ + + +
+ + +
+
+ + + +
+
+ + + + + + diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..407de27 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,137 @@ +-- Valley of the Commons Database Schema +-- PostgreSQL + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Waitlist table (simple interest signups) +CREATE TABLE IF NOT EXISTS waitlist ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + involvement TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + email_verified BOOLEAN DEFAULT FALSE, + subscribed BOOLEAN DEFAULT TRUE +); + +CREATE INDEX idx_waitlist_email ON waitlist(email); +CREATE INDEX idx_waitlist_created ON waitlist(created_at); + +-- Applications table (full event applications) +CREATE TABLE IF NOT EXISTS applications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Status tracking + status VARCHAR(50) DEFAULT 'pending', -- pending, reviewing, accepted, waitlisted, declined + submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + reviewed_at TIMESTAMP WITH TIME ZONE, + reviewed_by VARCHAR(255), + + -- Personal Information + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + phone VARCHAR(50), + country VARCHAR(100), + city VARCHAR(100), + pronouns VARCHAR(50), + date_of_birth DATE, + + -- Professional Background + occupation VARCHAR(255), + organization VARCHAR(255), + skills TEXT[], -- Array of skills + languages TEXT[], -- Array of languages spoken + website VARCHAR(500), + social_links JSONB, -- {twitter: "", linkedin: "", etc} + + -- Participation Details + attendance_type VARCHAR(50), -- full (4 weeks), partial + arrival_date DATE, + departure_date DATE, + accommodation_preference VARCHAR(50), -- tent, shared-room, private-room, offsite + dietary_requirements TEXT[], -- vegetarian, vegan, gluten-free, etc + dietary_notes TEXT, + + -- Motivation & Contribution + motivation TEXT NOT NULL, -- Why do you want to join? + contribution TEXT, -- What will you contribute? + projects TEXT, -- Projects you'd like to work on + workshops_offer TEXT, -- Workshops you could facilitate + + -- Commons Experience + commons_experience TEXT, -- Experience with commons/cooperatives + community_experience TEXT, -- Previous community living experience + governance_interest TEXT[], -- Areas of interest: housing, production, decision-making, ownership + + -- Practical + how_heard VARCHAR(255), -- How did you hear about us? + referral_name VARCHAR(255), -- Who referred you? + previous_events TEXT[], -- Previous related events attended + + -- Emergency Contact + emergency_name VARCHAR(255), + emergency_phone VARCHAR(50), + emergency_relationship VARCHAR(100), + + -- Agreements + code_of_conduct_accepted BOOLEAN DEFAULT FALSE, + privacy_policy_accepted BOOLEAN DEFAULT FALSE, + photo_consent BOOLEAN DEFAULT FALSE, + + -- Financial + scholarship_needed BOOLEAN DEFAULT FALSE, + scholarship_reason TEXT, + contribution_amount VARCHAR(50), -- sliding scale selection + + -- Admin notes + admin_notes TEXT, + + -- Metadata + ip_address VARCHAR(45), + user_agent TEXT, + + CONSTRAINT valid_status CHECK (status IN ('pending', 'reviewing', 'accepted', 'waitlisted', 'declined')) +); + +CREATE INDEX idx_applications_email ON applications(email); +CREATE INDEX idx_applications_status ON applications(status); +CREATE INDEX idx_applications_submitted ON applications(submitted_at); + +-- Email log table (track all sent emails) +CREATE TABLE IF NOT EXISTS email_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + recipient_email VARCHAR(255) NOT NULL, + recipient_name VARCHAR(255), + email_type VARCHAR(100) NOT NULL, -- application_confirmation, waitlist_welcome, status_update, etc + subject VARCHAR(500), + resend_id VARCHAR(255), -- Resend API message ID + status VARCHAR(50) DEFAULT 'sent', -- sent, delivered, bounced, failed + sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + metadata JSONB +); + +CREATE INDEX idx_email_log_recipient ON email_log(recipient_email); +CREATE INDEX idx_email_log_type ON email_log(email_type); + +-- Admin users table +CREATE TABLE IF NOT EXISTS admin_users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'reviewer', -- admin, reviewer + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP WITH TIME ZONE +); + +-- Session tokens for admin auth +CREATE TABLE IF NOT EXISTS admin_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + admin_id UUID REFERENCES admin_users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_admin_sessions_token ON admin_sessions(token); diff --git a/docker-compose.yml b/docker-compose.yml index a7ba01c..4037667 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,48 @@ services: build: . container_name: votc restart: unless-stopped + environment: + - DATABASE_URL=postgresql://votc:votc_password@votc-db:5432/votc + - RESEND_API_KEY=${RESEND_API_KEY} + - EMAIL_FROM=Valley of the Commons + - ADMIN_API_KEY=${ADMIN_API_KEY} + - ADMIN_EMAILS=${ADMIN_EMAILS:-jeff@jeffemmett.com} + - NODE_ENV=production + depends_on: + votc-db: + condition: service_healthy labels: - "traefik.enable=true" - "traefik.http.routers.votc.rule=Host(`votc.jeffemmett.com`)" - "traefik.http.services.votc.loadbalancer.server.port=3000" networks: - traefik-public + - votc-internal + + votc-db: + image: postgres:16-alpine + container_name: votc-db + restart: unless-stopped + environment: + - POSTGRES_USER=votc + - POSTGRES_PASSWORD=votc_password + - POSTGRES_DB=votc + volumes: + - votc-postgres-data:/var/lib/postgresql/data + - ./db/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U votc"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - votc-internal + +volumes: + votc-postgres-data: networks: traefik-public: external: true + votc-internal: + driver: bridge diff --git a/index.html b/index.html index 46738ce..ad0c104 100644 --- a/index.html +++ b/index.html @@ -53,7 +53,7 @@ Explore the Valley Get involved 🐰 - APPLY NOW + APPLY NOW @@ -78,7 +78,7 @@

Pop-Up Event to Seed the Valley

24 August 2026 – 20 September 2026

- APPLY NOW + APPLY NOW diff --git a/package-lock.json b/package-lock.json index c5203be..030ebb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "@octokit/rest": "^22.0.1", "ai": "^6.0.1", "express": "^4.21.0", - "googleapis": "^126.0.1" + "googleapis": "^126.0.1", + "pg": "^8.13.0", + "resend": "^4.0.0" }, "devDependencies": { "dotenv": "^16.3.1" @@ -263,6 +265,37 @@ "node": ">=8.0.0" } }, + "node_modules/@react-email/render": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", + "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -472,6 +505,15 @@ "ms": "2.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -491,6 +533,61 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -542,6 +639,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -664,6 +773,12 @@ ], "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -881,6 +996,41 @@ "node": ">= 0.4" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1012,6 +1162,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1140,6 +1299,19 @@ "node": ">= 0.8" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "license": "MIT", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1155,6 +1327,158 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "license": "MIT", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1207,6 +1531,50 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-promise-suspense": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", + "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/resend": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz", + "integrity": "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==", + "license": "MIT", + "dependencies": { + "@react-email/render": "1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1233,6 +1601,25 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "license": "MIT", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -1356,6 +1743,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1461,6 +1857,15 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 8d76b5d..7d1f947 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "@ai-sdk/mistral": "^3.0.0", "@octokit/rest": "^22.0.1", "ai": "^6.0.1", - "googleapis": "^126.0.1" + "googleapis": "^126.0.1", + "pg": "^8.13.0", + "resend": "^4.0.0" }, "devDependencies": { "dotenv": "^16.3.1" diff --git a/server.js b/server.js index 5f0088a..358b694 100644 --- a/server.js +++ b/server.js @@ -20,7 +20,8 @@ app.use((req, res, next) => { }); // API routes - wrap Vercel serverless functions -const waitlistHandler = require('./api/waitlist'); +const waitlistHandler = require('./api/waitlist-db'); +const applicationHandler = require('./api/application'); const gameChatHandler = require('./api/game-chat'); const shareToGithubHandler = require('./api/share-to-github'); @@ -37,6 +38,7 @@ const vercelToExpress = (handler) => async (req, res) => { }; app.all('/api/waitlist', vercelToExpress(waitlistHandler)); +app.all('/api/application', vercelToExpress(applicationHandler)); app.all('/api/game-chat', vercelToExpress(gameChatHandler)); app.all('/api/share-to-github', vercelToExpress(shareToGithubHandler));