lilith-platform.live/codebase/@features/provider-website/data-api/src/server.ts
autocommit ad5d9bd450 infra(data-api): 🧱 Update data processing pipelines and endpoint configurations for the data API server
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-04 04:45:05 -07:00

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();
});