800 lines
29 KiB
TypeScript
800 lines
29 KiB
TypeScript
/**
|
||
* 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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 24–48 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)} • 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 });
|
||
});
|
||
}
|