278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
/**
|
|
* Provider Website — Data API
|
|
*
|
|
* Node.js HTTP server. Serves Quinn's ProviderData as JSON for the provider-website
|
|
* frontend when apiBaseUrl is configured in ProviderSiteConfig.
|
|
*
|
|
* Data source: Postgres admin database (read from admin-api's QUINN_ADMIN_DB_URL).
|
|
*
|
|
* Env vars:
|
|
* PORT — HTTP listen port (default: 3022)
|
|
* DATA_API_TOKEN — When set, all data endpoints require
|
|
* Authorization: Bearer <token>. Unset = open (dev).
|
|
* QUINN_ADMIN_DB_URL — Postgres admin database URL (required)
|
|
* CSS_TRAPS_JSON — Path to css-traps.json (default: quinn.www/root/public/photos/css-traps.json)
|
|
* TOUR_SOURCE — 'api' | 'admin' (default: 'admin')
|
|
* When 'api': fetch tour stops from QUINN_API_URL /www/tour-stops.
|
|
* When 'admin': use existing admin DB serialization (tour: []).
|
|
*/
|
|
|
|
import { createServer } from 'node:http';
|
|
import { existsSync, statSync } from 'node:fs';
|
|
import { openDb } from '../../../api/src/shared/db/index';
|
|
import { logger } from './logger';
|
|
import { serializeFromDb } from './serialize';
|
|
import { loadRestoreKeys as loadRestoreKeysFromFile, type LoadRestoreKeysStatus } from './loadRestoreKeys';
|
|
|
|
const PORT = parseInt(process.env['PORT'] ?? '3022', 10);
|
|
const DATA_API_TOKEN = process.env['DATA_API_TOKEN'] ?? '';
|
|
const TOUR_SOURCE = (process.env['TOUR_SOURCE'] ?? 'admin') === 'api' ? 'api' : 'admin';
|
|
|
|
const QUINN_API_URL = process.env['QUINN_API_URL'] ?? 'http://localhost:3030';
|
|
|
|
const QUINN_ADMIN_DB_URL = process.env['QUINN_ADMIN_DB_URL'] ?? 'postgres://localhost:25435/quinn_admin';
|
|
|
|
const sql = openDb(QUINN_ADMIN_DB_URL);
|
|
|
|
/** Path to css-traps.json written by the my backend after photo distortion. */
|
|
const CSS_TRAPS_JSON_PATH = process.env['CSS_TRAPS_JSON'] ?? new URL(
|
|
'../../../../../deployments/@domains/quinn.www/root/public/photos/css-traps.json',
|
|
import.meta.url,
|
|
).pathname;
|
|
|
|
const DEFAULT_ORIGINS = [
|
|
'https://transquinnftw.com',
|
|
'https://www.transquinnftw.com',
|
|
'https://quinn.apricot.lan',
|
|
];
|
|
|
|
const ALLOWED_ORIGINS = new Set(
|
|
process.env['ALLOWED_ORIGINS']
|
|
? process.env['ALLOWED_ORIGINS'].split(',').map((o) => o.trim())
|
|
: DEFAULT_ORIGINS,
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// In-memory cache with last_modified invalidation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Cache key blends the latest `updated_at` across every source table the
|
|
* serializer reads, plus the css-traps.json mtime. Any admin write bumps
|
|
* `updated_at` in its row → cache busts on the next request. The previous
|
|
* `_admin_migrations.applied_at` key only changed on schema migrations, so
|
|
* data writes (rates, identity, gallery edits) never invalidated the cache
|
|
* and required a service restart to surface.
|
|
*/
|
|
const CONTENT_TABLES = [
|
|
'about', 'contact', 'cult_of_lilith', 'destinations', 'etiquette_items',
|
|
'etiquette_sections', 'gallery_items', 'hobby_terms', 'identity', 'link_values',
|
|
'physical', 'policy_items', 'policy_sections', 'positioning_tags',
|
|
'rate_entries', 'rate_sections', 'regions', 'roster_track_content',
|
|
'specialties', 'tour_stops', 'verified_profiles',
|
|
] as const;
|
|
|
|
let cachedJson: string | null = null;
|
|
let cachedLastModified: string | null = null;
|
|
|
|
let lastRestoreKeyStatus: LoadRestoreKeysStatus | null = null;
|
|
let lastRestoreKeyCount = -1;
|
|
|
|
function loadRestoreKeys(): Map<string, string> {
|
|
const result = loadRestoreKeysFromFile(CSS_TRAPS_JSON_PATH);
|
|
if (result.status !== lastRestoreKeyStatus || result.count !== lastRestoreKeyCount) {
|
|
if (result.status === 'ok') {
|
|
logger.info('css-traps loaded', { path: result.path, count: result.count });
|
|
} else {
|
|
logger.warn('css-traps unavailable — gallery restoreKeys will be missing', {
|
|
path: result.path,
|
|
status: result.status,
|
|
});
|
|
}
|
|
lastRestoreKeyStatus = result.status;
|
|
lastRestoreKeyCount = result.count;
|
|
}
|
|
return result.keys;
|
|
}
|
|
|
|
async function getDataJson(): Promise<string | null> {
|
|
try {
|
|
const unionSql = CONTENT_TABLES
|
|
.map((t) => `SELECT MAX(updated_at) AS ts FROM ${t}`)
|
|
.join(' UNION ALL ');
|
|
const row = await sql.unsafe<Array<{ ts: string | null }>>(`SELECT MAX(ts) AS ts FROM (${unionSql}) m`);
|
|
const lastModified = row[0]?.ts ?? new Date().toISOString();
|
|
|
|
// Include css-traps.json mtime so cache busts when photos are re-distorted
|
|
const cssTrapsModified = existsSync(CSS_TRAPS_JSON_PATH)
|
|
? String(statSync(CSS_TRAPS_JSON_PATH).mtimeMs)
|
|
: 'absent';
|
|
const cacheKey = `${lastModified}:${cssTrapsModified}`;
|
|
|
|
if (cachedJson && cacheKey === cachedLastModified) {
|
|
return cachedJson;
|
|
}
|
|
|
|
const restoreKeys = loadRestoreKeys();
|
|
const data = await serializeFromDb(sql, restoreKeys);
|
|
cachedJson = JSON.stringify(data);
|
|
cachedLastModified = cacheKey;
|
|
return cachedJson;
|
|
} catch (err) {
|
|
logger.error('Failed to read from Postgres', { error: String(err) });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tour stops from @features/api (cached, 60s TTL)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ApiTourStop {
|
|
id: number;
|
|
city: string;
|
|
state: string;
|
|
country: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
status: 'confirmed' | 'conditional' | 'sold-out';
|
|
fmtyRate: number | null;
|
|
travelFee: number | null;
|
|
pricingTiers: { incall?: number; outcall?: number; overnight?: number } | null;
|
|
notes: string;
|
|
incallAvailable: boolean;
|
|
lat: number | null;
|
|
lng: number | null;
|
|
}
|
|
|
|
interface TourStopCache { stops: ApiTourStop[]; fetchedAt: number }
|
|
let cachedTourStops: TourStopCache | null = null;
|
|
const TOUR_TTL_MS = 60_000;
|
|
|
|
async function fetchTourStops(): Promise<ApiTourStop[]> {
|
|
const now = Date.now();
|
|
if (cachedTourStops && now - cachedTourStops.fetchedAt < TOUR_TTL_MS) {
|
|
return cachedTourStops.stops;
|
|
}
|
|
try {
|
|
const res = await fetch(`${QUINN_API_URL}/www/tour/stops`, {
|
|
signal: AbortSignal.timeout(3_000),
|
|
});
|
|
if (!res.ok) {
|
|
logger.warn('@features/api /www/tour/stops returned non-OK', { status: res.status });
|
|
return cachedTourStops?.stops ?? [];
|
|
}
|
|
const stops = await res.json() as ApiTourStop[];
|
|
cachedTourStops = { stops, fetchedAt: now };
|
|
return stops;
|
|
} catch (err) {
|
|
logger.warn('Failed to fetch tour stops from @features/api', { error: String(err) });
|
|
return cachedTourStops?.stops ?? [];
|
|
}
|
|
}
|
|
|
|
function serializeTourStops(stops: ApiTourStop[]): { tour: Record<string, unknown>[]; currentLocation: Record<string, unknown> | null } {
|
|
const todayIso = new Date().toISOString().slice(0, 10);
|
|
const tour = stops.map((s) => ({
|
|
city: s.city,
|
|
state: s.state || undefined,
|
|
country: s.country || undefined,
|
|
startDate: s.startDate,
|
|
endDate: s.endDate,
|
|
status: todayIso < s.startDate ? 'upcoming' : todayIso <= s.endDate ? 'active' : 'completed',
|
|
bookingStatus: s.status,
|
|
pricingTiers: s.pricingTiers ?? undefined,
|
|
notes: s.notes || undefined,
|
|
lat: s.lat ?? null,
|
|
lng: s.lng ?? null,
|
|
}));
|
|
const active = stops.find((s) => s.startDate <= todayIso && s.endDate >= todayIso);
|
|
const currentLocation = active
|
|
? { city: active.city, state: active.state || null, country: active.country, incallAvailable: active.incallAvailable, publicNote: active.notes || null }
|
|
: null;
|
|
return { tour, currentLocation };
|
|
}
|
|
|
|
async function getDataJsonWithTouring(): Promise<string | null> {
|
|
const baseJson = await getDataJson();
|
|
if (!baseJson) return null;
|
|
|
|
if (TOUR_SOURCE !== 'api') return baseJson;
|
|
|
|
const stops = await fetchTourStops();
|
|
if (stops.length === 0) return baseJson;
|
|
|
|
try {
|
|
const data = JSON.parse(baseJson) as Record<string, unknown>;
|
|
const { tour, currentLocation } = serializeTourStops(stops);
|
|
data['tour'] = tour;
|
|
data['currentLocation'] = currentLocation;
|
|
return JSON.stringify(data);
|
|
} catch (err) {
|
|
logger.warn('Failed to inject tour stops', { error: String(err) });
|
|
return baseJson;
|
|
}
|
|
}
|
|
|
|
function getCorsHeaders(origin: string): Record<string, string> {
|
|
if (!ALLOWED_ORIGINS.has(origin)) return {};
|
|
return {
|
|
'Access-Control-Allow-Origin': origin,
|
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
'Vary': 'Origin',
|
|
};
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const origin = req.headers['origin'] ?? '';
|
|
const corsHeaders = getCorsHeaders(origin);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, corsHeaders);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
if (req.method !== 'GET') {
|
|
res.writeHead(405, { 'Content-Type': 'application/json', ...corsHeaders });
|
|
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
return;
|
|
}
|
|
|
|
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
|
|
|
|
if (url.pathname === '/health') {
|
|
res.writeHead(200);
|
|
res.end('ok');
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/' || url.pathname === '/api/data') {
|
|
const authHeader = req.headers['authorization'] ?? null;
|
|
if (DATA_API_TOKEN && authHeader !== `Bearer ${DATA_API_TOKEN}`) {
|
|
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer', ...corsHeaders });
|
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
return;
|
|
}
|
|
const json = await getDataJsonWithTouring();
|
|
if (!json) {
|
|
res.writeHead(503, { 'Content-Type': 'application/json', ...corsHeaders });
|
|
res.end(JSON.stringify({ error: 'Database unavailable' }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json', ...corsHeaders });
|
|
res.end(json);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { 'Content-Type': 'application/json', ...corsHeaders });
|
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
logger.info('Data API listening', { port: PORT, auth: DATA_API_TOKEN ? 'token' : 'open', db: QUINN_ADMIN_DB_URL, api: QUINN_API_URL, tourSource: TOUR_SOURCE });
|
|
// Prime the restore-key cache at boot so operators see the status immediately
|
|
// in journalctl, rather than waiting for the first /api/data request.
|
|
loadRestoreKeys();
|
|
});
|