lilith-platform.live/codebase/@features/provider-website/data-api/src/serialize.ts
2026-04-09 20:54:16 -07:00

269 lines
12 KiB
TypeScript

/**
* Database → ProviderData serialization
*
* Reads all tables from the admin SQLite database and assembles
* the ProviderData JSON shape expected by the frontend.
*/
import type { DatabaseSync } from 'node:sqlite';
type Row = Record<string, unknown>;
export function serializeFromDb(db: DatabaseSync, restoreKeys: Map<string, string> = new Map()): Record<string, unknown> {
// Identity
const identity = db.prepare('SELECT * FROM identity WHERE id = 1').get() as Row | null;
const identityData = identity ? {
name: identity['name'], pronouns: identity['pronouns'], gender: identity['gender'],
location: identity['location'], incallCity: identity['incall_city'] ?? undefined,
secondaryLocations: parseJson(identity['secondary_locations']),
languages: parseJson(identity['languages']),
tagline: identity['tagline'],
} : {};
// Physical
const physical = db.prepare('SELECT * FROM physical WHERE id = 1').get() as Row | null;
const physicalData = physical ? {
age: physical['age'], height: physical['height'], bodyType: physical['body_type'],
ethnicity: physical['ethnicity'], hairColor: physical['hair_color'],
hairLength: physical['hair_length'], eyeColor: physical['eye_color'],
cupSize: physical['cup_size'],
additional: parseJson(physical['additional']),
} : {};
// Rates
const sections = db.prepare('SELECT * FROM rate_sections ORDER BY sort_order').all() as Row[];
const entries = db.prepare('SELECT * FROM rate_entries ORDER BY sort_order').all() as Row[];
const entriesBySection = groupBy(entries, 'section_id');
const serializeEntries = (sectionId: number) =>
(entriesBySection.get(sectionId) ?? []).map((e) => ({
service: e['service'], duration: e['duration'] ?? undefined,
price: e['price'], priceMax: e['price_max'] ?? undefined,
description: e['description'] ?? undefined, notes: e['notes'] ?? undefined,
}));
const rateSections = sections.filter((s) => s['section_type'] === 'incall' || s['section_type'] === 'outcall');
const addOnSection = sections.find((s) => s['section_type'] === 'addons');
const touringSection = sections.find((s) => s['section_type'] === 'touring');
const onlineSection = sections.find((s) => s['section_type'] === 'online');
const rates = rateSections.map((s) => ({
title: s['title'], description: s['description'] ?? undefined,
entries: serializeEntries(s['id'] as number),
}));
const makeGroup = (section: Row | undefined) =>
section
? { description: section['description'] ?? undefined, entries: serializeEntries(section['id'] as number) }
: { entries: [] };
// Tour
const tourRows = db.prepare('SELECT * FROM tour_stops ORDER BY sort_order').all() as Row[];
const tour = tourRows.map((t) => ({
city: t['city'], state: t['state'], startDate: t['start_date'], endDate: t['end_date'],
status: t['status'], fmtyRate: t['fmty_rate'] ?? undefined,
travelFee: t['travel_fee'] ?? undefined, notes: t['notes'] ?? undefined,
}));
// Gallery — merge WASM restore keys (keyed by filename, sourced from css-traps.json)
const galleryRows = db.prepare('SELECT * FROM gallery_items ORDER BY sort_order').all() as Row[];
const gallery = galleryRows.map((g) => {
const webpFilename = g['webp_filename'] as string | null;
// Prefer the WebP restore key since WasmImage fetches webpSrc first
const restoreKey = (webpFilename && restoreKeys.get(webpFilename)) ?? restoreKeys.get(g['filename'] as string) ?? undefined;
return {
src: `/photos/${g['filename']}`, alt: g['alt'],
category: g['category'] ?? undefined, featured: g['featured'] === 1 ? true : undefined,
webpSrc: webpFilename ? `/photos/${webpFilename}` : undefined,
intrinsicWidth: g['intrinsic_width'] ?? undefined, intrinsicHeight: g['intrinsic_height'] ?? undefined,
...(restoreKey ? { restoreKey } : {}),
};
});
// Contact
const contact = db.prepare('SELECT * FROM contact WHERE id = 1').get() as Row | null;
const contactData = contact ? {
phone: contact['phone'], whatsapp: contact['whatsapp'] ?? undefined,
email: contact['email'] ?? undefined, instagram: contact['instagram'] ?? undefined,
twitter: contact['twitter'] ?? undefined, threads: contact['threads'] ?? undefined,
snapchat: contact['snapchat'] ?? undefined, youtube: contact['youtube'] ?? undefined,
onlyfans: contact['onlyfans'] ?? undefined, transfans: contact['transfans'] ?? undefined,
fansly: contact['fansly'] ?? undefined, loyalfans: contact['loyalfans'] ?? undefined,
fancentro: contact['fancentro'] ?? undefined, fantime: contact['fantime'] ?? undefined,
tryst: contact['tryst'] ?? undefined,
bluesky: contact['bluesky'] ?? undefined,
manyvids: contact['manyvids'] ?? undefined,
website: contact['website'] ?? undefined,
linktree: contact['linktree'] ?? undefined,
communicationNote: contact['communication_note'], responseTime: contact['response_time'],
availabilityNote: contact['availability_note'] ?? undefined,
paymentMethods: parseJson(contact['payment_methods']),
} : {};
// Policies
const policySections = db.prepare('SELECT * FROM policy_sections ORDER BY sort_order').all() as Row[];
const policyItems = db.prepare('SELECT * FROM policy_items ORDER BY sort_order').all() as Row[];
const policyItemsBySection = groupBy(policyItems, 'section_id');
const policies = policySections.map((s) => ({
title: s['title'],
items: (policyItemsBySection.get(s['id'] as number) ?? []).map((i) => ({
label: i['label'], detail: i['detail'],
})),
}));
// Etiquette
const etiquetteSections = db.prepare('SELECT * FROM etiquette_sections ORDER BY sort_order').all() as Row[];
const etiquetteItems = db.prepare('SELECT * FROM etiquette_items ORDER BY sort_order').all() as Row[];
const etiquetteItemsBySection = groupBy(etiquetteItems, 'section_id');
const etiquette = etiquetteSections.map((s) => ({
title: s['title'],
items: (etiquetteItemsBySection.get(s['id'] as number) ?? []).map((i) => ({
label: i['label'], detail: i['detail'] ?? undefined,
ctaHref: i['cta_href'] ?? undefined, ctaText: i['cta_text'] ?? undefined,
})),
}));
// About
const about = db.prepare('SELECT * FROM about WHERE id = 1').get() as Row | null;
const activities = db.prepare('SELECT * FROM activity_menus ORDER BY sort_order').all() as Row[];
const aboutData = about ? {
bio: about['bio'], personality: parseJson(about['personality']),
availableFor: parseJson(about['available_for']),
availableTo: parseJson(about['available_to']),
activities: activities.map((a) => ({ category: a['category'], items: parseJson(a['items']) })),
} : {};
// Destinations
const destRows = db.prepare('SELECT * FROM destinations ORDER BY sort_order').all() as Row[];
const destinations = destRows.map((d) => ({
slug: d['slug'], city: d['city'], country: d['country'], region: d['region'] ?? undefined,
fmtyTier: d['fmty_tier'], metaTitle: d['meta_title'], metaDescription: d['meta_description'],
headline: d['headline'], intro: d['intro'],
linkedTourStop: d['linked_tour_stop'] === 1 ? true : undefined,
experiences: parseJson(d['experiences']), note: d['note'] ?? undefined,
}));
// Specialties
const specialtyRows = db.prepare('SELECT * FROM specialties ORDER BY category_slug, sort_order').all() as Row[];
const specialtyCategoryMap = new Map<string, { name: string; metaTitle: string; metaDescription: string; intro: string; items: Row[] }>();
for (const s of specialtyRows) {
const catSlug = s['category_slug'] as string;
if (!specialtyCategoryMap.has(catSlug)) {
specialtyCategoryMap.set(catSlug, {
name: s['category_name'] as string, metaTitle: s['category_meta_title'] as string,
metaDescription: s['category_meta_description'] as string, intro: s['category_intro'] as string, items: [],
});
}
specialtyCategoryMap.get(catSlug)!.items.push({
slug: s['slug'], name: s['name'], categorySlug: catSlug,
metaTitle: s['meta_title'], metaDescription: s['meta_description'],
headline: s['headline'], intro: s['intro'],
includes: s['includes'] ? parseJson(s['includes']) : undefined,
note: s['note'] ?? undefined, relatedRateType: s['related_rate_type'] ?? undefined,
});
}
const specialties = Array.from(specialtyCategoryMap.entries()).map(([slug, cat]) => ({
slug, name: cat.name, metaTitle: cat.metaTitle, metaDescription: cat.metaDescription,
intro: cat.intro, items: cat.items,
}));
// Site Text
const textRows = db.prepare('SELECT namespace, key, value FROM site_text ORDER BY namespace, key').all() as { namespace: string; key: string; value: string }[];
const siteText: Record<string, Record<string, string>> = {};
for (const row of textRows) {
if (!siteText[row.namespace]) siteText[row.namespace] = {};
siteText[row.namespace][row.key] = row.value;
}
// Shop listings (only available + sold — not hidden)
const shopRows = db.prepare(`
SELECT * FROM shop_listings
WHERE status != 'hidden'
ORDER BY sort_order, created_at
`).all() as Row[];
const shopPhotoRows = db.prepare(`
SELECT * FROM shop_listing_photos ORDER BY listing_id, sort_order
`).all() as Row[];
const photosByListing = groupBy(shopPhotoRows, 'listing_id');
const shop = shopRows.map((s) => ({
id: s['id'],
slug: s['slug'],
title: s['title'],
description: s['description'],
price: s['price'],
currency: s['currency'],
condition: s['condition'],
category: s['category'],
size: s['size'] ?? undefined,
status: s['status'],
photos: (photosByListing.get(s['id'] as number) ?? []).map((p) => ({
src: `/photos/${p['filename']}`,
webpSrc: p['webp_filename'] ? `/photos/${p['webp_filename']}` : undefined,
width: p['width'],
height: p['height'],
})),
}));
// Roster track content
let rosterContent: Record<string, unknown>[] | undefined;
try {
const rosterRows = db.prepare('SELECT * FROM roster_track_content ORDER BY sort_order').all() as Row[];
if (rosterRows.length > 0) {
rosterContent = rosterRows.map((r) => ({
slug: r['slug'], name: r['name'],
metaTitle: r['meta_title'], metaDescription: r['meta_description'],
heroLine: r['hero_line'],
description: parseJson(r['description']),
whatToExpect: parseJson(r['what_to_expect']),
interestsConfig: parseJson(r['interests_config']),
}));
}
} catch {
// Table may not exist yet on older admin DBs
}
// Cult of Lilith lore
let cultOfLilith: Record<string, unknown>[] | undefined;
try {
const cultRows = db.prepare('SELECT * FROM cult_of_lilith ORDER BY sort_order').all() as Row[];
if (cultRows.length > 0) {
cultOfLilith = cultRows.map((c) => ({
sectionKey: c['section_key'], title: c['title'], body: c['body'],
}));
}
} catch {
// Table may not exist yet on older admin DBs
}
return {
identity: identityData, physical: physicalData,
rates, addOns: makeGroup(addOnSection), touringPackages: makeGroup(touringSection),
onlineServices: makeGroup(onlineSection),
tour, gallery, contact: contactData, policies, about: aboutData,
destinations, specialties: specialties.length > 0 ? specialties : undefined,
etiquette: etiquette.length > 0 ? etiquette : undefined,
siteText: Object.keys(siteText).length > 0 ? siteText : undefined,
shop: shop.length > 0 ? shop : undefined,
rosterContent,
cultOfLilith,
};
}
function parseJson(value: unknown): unknown {
if (typeof value !== 'string') return value;
try { return JSON.parse(value); } catch { return value; }
}
function groupBy(rows: Row[], key: string): Map<number, Row[]> {
const map = new Map<number, Row[]>();
for (const row of rows) {
const k = row[key] as number;
const list = map.get(k) ?? [];
list.push(row);
map.set(k, list);
}
return map;
}