lilith-platform.live/codebase/@features/provider-website/backend-api/src/server.ts
2026-04-09 20:47:07 -07:00

800 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Provider Website — Contact Form + Analytics Relay API
*
* Node.js HTTP server. Two responsibilities:
*
* 1. Contact form submissions — sends two emails via the domain's self-hosted SMTP server
* (docker-mailserver in production, Mailpit in development):
* a. Notification to the provider (CONTACT_TO_EMAIL) with submitted details + reply-to
* b. Confirmation receipt to the person who submitted the form
*
* 2. Analytics relay — receives tracking events from the frontend (same-origin) and
* forwards them async to the analytics collector. This keeps the write key server-side
* and lets nginx set X-Real-IP so the collector gets the true client IP.
*
* Env vars:
* SMTP_HOST — SMTP server host (default: localhost)
* SMTP_PORT — SMTP port (default: 587)
* SMTP_USER — SMTP account username (e.g. contact@transquinnftw.com)
* SMTP_PASS — SMTP account password
* SMTP_FROM_EMAIL — Sender display address (default: Quinn <contact@transquinnftw.com>)
* SMTP_REQUIRE_TLS — Enforce STARTTLS; set "false" for dev (default: true)
* CONTACT_TO_EMAIL — Provider notification address (default: TransQuinnFTW@pm.me)
* PORT — HTTP listen port (default: 3021)
* ALLOWED_ORIGINS — Comma-separated allowed CORS origins (optional override)
* ANALYTICS_COLLECTOR_URL — Analytics collector base URL (e.g. http://localhost:4001)
* If unset, analytics events are silently discarded.
* ANALYTICS_WRITE_KEY — Write key for collector authentication (X-Write-Key header)
* ADMIN_API_URL — Admin backend URL (default: http://localhost:3023)
* ADMIN_SERVICE_TOKEN — Bearer token for admin service-to-service calls
* QUINN_MY_URL — Quinn.my backend URL for roster (default: http://localhost:3024)
* ROSTER_TO_EMAIL — Roster notification recipient (default: CONTACT_TO_EMAIL)
*/
import { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { createMailer, mailerConfigFromEnv } from '@lilith/mailer';
import { logger } from './logger';
const PORT = parseInt(process.env['PORT'] ?? '3021', 10);
const CONTACT_TO = process.env['CONTACT_TO_EMAIL'] ?? 'TransQuinnFTW@pm.me';
const ANALYTICS_COLLECTOR_URL = process.env['ANALYTICS_COLLECTOR_URL']?.replace(/\/$/, '') ?? null;
const ANALYTICS_WRITE_KEY = process.env['ANALYTICS_WRITE_KEY'] ?? null;
const ADMIN_API_URL = (process.env['ADMIN_API_URL'] ?? 'http://localhost:3023').replace(/\/$/, '');
const ADMIN_SERVICE_TOKEN = process.env['ADMIN_SERVICE_TOKEN'] ?? '';
const QUINN_MY_URL = (process.env['QUINN_MY_URL'] ?? 'http://localhost:3024').replace(/\/$/, '');
const ROSTER_TO = process.env['ROSTER_TO_EMAIL'] ?? CONTACT_TO;
const DEFAULT_ORIGINS = [
'https://transquinnftw.com',
'https://www.transquinnftw.com',
'http://quinn.apricot.local',
'https://quinn.black.local',
];
const ALLOWED_ORIGINS = new Set(
process.env['ALLOWED_ORIGINS']
? process.env['ALLOWED_ORIGINS'].split(',').map((o) => o.trim())
: DEFAULT_ORIGINS,
);
// ---------------------------------------------------------------------------
// Mailer
// ---------------------------------------------------------------------------
const mailer = createMailer(
mailerConfigFromEnv({ from: process.env['SMTP_FROM_EMAIL'] ?? 'Quinn <contact@transquinnftw.com>' }),
);
// ---------------------------------------------------------------------------
// Inquiry type definitions
// ---------------------------------------------------------------------------
type InquiryType = 'booking' | 'tour' | 'fmty' | 'general' | 'media';
const INQUIRY_LABELS: Record<InquiryType, string> = {
booking: 'Booking Inquiry',
tour: 'Tour / Visiting City',
fmty: 'Fly Me To You (FMTY)',
general: 'General Question',
media: 'Press / Media',
};
const VALID_INQUIRY_TYPES = new Set<string>(Object.keys(INQUIRY_LABELS));
// ---------------------------------------------------------------------------
// Rate limiting — 5 submissions per IP per 10 minutes
// ---------------------------------------------------------------------------
interface RateBucket {
count: number;
resetAt: number;
}
const rateLimits = new Map<string, RateBucket>();
const rosterRateLimits = new Map<string, RateBucket>();
setInterval(() => {
const now = Date.now();
for (const [ip, bucket] of rateLimits) {
if (now > bucket.resetAt) rateLimits.delete(ip);
}
for (const [ip, bucket] of rosterRateLimits) {
if (now > bucket.resetAt) rosterRateLimits.delete(ip);
}
}, 5 * 60_000);
/** Exported for tests only — clears all rate limit buckets. */
export function resetRateLimits(): void {
rateLimits.clear();
rosterRateLimits.clear();
}
export function checkRateLimit(ip: string): boolean {
const now = Date.now();
const bucket = rateLimits.get(ip);
if (!bucket || now > bucket.resetAt) {
rateLimits.set(ip, { count: 1, resetAt: now + 10 * 60_000 });
return true;
}
if (bucket.count >= 5) return false;
bucket.count++;
return true;
}
// ---------------------------------------------------------------------------
// Request validation
// ---------------------------------------------------------------------------
interface ContactBody {
name: string;
email?: string;
countryCode?: string;
phone: string;
preferSms: boolean;
inquiryType: InquiryType;
message: string;
}
export function validateBody(raw: unknown): ContactBody | string {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return 'Invalid request body';
}
const b = raw as Record<string, unknown>;
if (typeof b['name'] !== 'string' || b['name'].trim().length < 2) {
return 'Name is required';
}
// Email is optional. When present, must be a valid format and free of control chars.
let emailOut: string | undefined;
if (b['email'] !== undefined && b['email'] !== null && b['email'] !== '') {
if (typeof b['email'] !== 'string') {
return 'Enter a valid email address';
}
if (/[\x00-\x1f]/.test(b['email'])) {
return 'Enter a valid email address';
}
const trimmed = b['email'].trim();
if (trimmed) {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmed)) {
return 'Enter a valid email address';
}
emailOut = trimmed;
}
}
// Phone is required.
if (typeof b['phone'] !== 'string' || !b['phone'].trim()) {
return 'Phone is required';
}
if (/[\x00-\x1f]/.test(b['phone'])) {
return 'Phone is required';
}
if (typeof b['inquiryType'] !== 'string' || !VALID_INQUIRY_TYPES.has(b['inquiryType'])) {
return 'Invalid inquiry type';
}
if (typeof b['message'] !== 'string' || b['message'].trim().length < 20) {
return 'Message must be at least 20 characters';
}
// Strip control characters from all string fields to prevent SMTP header injection
const strip = (s: string): string => s.replace(/[\x00-\x1f]/g, '');
return {
name: strip(b['name'].trim()).slice(0, 100),
email: emailOut ? strip(emailOut).slice(0, 200) : undefined,
countryCode:
typeof b['countryCode'] === 'string' && b['countryCode'].trim()
? strip(b['countryCode'].trim()).slice(0, 6)
: undefined,
phone: strip(b['phone'].trim()).slice(0, 30),
preferSms: b['preferSms'] === false ? false : true,
inquiryType: b['inquiryType'] as InquiryType,
message: b['message'].trim().slice(0, 2000),
};
}
// ---------------------------------------------------------------------------
// HTML email templates
// ---------------------------------------------------------------------------
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
function notificationHtml(body: ContactBody): string {
const inquiry = INQUIRY_LABELS[body.inquiryType];
const phoneDisplay = body.countryCode
? `${body.countryCode} ${body.phone}`
: body.phone;
const emailRow = body.email
? `<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Email</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">
<a href="mailto:${escapeHtml(body.email)}" style="color:#D4AF37;text-decoration:none;">${escapeHtml(body.email)}</a>
</td>
</tr>`
: '';
const replyPreference = body.preferSms ? 'SMS' : 'Phone call';
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0a0a0f;font-family:Georgia,serif;color:#f0e6d3;">
<div style="max-width:600px;margin:0 auto;padding:32px 24px;">
<h1 style="font-size:24px;color:#D4AF37;margin:0 0 4px;">${escapeHtml(inquiry)}</h1>
<p style="color:#b8a99a;margin:0 0 32px;font-size:14px;">Received via transquinnftw.com</p>
<table style="width:100%;border-collapse:collapse;">
<tbody>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;width:140px;vertical-align:top;">From</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(body.name)}</td>
</tr>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Phone</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(phoneDisplay)}</td>
</tr>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Reply via</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(replyPreference)}</td>
</tr>
${emailRow}
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Inquiry</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(inquiry)}</td>
</tr>
</tbody>
</table>
<div style="margin-top:24px;">
<p style="color:#b8a99a;margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:0.06em;">Message</p>
<div style="background:#12121a;border:1px solid #2a2a3e;border-radius:8px;padding:20px;line-height:1.7;font-size:16px;">
${escapeHtml(body.message).replace(/\n/g, '<br>')}
</div>
</div>
<p style="margin-top:32px;color:#444;font-size:12px;">Hit reply to respond directly to ${escapeHtml(body.name)}.</p>
</div>
</body>
</html>`;
}
function confirmationHtml(body: ContactBody): string {
const inquiry = INQUIRY_LABELS[body.inquiryType];
const channel = body.preferSms ? 'via SMS' : 'with a phone call';
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0a0a0f;font-family:Georgia,serif;color:#f0e6d3;">
<div style="max-width:600px;margin:0 auto;padding:32px 24px;">
<h1 style="font-size:24px;color:#D4AF37;margin:0 0 16px;">Message Received</h1>
<p style="line-height:1.7;margin:0 0 8px;">Hi ${escapeHtml(body.name)},</p>
<p style="line-height:1.7;color:#b8a99a;margin:0 0 24px;">
Quinn has received your ${escapeHtml(inquiry.toLowerCase())} and will reply within 2448 hours ${escapeHtml(channel)}.
</p>
<div style="margin-top:8px;">
<p style="color:#b8a99a;margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:0.06em;">Your message</p>
<div style="background:#12121a;border:1px solid #2a2a3e;border-radius:8px;padding:20px;line-height:1.7;color:#b8a99a;font-size:15px;">
${escapeHtml(body.message).replace(/\n/g, '<br>')}
</div>
</div>
<p style="margin-top:32px;color:#444;font-size:12px;">transquinnftw.com</p>
</div>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Analytics relay — fire-and-forget forward to collector
// ---------------------------------------------------------------------------
/**
* Forward an analytics track request to the collector with server-side enrichment.
* The caller gets 202 immediately; this runs async in the background.
* If ANALYTICS_COLLECTOR_URL is not set, events are silently discarded.
*/
function relayAnalyticsEvent(
trackPath: string,
body: Buffer,
realIp: string,
userAgent: string,
): void {
if (!ANALYTICS_COLLECTOR_URL) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Real-IP': realIp,
'X-Forwarded-For': realIp,
'User-Agent': userAgent,
};
if (ANALYTICS_WRITE_KEY) headers['X-Write-Key'] = ANALYTICS_WRITE_KEY;
void fetch(`${ANALYTICS_COLLECTOR_URL}${trackPath}`, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(10_000),
}).catch((err: unknown) => {
logger.warn('Analytics relay failed', {
route: `POST ${trackPath}`,
outcome: 'relay_error',
error: err instanceof Error ? err.message : String(err),
});
});
}
// ---------------------------------------------------------------------------
// Admin API — persist contact submissions
// ---------------------------------------------------------------------------
interface PersistResult {
success: boolean;
id: number;
}
async function persistContact(body: ContactBody): Promise<PersistResult> {
const res = await fetch(`${ADMIN_API_URL}/api/contact-submissions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ADMIN_SERVICE_TOKEN}`,
},
body: JSON.stringify({
name: body.name,
email: body.email,
phone: body.phone,
countryCode: body.countryCode,
preferSms: body.preferSms,
inquiryType: body.inquiryType,
message: body.message,
}),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Admin persist failed: ${res.status} ${text}`);
}
const data = await res.json() as PersistResult;
return data;
}
async function patchContactStatus(
id: number,
status: 'notified' | 'failed',
errorMsg?: string,
): Promise<void> {
const body: Record<string, unknown> = { status };
if (errorMsg !== undefined) body['error'] = errorMsg.slice(0, 1000);
await fetch(`${ADMIN_API_URL}/api/contact-submissions/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ADMIN_SERVICE_TOKEN}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(5_000),
}).catch((err: unknown) => {
logger.warn('Contact status patch failed', {
route: `PATCH /api/contact-submissions/${id}`,
submissionId: id,
status,
error: err instanceof Error ? err.message : String(err),
});
});
}
// ---------------------------------------------------------------------------
// Email sending
// ---------------------------------------------------------------------------
async function sendContactEmails(body: ContactBody): Promise<void> {
const sends: Array<Promise<unknown>> = [
mailer.sendMail({
to: CONTACT_TO,
...(body.email ? { replyTo: body.email } : {}),
subject: `${INQUIRY_LABELS[body.inquiryType]} from ${body.name}`,
html: notificationHtml(body),
}),
];
// Confirmation receipt is only sent when the submitter provided an email.
if (body.email) {
sends.push(
mailer.sendMail({
to: body.email,
subject: 'Your message to Quinn has been received',
html: confirmationHtml(body),
}),
);
}
await Promise.all(sends);
}
// ---------------------------------------------------------------------------
// Roster — availability cache + rate limit + proxy + email
// ---------------------------------------------------------------------------
export function checkRosterRateLimit(ip: string): boolean {
const now = Date.now();
const bucket = rosterRateLimits.get(ip);
if (!bucket || now > bucket.resetAt) {
rosterRateLimits.set(ip, { count: 1, resetAt: now + 10 * 60_000 });
return true;
}
if (bucket.count >= 5) return false;
bucket.count++;
return true;
}
interface CacheEntry {
data: unknown;
expires: number;
}
const availabilityCache = new Map<string, CacheEntry>();
/** Exported for tests only — clears the availability cache. */
export function resetAvailabilityCache(): void {
availabilityCache.clear();
}
async function fetchRosterAvailability(trackSlug?: string): Promise<unknown> {
const cacheKey = trackSlug ?? '__all__';
const now = Date.now();
const cached = availabilityCache.get(cacheKey);
if (cached && now < cached.expires) return cached.data;
const path = trackSlug
? `/public/roster/availability/${trackSlug}`
: '/public/roster/availability';
const res = await fetch(`${QUINN_MY_URL}${path}`, {
method: 'GET',
signal: AbortSignal.timeout(5_000),
});
if (!res.ok) throw new Error(`Roster availability fetch failed: ${res.status}`);
const data: unknown = await res.json();
availabilityCache.set(cacheKey, { data, expires: now + 30_000 });
return data;
}
interface RosterApplyBody {
track: string;
handle: string;
email: string;
phone: string;
interests: string[];
[key: string]: unknown;
}
async function proxyRosterApply(body: RosterApplyBody, clientIp: string): Promise<Response> {
const res = await fetch(`${QUINN_MY_URL}/public/roster/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Real-IP': clientIp,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
const data = await res.json() as { success?: boolean; id?: number; status?: string; error?: string };
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
function rosterNotificationHtml(body: RosterApplyBody): string {
const phoneDisplay = body.phone;
const interests = Array.isArray(body.interests) ? body.interests.join(', ') : '';
return `<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0a0a0f;font-family:Georgia,serif;color:#f0e6d3;">
<div style="max-width:600px;margin:0 auto;padding:32px 24px;">
<h1 style="font-size:24px;color:#D4AF37;margin:0 0 4px;">New Roster Application</h1>
<p style="color:#b8a99a;margin:0 0 32px;font-size:14px;">Track: ${escapeHtml(body.track)} &bull; via transquinnftw.com</p>
<table style="width:100%;border-collapse:collapse;">
<tbody>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;width:140px;vertical-align:top;">Handle</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(body.handle)}</td>
</tr>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Email</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">
<a href="mailto:${escapeHtml(body.email)}" style="color:#D4AF37;text-decoration:none;">${escapeHtml(body.email)}</a>
</td>
</tr>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Phone</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(phoneDisplay)}</td>
</tr>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Track</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(body.track)}</td>
</tr>
<tr>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;color:#b8a99a;vertical-align:top;">Interests</td>
<td style="padding:12px 0;border-bottom:1px solid #1e1e2e;">${escapeHtml(interests)}</td>
</tr>
</tbody>
</table>
<p style="margin-top:24px;color:#b8a99a;font-size:13px;">Review in dashboard: <code>quinn-my roster list --status pending</code></p>
</div>
</body>
</html>`;
}
async function sendRosterNotification(body: RosterApplyBody): Promise<void> {
await mailer.sendMail({
to: ROSTER_TO,
replyTo: body.email,
subject: `Roster Application: ${body.handle} (${body.track})`,
html: rosterNotificationHtml(body),
});
}
// ---------------------------------------------------------------------------
// Server — fetch-style handler (testable) + Node HTTP bridge
// ---------------------------------------------------------------------------
function json(body: unknown, status = 200, corsHeaders: Record<string, string> = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
export async function fetchHandler(req: Request): Promise<Response> {
const url = new URL(req.url);
const origin = req.headers.get('Origin') ?? '';
const method = req.method;
const corsHeaders: Record<string, string> = ALLOWED_ORIGINS.has(origin)
? {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Vary': 'Origin',
}
: {};
if (method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (url.pathname === '/health' && method === 'GET') {
return new Response('ok', { status: 200, headers: { 'Content-Type': 'text/plain' } });
}
// Analytics relay — fire-and-forget forward to collector
if (url.pathname.startsWith('/analytics/track/') && method === 'POST') {
const buf = Buffer.from(await req.arrayBuffer());
const realIp =
req.headers.get('X-Real-IP') ??
req.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ??
'unknown';
const userAgent = req.headers.get('User-Agent') ?? '';
relayAnalyticsEvent(url.pathname, buf, realIp, userAgent);
return new Response(null, { status: 202, headers: corsHeaders });
}
if (url.pathname === '/api/contact' && method === 'POST') {
const ip = req.headers.get('X-Real-IP') ?? 'unknown';
if (!checkRateLimit(ip)) {
return json({ error: 'Too many requests. Please try again later.' }, 429, corsHeaders);
}
let rawBody: unknown;
try {
rawBody = await req.json() as unknown;
} catch {
return json({ error: 'Invalid request body' }, 400, corsHeaders);
}
const validated = validateBody(rawBody);
if (typeof validated === 'string') {
return json({ error: validated }, 400, corsHeaders);
}
// Persist first — if this fails we return 500 (nothing to lose yet)
let submissionId: number;
try {
const persisted = await persistContact(validated);
submissionId = persisted.id;
} catch (err) {
logger.error('Contact persist failed', {
route: 'POST /api/contact',
outcome: 'persist_error',
error: err instanceof Error ? err.message : String(err),
});
return json({ error: 'Failed to save contact submission. Please try again.' }, 500, corsHeaders);
}
// Send emails — failure does NOT lose the lead; outbox worker will retry
let emailed = false;
try {
await sendContactEmails(validated);
emailed = true;
await patchContactStatus(submissionId, 'notified');
logger.info('Contact form submitted', {
route: 'POST /api/contact',
submissionId,
inquiryType: validated.inquiryType,
outcome: 'emailed',
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
logger.error('SMTP delivery failed', {
route: 'POST /api/contact',
submissionId,
outcome: 'smtp_error',
error: errMsg,
});
void patchContactStatus(submissionId, 'failed', errMsg);
}
return json({ success: true, id: submissionId, emailed }, 200, corsHeaders);
}
// Roster availability — cached proxy to quinn.my
if (url.pathname === '/api/roster/availability' && method === 'GET') {
try {
const data = await fetchRosterAvailability();
return json(data, 200, corsHeaders);
} catch (err) {
logger.error('Roster availability proxy failed', {
route: 'GET /api/roster/availability',
error: err instanceof Error ? err.message : String(err),
});
return json({ error: 'Service unavailable' }, 503, corsHeaders);
}
}
// Single track availability
const trackAvailMatch = url.pathname.match(/^\/api\/roster\/availability\/([a-z-]+)$/);
if (trackAvailMatch && method === 'GET') {
try {
const data = await fetchRosterAvailability(trackAvailMatch[1]);
return json(data, 200, corsHeaders);
} catch (err) {
logger.error('Roster track availability proxy failed', {
route: `GET /api/roster/availability/${trackAvailMatch[1]}`,
error: err instanceof Error ? err.message : String(err),
});
return json({ error: 'Service unavailable' }, 503, corsHeaders);
}
}
// Roster application — rate-limited proxy to quinn.my + email notification
if (url.pathname === '/api/roster/apply' && method === 'POST') {
const ip = req.headers.get('X-Real-IP') ?? 'unknown';
if (!checkRosterRateLimit(ip)) {
return json({ error: 'Too many requests. Please try again later.' }, 429, corsHeaders);
}
let rawBody: unknown;
try {
rawBody = await req.json() as unknown;
} catch {
return json({ error: 'Invalid request body' }, 400, corsHeaders);
}
if (!rawBody || typeof rawBody !== 'object' || Array.isArray(rawBody)) {
return json({ error: 'Invalid request body' }, 400, corsHeaders);
}
const body = rawBody as RosterApplyBody;
// Shallow validation — heavy validation lives in quinn.my
if (typeof body.track !== 'string' || !body.track.trim()) {
return json({ error: 'Track is required' }, 400, corsHeaders);
}
if (typeof body.handle !== 'string' || !body.handle.trim()) {
return json({ error: 'Handle is required' }, 400, corsHeaders);
}
try {
const proxyRes = await proxyRosterApply(body, ip);
const proxyData = await proxyRes.json() as { success?: boolean; id?: number; error?: string };
if (proxyRes.status !== 200 || !proxyData.success) {
return json(proxyData, proxyRes.status, corsHeaders);
}
// Fire-and-forget email notification
sendRosterNotification(body).catch((err: unknown) => {
logger.error('Roster notification email failed', {
route: 'POST /api/roster/apply',
error: err instanceof Error ? err.message : String(err),
});
});
// Invalidate availability cache for the track
availabilityCache.delete(body.track);
availabilityCache.delete('__all__');
logger.info('Roster application proxied', {
route: 'POST /api/roster/apply',
track: body.track,
outcome: 'proxied',
});
return json(proxyData, 200, corsHeaders);
} catch (err) {
logger.error('Roster apply proxy failed', {
route: 'POST /api/roster/apply',
error: err instanceof Error ? err.message : String(err),
});
return json({ error: 'Failed to submit application. Please try again.' }, 500, corsHeaders);
}
}
return json({ error: 'Not found' }, 404, corsHeaders);
}
// Node HTTP bridge — delegates to fetchHandler
async function readNodeBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
return Buffer.concat(chunks);
}
const server = createServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
try {
const host = nodeReq.headers['host'] ?? 'localhost';
const buf = await readNodeBody(nodeReq);
const webReq = new Request(`http://${host}${nodeReq.url ?? '/'}`, {
method: nodeReq.method ?? 'GET',
headers: nodeReq.headers as Record<string, string>,
body: buf.length > 0 ? buf : undefined,
});
const webRes = await fetchHandler(webReq);
nodeRes.statusCode = webRes.status;
webRes.headers.forEach((value, key) => nodeRes.setHeader(key, value));
const body = await webRes.text();
nodeRes.end(body || undefined);
} catch (err) {
logger.error('Unhandled request error', {
error: err instanceof Error ? err.message : String(err),
});
nodeRes.statusCode = 500;
nodeRes.end(JSON.stringify({ error: 'Internal server error' }));
}
});
// Only start listening when executed directly (not when imported by tests)
if (process.env['NODE_ENV'] !== 'test') {
server.listen(PORT, () => {
logger.info('Contact API listening', { port: PORT });
});
}