269 lines
12 KiB
TypeScript
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;
|
|
}
|