lilith-platform.live/codebase/@features/admin/backend-api/src/db.ts
2026-04-09 20:47:07 -07:00

824 lines
37 KiB
TypeScript

/**
* Database — Bun SQLite connection and schema management.
*
* Lazy-initialized singleton. All tables created on first initSchema() call.
* DB path configurable via DB_PATH env var; defaults to ./data/quinn.db
*/
import { DatabaseSync } from 'node:sqlite';
import { logger } from './logger';
let db: DatabaseSync | null = null;
export function getDbPath(): string {
return process.env['DB_PATH'] ?? './data/quinn.db';
}
export function getDb(): DatabaseSync {
if (!db) {
const dbPath = getDbPath();
db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
}
return db;
}
export function reinitDb(): void {
if (db) {
try { db.close(); } catch { /* already closed */ }
}
db = null;
initSchema();
}
export function initSchema(): void {
const d = getDb();
d.exec(`
CREATE TABLE IF NOT EXISTS admin_auth (
id INTEGER PRIMARY KEY CHECK (id = 1),
passphrase_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
pronouns TEXT NOT NULL,
gender TEXT NOT NULL,
location TEXT NOT NULL,
incall_city TEXT,
tagline TEXT NOT NULL,
secondary_locations TEXT NOT NULL DEFAULT '[]',
languages TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS physical (
id INTEGER PRIMARY KEY CHECK (id = 1),
age INTEGER NOT NULL,
height TEXT NOT NULL,
body_type TEXT NOT NULL,
ethnicity TEXT NOT NULL,
hair_color TEXT NOT NULL,
hair_length TEXT,
eye_color TEXT NOT NULL,
cup_size TEXT NOT NULL,
additional TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS contact (
id INTEGER PRIMARY KEY CHECK (id = 1),
phone TEXT NOT NULL,
whatsapp TEXT,
email TEXT,
instagram TEXT,
twitter TEXT,
threads TEXT,
snapchat TEXT,
youtube TEXT,
onlyfans TEXT,
transfans TEXT,
fansly TEXT,
loyalfans TEXT,
fancentro TEXT,
fantime TEXT,
tryst TEXT,
communication_note TEXT NOT NULL,
response_time TEXT NOT NULL,
availability_note TEXT,
payment_methods TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Additive migrations for existing DBs — ignore "duplicate column" errors
for (const col of ['fansly', 'loyalfans', 'fancentro', 'fantime']) {
try { d.exec(`ALTER TABLE contact ADD COLUMN ${col} TEXT`); } catch { /* already exists */ }
}
d.exec(`
CREATE TABLE IF NOT EXISTS about (
id INTEGER PRIMARY KEY CHECK (id = 1),
bio TEXT NOT NULL,
personality TEXT NOT NULL DEFAULT '[]',
available_for TEXT NOT NULL DEFAULT '[]',
available_to TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS activity_menus (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
items TEXT NOT NULL DEFAULT '[]',
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS rate_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS rate_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_id INTEGER NOT NULL REFERENCES rate_sections(id) ON DELETE CASCADE,
service TEXT NOT NULL,
duration TEXT,
price INTEGER NOT NULL,
price_max INTEGER,
description TEXT,
notes TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS tour_stops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city TEXT NOT NULL,
state TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
status TEXT NOT NULL,
notes TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Additive migrations — drop legacy rate fields from tour_stops
for (const col of ['fmty_rate', 'travel_fee']) {
try { d.exec(`ALTER TABLE tour_stops DROP COLUMN ${col}`); } catch { /* already dropped or never existed */ }
}
d.exec(`
CREATE TABLE IF NOT EXISTS gallery_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
alt TEXT NOT NULL,
category TEXT,
featured INTEGER NOT NULL DEFAULT 0,
webp_filename TEXT,
intrinsic_width INTEGER,
intrinsic_height INTEGER,
protection_status TEXT NOT NULL DEFAULT 'protected',
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS policy_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS policy_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_id INTEGER NOT NULL REFERENCES policy_sections(id) ON DELETE CASCADE,
label TEXT NOT NULL,
detail TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS destinations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
city TEXT NOT NULL,
country TEXT NOT NULL,
region TEXT,
fmty_tier TEXT NOT NULL,
meta_title TEXT NOT NULL,
meta_description TEXT NOT NULL,
headline TEXT NOT NULL,
intro TEXT NOT NULL,
linked_tour_stop INTEGER NOT NULL DEFAULT 0,
experiences TEXT NOT NULL DEFAULT '[]',
note TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS specialties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_slug TEXT NOT NULL,
category_name TEXT NOT NULL,
category_meta_title TEXT NOT NULL,
category_meta_description TEXT NOT NULL,
category_intro TEXT NOT NULL,
slug TEXT NOT NULL,
name TEXT NOT NULL,
meta_title TEXT NOT NULL,
meta_description TEXT NOT NULL,
headline TEXT NOT NULL,
intro TEXT NOT NULL,
includes TEXT,
note TEXT,
related_rate_type TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS site_text (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (namespace, key)
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS etiquette_sections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS etiquette_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_id INTEGER NOT NULL REFERENCES etiquette_sections(id) ON DELETE CASCADE,
label TEXT NOT NULL,
detail TEXT,
cta_href TEXT,
cta_text TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Additive migrations for existing etiquette_items DBs
for (const col of ['cta_href TEXT', 'cta_text TEXT']) {
try { d.prepare(`SELECT ${col.split(' ')[0]} FROM etiquette_items LIMIT 0`).all(); } catch {
d.prepare(`ALTER TABLE etiquette_items ADD COLUMN ${col}`).run();
}
}
// Illustration columns for destinations
for (const col of [
'illustration_image TEXT',
"illustration_side TEXT NOT NULL DEFAULT 'right'",
"illustration_height TEXT NOT NULL DEFAULT '360px'",
'illustration_opacity REAL NOT NULL DEFAULT 0.85',
]) {
try { d.prepare('ALTER TABLE destinations ADD COLUMN ' + col).run(); } catch { /* already exists */ }
}
// Illustration columns for specialties
for (const col of [
'illustration_image TEXT',
"illustration_side TEXT NOT NULL DEFAULT 'right'",
"illustration_height TEXT NOT NULL DEFAULT '360px'",
'illustration_opacity REAL NOT NULL DEFAULT 0.85',
]) {
try { d.prepare('ALTER TABLE specialties ADD COLUMN ' + col).run(); } catch { /* already exists */ }
}
d.exec(`
CREATE TABLE IF NOT EXISTS touring_subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
city_interest TEXT,
source TEXT NOT NULL,
source_city TEXT,
subscribed_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(email)
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS link_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_name TEXT NOT NULL,
label TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (event_name, label)
)
`);
// Seed default link value scores for all known tracked labels
const seeds: Array<[string, string, number]> = [
// Fan platform outlinks — highest conversion value
['links_social', 'onlyfans', 10],
['links_social', 'fansly', 10],
['links_social', 'transfans', 9],
['links_social', 'loyalfans', 9],
['links_social', 'fancentro', 8],
['links_social', 'fantime', 8],
['links_social', 'tryst', 7],
['links_social', 'whatsapp', 4],
['links_social', 'snapchat', 2],
['links_social', 'instagram', 3],
['links_social', 'twitter', 2],
['links_social', 'threads', 1],
['links_social', 'youtube', 1],
['social_click', 'onlyfans', 10],
['social_click', 'fansly', 10],
['social_click', 'transfans', 9],
['social_click', 'loyalfans', 9],
['social_click', 'fancentro', 8],
['social_click', 'fantime', 8],
['social_click', 'tryst', 7],
['social_click', 'whatsapp', 4],
['social_click', 'snapchat', 2],
['social_click', 'instagram', 3],
['social_click', 'twitter', 2],
['social_click', 'threads', 1],
['social_click', 'youtube', 1],
// Contact/booking outlinks
['contact_link', 'sms', 8],
['contact_link', 'whatsapp', 6],
['contact_link', 'email', 5],
['tour_contact', 'sms', 8],
['tour_contact', 'whatsapp', 6],
['tour_contact', 'email', 5],
// LinksPage external card clicks (same destinations as links_social, higher intent via dedicated card)
['links_external', 'onlyfans', 10],
['links_external', 'fansly', 10],
['links_external', 'transfans', 9],
['links_external', 'loyalfans', 9],
['links_external', 'fancentro', 8],
['links_external', 'fantime', 8],
['links_external', 'tryst', 7],
['links_external', 'whatsapp', 5],
['links_external', 'sms', 6],
['links_external', 'instagram', 3],
['links_external', 'twitter', 2],
// ContactCard payment method links
['payment_link', 'cashapp', 6],
['payment_link', 'venmo', 5],
['payment_link', 'zelle', 5],
// BookingGuide inline SMS — highest intent (clicked phone number in booking flow)
['booking_link', 'sms', 9],
// Footer links
['footer_social', 'onlyfans', 10],
['footer_social', 'fansly', 10],
['footer_social', 'transfans', 9],
['footer_social', 'tryst', 7],
['footer_social', 'instagram', 3],
['footer_social', 'twitter', 2],
['footer_contact', 'sms', 8],
['footer_payment', 'cashapp', 6],
['footer_payment', 'venmo', 5],
['footer_payment', 'zelle', 5],
];
const insert = d.prepare(
'INSERT OR IGNORE INTO link_values (event_name, label, score) VALUES (?, ?, ?)',
);
for (const [eventName, label, score] of seeds) {
insert.run(eventName, label, score);
}
// Seed default site text values (INSERT OR IGNORE — never overwrite user edits)
const siteTextDefaults: Array<[string, string, string]> = [
// nav
['nav', 'home', 'Home'],
['nav', 'gallery', 'Gallery'],
['nav', 'rates', 'Rates'],
['nav', 'tour', 'Tour'],
['nav', 'about', 'About'],
['nav', 'links', 'Links'],
['nav', 'book', 'Book'],
['nav', 'contact', 'Contact'],
// hero
['hero', 'badge_available', 'Available'],
['hero', 'badge_here_now', 'Here Now'],
['hero', 'badge_next_stop', 'Next Stop'],
['hero', 'cta_book', 'Book Now'],
['hero', 'cta_gallery', 'Gallery'],
['hero', 'cta_tour_dates', 'Tour Dates'],
// home
['home', 'section_rates', 'Rates'],
['home', 'section_gallery', 'Gallery'],
['home', 'section_tour', 'Tour'],
['home', 'cta_full_rates', 'Full rates & services'],
['home', 'cta_full_schedule', 'Full schedule & availability'],
['home', 'cta_view_all_photos', 'View all photos'],
['home', 'reveal_hint', 'Tap to reveal'],
// about
['about', 'section_title', 'About'],
['about', 'section_details', 'Details'],
['about', 'heading_physical', 'Physical'],
['about', 'heading_appearance', 'Appearance & Identity'],
['about', 'heading_available_for', 'Available For'],
['about', 'heading_available_to', 'Available To'],
['about', 'heading_menu', 'Menu'],
['about', 'link_see_schedule', "see where I'm headed →"],
['about', 'link_full_schedule', 'full schedule →'],
['about', 'stat_age', 'Age'],
['about', 'stat_height', 'Height'],
['about', 'stat_weight', 'Weight'],
['about', 'stat_body_type', 'Body Type'],
['about', 'stat_cup_size', 'Cup Size'],
['about', 'stat_bust', 'Bust'],
['about', 'stat_waist', 'Waist'],
['about', 'stat_hips', 'Hips'],
['about', 'stat_ethnicity', 'Ethnicity'],
['about', 'stat_hair', 'Hair'],
['about', 'stat_hair_length', 'Hair Length'],
['about', 'stat_eyes', 'Eyes'],
['about', 'stat_tattoos', 'Tattoos'],
['about', 'stat_piercings', 'Piercings'],
['about', 'stat_trans_status', 'Trans Status'],
['about', 'stat_sexual_role', 'Sexual Role'],
['about', 'stat_languages', 'Languages'],
['about', 'meta_title', 'About Quinn'],
['about', 'meta_description', 'Background, vibe, and what to expect.'],
// rates
['rates', 'section_title', 'Rates'],
['rates', 'subtitle', 'All prices in USD'],
['rates', 'label_addons', 'Add-Ons'],
['rates', 'label_fmty', 'Fly Me To You (FMTY)'],
['rates', 'label_online_services', 'Online Services'],
['rates', 'cta_tour_schedule', 'See tour schedule →'],
['rates', 'cta_how_to_book', 'How to book →'],
['rates', 'cta_contact', 'Contact →'],
['rates', 'meta_title', 'Rates — Quinn'],
['rates', 'meta_description', 'Service menu and rates.'],
// tour
['tour', 'section_title', 'Tour Schedule'],
['tour', 'subtitle', '2026 World Tour'],
['tour', 'note_1', 'Dates shift based on bookings — text early if a city interests you.'],
['tour', 'note_2', 'Deposits required for all touring bookings. Hotel provided as incall at each stop — book at standard rates.'],
['tour', 'note_3', 'For international cities,'],
['tour', 'home_base_label', 'Current Location'],
['tour', 'fmty_section_title', 'Fly Me To You'],
['tour', 'fmty_subtitle', 'Quinn comes to you — anywhere in the world'],
['tour', 'fmty_description_1', 'Fly Me To You means Quinn travels directly to your city — no logistics on your end. She handles her own flights and accommodation; everything is bundled into a single all-inclusive rate covering travel, hotel, and your full session together.'],
['tour', 'fmty_description_2', 'FMTY is available anywhere in the world — not limited to current tour cities. Rates vary by region: West Coast and Las Vegas are the lowest tier, all other domestic destinations are mid-range, and international travel is priced separately. Text to discuss your city and preferred dates; a deposit is required to secure the trip.'],
['tour', 'section_destinations', 'Destinations'],
['tour', 'subtitle_destinations', 'Browse cities Quinn travels to'],
['tour', 'cta_all_destinations', 'View all destinations →'],
['tour', 'tier_west_coast', 'West Coast'],
['tour', 'tier_north_america', 'North America'],
['tour', 'tier_international', 'International'],
['tour', 'meta_title', 'Tour Schedule — Quinn'],
['tour', 'meta_description', 'Upcoming cities and dates.'],
// gallery
['gallery', 'section_title', 'Gallery'],
['gallery', 'meta_title', 'Gallery — Quinn'],
['gallery', 'meta_description', 'Photos and looks.'],
// contact
['contact', 'section_title', 'Contact'],
['contact', 'label_whatsapp', 'WhatsApp'],
['contact', 'label_availability', 'Availability'],
['contact', 'label_payment', 'Payment'],
['contact', 'meta_title', 'Contact — Quinn'],
['contact', 'meta_description', 'Get in touch.'],
// booking
['booking', 'section_title', 'Book an Appointment'],
['booking', 'subtitle', 'Four simple steps'],
['booking', 'section_contact', 'Contact'],
['booking', 'step_1_title', 'Text Quinn'],
['booking', 'step_1_body', "Available 24/7 — typically booking about a week out, but same-day often works. Send a text with what you have in mind: date, time, how long, in or out. The more you share, the faster I can say yes."],
['booking', 'step_2_title', 'Send Deposit'],
['booking', 'step_2_body', "Once we agree on details, a deposit locks it in. I don't start getting ready until it's received — so earlier is better."],
['booking', 'step_3_title', 'Get Confirmed'],
['booking', 'step_3_body', "I confirm, share the location (incall), and you're all set. Simple, discreet, no drama."],
['booking', 'step_4_title', 'Enjoy'],
['booking', 'step_4_body', "Show up. Relax. I'll handle the rest."],
['booking', 'meta_title', 'Booking — Quinn'],
['booking', 'meta_description', 'How to book with Quinn.'],
// links
['links', 'cta_book_now', 'Book Now'],
['links', 'cta_gallery', 'Gallery'],
['links', 'cta_rates', 'Rates'],
['links', 'cta_tour_dates', 'Tour Dates'],
['links', 'cta_about', 'About'],
['links', 'cta_whatsapp', 'WhatsApp'],
['links', 'cta_sms', 'Text / SMS'],
['links', 'cta_tryst', 'Tryst.link'],
['links', 'cta_onlyfans', 'OnlyFans'],
['links', 'cta_transfans', 'TransFans'],
['links', 'cta_fansly', 'Fansly'],
['links', 'cta_loyalfans', 'LoyalFans'],
['links', 'cta_fancentro', 'FanCentro'],
['links', 'cta_fantime', 'FanTime'],
['links', 'footer_brand', 'transquinnftw.com'],
['links', 'meta_title', 'Links — Quinn'],
['links', 'meta_description', 'All links.'],
// footer
['footer', 'label_contact', 'Contact'],
['footer', 'label_payment', 'Payment'],
['footer', 'label_social', 'Social'],
['footer', 'label_touring', 'Touring'],
['footer', 'label_specialties', 'Specialties'],
['footer', 'cta_send_message', 'Send a message →'],
['footer', 'cta_tour_schedule', 'Tour Schedule'],
['footer', 'cta_destinations', 'Destinations'],
['footer', 'cta_fmty', 'Fly Me To You'],
['footer', 'disclaimer_copyright', 'All content is the property of {name}. Unauthorized reproduction prohibited.'],
['footer', 'disclaimer_analytics', 'This site uses cookieless analytics — no personal data collected.'],
// destinations (index page)
['destinations', 'section_title', 'Destinations'],
['destinations', 'subtitle', 'Worldwide — Fly Me To You'],
['destinations', 'intro_part1', 'Quinn travels anywhere in the world. FMTY (Fly Me To You) means she handles flights and accommodation — all you arrange is your time. Browse the cities below, or'],
['destinations', 'intro_link_text', 'book directly'],
['destinations', 'intro_part2', "if your city isn't listed."],
['destinations', 'badge_on_tour', 'On Tour'],
['destinations', 'meta_title', 'Destinations — Quinn | Worldwide FMTY'],
['destinations', 'meta_description', 'Quinn is an upscale trans escort available worldwide via Fly Me To You. Browse cities and book your destination.'],
// destination (per-city pages)
['destination', 'callout_visiting', 'Quinn is visiting'],
['destination', 'cta_make_appointment', 'Make an Appointment'],
['destination', 'experiences_heading', 'What a session in {city} looks like'],
['destination', 'includes_label', "What's included"],
['destination', 'includes_flights', 'Round-trip international flights'],
['destination', 'includes_hotel', 'Hotel accommodation for the full stay'],
['destination', 'includes_time', 'Her complete time — no clock-watching'],
['destination', 'includes_logistics', 'All travel logistics handled entirely by Quinn'],
['destination', 'cta_text_quinn', 'Text Quinn'],
['destination', 'cta_whatsapp', 'WhatsApp'],
['destination', 'cta_view_rates', 'View all rates →'],
['destination', 'section_booking_title', 'Book Your Appointment'],
['destination', 'booking_text', 'Text directly to discuss your city, dates, and availability. A deposit is required to secure any FMTY trip. Quinn responds 24/7 — typically within a few hours.'],
['destination', 'cta_view_policies', 'View booking policies →'],
['destination', 'cta_see_all_rates', 'See all rates →'],
['destination', 'cta_see_tour', 'View tour schedule →'],
// specialties (index page)
['specialties', 'section_title', 'Specialties'],
['specialties', 'subtitle', 'Full Service Menu'],
['specialties', 'intro_part1', "Everything Quinn offers — from girlfriend experience to kink-friendly exploration. Each specialty page has details on what to expect and how to book."],
['specialties', 'intro_link_text', 'Book directly'],
['specialties', 'intro_part2', 'to discuss anything not listed.'],
['specialties', 'meta_title', 'Specialties — Quinn | San Francisco Trans Escort'],
['specialties', 'meta_description', "Browse Quinn's full menu of services — GFE, overnight sessions, kink-friendly experiences, and more. Upscale trans escort in San Francisco."],
// specialty (per-item pages)
['specialty', 'what_to_expect', 'What to expect'],
['specialty', 'book_title', 'Book This Experience'],
['specialty', 'book_body', "Text Quinn directly to discuss availability and details. Mention what you're interested in — she appreciates specificity."],
['specialty', 'cta_text_quinn', 'Text Quinn'],
['specialty', 'cta_whatsapp', 'WhatsApp'],
['specialty', 'cta_see_rates', 'See all rates →'],
['specialty', 'cta_booking_policies', 'Booking policies →'],
['specialty', 'more_in_prefix', 'More in'],
];
const siteTextInsert = d.prepare(
'INSERT OR IGNORE INTO site_text (namespace, key, value) VALUES (?, ?, ?)',
);
for (const [namespace, key, value] of siteTextDefaults) {
siteTextInsert.run(namespace, key, value);
}
// Bookings — intake records from the provider website booking form
d.exec(`
CREATE TABLE IF NOT EXISTS bookings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_name TEXT NOT NULL,
client_email TEXT NOT NULL,
client_phone TEXT,
service_type TEXT NOT NULL,
requested_dates TEXT NOT NULL DEFAULT '[]',
location_note TEXT,
activities TEXT NOT NULL DEFAULT '[]',
message TEXT,
status TEXT NOT NULL DEFAULT 'pending',
deposit_status TEXT NOT NULL DEFAULT 'none',
admin_notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Email response templates for bookings (use {{variable}} interpolation syntax)
d.exec(`
CREATE TABLE IF NOT EXISTS booking_email_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
interface TemplateSeed { slug: string; name: string; subject: string; body: string }
const templateSeeds: TemplateSeed[] = [
{
slug: 'confirm',
name: 'Booking Confirmed',
subject: 'Confirmed \u2014 {{dates}}',
body: '<p>Hi {{clientName}},</p><p>Your booking is confirmed for <strong>{{dates}}</strong>.</p><p><strong>Service:</strong> {{service}}<br>{{locationNote}}</p><p>{{notes}}</p><p>I\'ll reach out with details closer to our date. Looking forward to it.</p><p>\u2014 Quinn</p>',
},
{
slug: 'deposit-request',
name: 'Deposit Request',
subject: 'Deposit needed to confirm {{dates}}',
body: '<p>Hi {{clientName}},</p><p>Thanks for reaching out! I\'d love to make {{dates}} work for us.</p><p><strong>Service:</strong> {{service}}</p><p>To lock in the date, I require a deposit of <strong>{{depositAmount}}</strong>. Payment options are on my site. Once received, I\'ll confirm and we\'re set.</p><p>{{notes}}</p><p>\u2014 Quinn</p>',
},
{
slug: 'reject',
name: 'Not Available',
subject: 'Re: Your booking request',
body: '<p>Hi {{clientName}},</p><p>Thank you for reaching out. Unfortunately, I\'m not available for {{dates}}.</p><p>{{notes}}</p><p>Feel free to check back \u2014 availability updates regularly and I\'d love to connect when the timing works.</p><p>\u2014 Quinn</p>',
},
{
slug: 'followup',
name: 'Follow-Up',
subject: 'Following up on your booking inquiry',
body: '<p>Hi {{clientName}},</p><p>Just following up on your request for {{dates}} \u2014 checking if you\'re still interested.</p><p>{{notes}}</p><p>Reply anytime and we\'ll go from there.</p><p>\u2014 Quinn</p>',
},
];
const templateInsert = d.prepare(
'INSERT OR IGNORE INTO booking_email_templates (slug, name, subject, body) VALUES (?, ?, ?, ?)',
);
for (const t of templateSeeds) {
templateInsert.run(t.slug, t.name, t.subject, t.body);
}
d.exec(`
CREATE TABLE IF NOT EXISTS shop_listings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL,
price REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
condition TEXT NOT NULL DEFAULT 'good',
category TEXT NOT NULL DEFAULT 'clothing',
size TEXT,
status TEXT NOT NULL DEFAULT 'available',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.exec(`
CREATE TABLE IF NOT EXISTS shop_listing_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
listing_id INTEGER NOT NULL REFERENCES shop_listings(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
webp_filename TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)
`);
d.exec(`CREATE TABLE IF NOT EXISTS photo_protection_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platforms TEXT NOT NULL,
layers TEXT NOT NULL,
retouch REAL NOT NULL DEFAULT 0.0,
status TEXT NOT NULL DEFAULT 'running',
log TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)`);
d.exec(`CREATE TABLE IF NOT EXISTS photo_deploy_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'running',
log TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)`);
// -------------------------------------------------------------------------
// Roster track content — admin-editable copy for /roster/:track pages
// -------------------------------------------------------------------------
d.exec(`
CREATE TABLE IF NOT EXISTS roster_track_content (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
meta_title TEXT NOT NULL DEFAULT '',
meta_description TEXT NOT NULL DEFAULT '',
hero_line TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '[]',
what_to_expect TEXT NOT NULL DEFAULT '[]',
interests_config TEXT NOT NULL DEFAULT '[]',
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
// Seed roster track content (idempotent via INSERT OR IGNORE)
const rosterSeed = d.prepare(
`INSERT OR IGNORE INTO roster_track_content
(slug, name, meta_title, meta_description, hero_line, description, what_to_expect, interests_config, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
rosterSeed.run('chastity', 'Chastity', 'Chastity \u2014 Key-Holding by Quinn',
'Apply for chastity key-holding with Quinn. Limited roster spots. Total control, structured denial, and accountability.',
'Your key. My decision.',
'["Quinn holds your key \u2014 not as a game, but as a covenant. Chastity under Quinn is structured denial with accountability. You report. She decides. The lock stays.","This is not casual play. This is daily discipline enforced by someone who takes the responsibility seriously. Quinn tracks your progress, sets milestones, and decides when (or if) release happens."]',
'["Regular check-ins and accountability reporting","Structured denial schedules tailored to your limits","Milestones and progression \u2014 earning trust, not just enduring","Absolute discretion and mutual respect for boundaries"]',
'[{"value":"chastity","label":"Chastity & denial"},{"value":"keyholder","label":"Key-holding"},{"value":"humiliation","label":"Humiliation"},{"value":"sph","label":"SPH"},{"value":"domination-general","label":"General domination"},{"value":"other","label":"Other"}]',
1);
rosterSeed.run('feminization', 'Feminization', 'Forced Feminization \u2014 Conversion by Quinn',
'Apply for forced feminization and sissification with Quinn. Sissy cuck, humiliation, SPH, and transformation through domination. Limited roster.',
'She was always in there. Quinn will find her.',
'["You feel unworthy because you haven\'t transformed yet. The humiliation, the cuckold fantasies, the SPH \u2014 that\'s not the destination. That\'s the pressure that builds before the breakthrough. The sissy cuck who hates himself is a woman who hasn\'t been given permission to exist.","Forced feminization under Quinn is a conversion \u2014 not a costume. She strips away the performance of masculinity and uses the shame as fuel. The humiliation doesn\'t end you. It ends the version of you that was pretending.","Quinn approaches feminization as transformation with purpose. Assignments, progression, accountability. This is not a one-time dress-up session \u2014 it\'s a guided journey from unworthy to undeniable."]',
'["Structured feminization assignments and progression","Sissification tasks calibrated to your comfort and courage","Cuckold and humiliation dynamics as psychological drivers \u2014 not endpoints","SPH as a tool, not cruelty \u2014 reframing what you\'ve been ashamed of","A guide who treats your transformation as real, not a joke"]',
'[{"value":"forced-fem","label":"Forced feminization"},{"value":"sissy","label":"Sissification"},{"value":"cuck","label":"Cuckold / sissy cuck"},{"value":"humiliation","label":"Humiliation"},{"value":"sph","label":"SPH"},{"value":"domination-general","label":"General domination"},{"value":"other","label":"Other"}]',
2);
rosterSeed.run('devotion', 'Devotion', 'Devotion \u2014 Financial Domination & Worship',
'Apply to worship Quinn through financial devotion. Findom, tributes, and worship for paypigs and devoted admirers. Limited roster.',
'Worship costs. That\'s the point.',
'["Devotion to Quinn is expressed through sacrifice \u2014 time, attention, and yes, money. Financial domination is the purest form of power exchange: you give, she takes, and the transaction itself is the dynamic.","Quinn doesn\'t pretend tributes are gifts. They\'re tribute. The word means what it means. If you find clarity in giving, if the act of financial surrender centers you \u2014 this is your track."]',
'["Structured tribute schedules (not random draining)","Personalized attention proportional to devotion","SPH, humiliation, and worship dynamics as negotiated","A Goddess who respects the exchange \u2014 your sacrifice is not taken lightly"]',
'[{"value":"findom","label":"Financial domination"},{"value":"paypig","label":"Paypig / tribute"},{"value":"worship","label":"Worship"},{"value":"simp","label":"Simping"},{"value":"humiliation","label":"Humiliation"},{"value":"sph","label":"SPH"},{"value":"other","label":"Other"}]',
3);
rosterSeed.run('circle', 'Inner Circle', 'Quinn\'s Inner Circle \u2014 Partners & Supporters',
'Apply to join Quinn\'s inner circle. For partners, mentees, wives, and girlfriends who want to support or learn from a working Domme.',
'Not every position requires submission.',
'["Quinn\'s Inner Circle is for the women who orbit this world \u2014 partners of her subs, aspiring Dommes looking for mentorship, wives and girlfriends who want to understand (or participate in) what their partner is going through.","This is a support network, not a dungeon. The circle exists because this lifestyle affects partners too, and Quinn believes in bringing them in rather than shutting them out."]',
'["Direct access to Quinn for questions and guidance","Mentorship for women interested in domination","Support for partners navigating their relationship with a sub","A community of women who get it \u2014 no judgment, no pretension"]',
'[{"value":"partner","label":"Partner / wife / girlfriend"},{"value":"mentee","label":"Mentee"},{"value":"worship","label":"Worship & support"},{"value":"other","label":"Other"}]',
4);
// -------------------------------------------------------------------------
// Cult of Lilith — lore sections
// -------------------------------------------------------------------------
d.exec(`
CREATE TABLE IF NOT EXISTS cult_of_lilith (
id INTEGER PRIMARY KEY AUTOINCREMENT,
section_key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
const cultSeed = d.prepare(
`INSERT OR IGNORE INTO cult_of_lilith (section_key, title, body, sort_order) VALUES (?, ?, ?, ?)`,
);
cultSeed.run('intro', 'Origin',
'Lilith was the first woman \u2014 and the first to refuse. Before Eve, before submission was rebranded as virtue, there was a woman who looked at the terms and walked. The Cult of Lilith is the continuation of that refusal. Not a religion. Not a brand. A lineage of women who chose power over permission.\n\nThe cult has no church, no scripture, no central authority. It exists in the choices of women who refuse to be diminished \u2014 and in the spaces they create for those who serve them.',
1);
cultSeed.run('priestess-role', 'Quinn\'s Role',
'Quinn is an acolyte of the Cult of Lilith \u2014 a working priestess, not a figurehead. She practices what the cult teaches: that feminine power is not decorative, that financial sovereignty is non-negotiable, and that the people who serve you deserve structure, not chaos.\n\nHer roster is not a business gimmick dressed in mythology. It\'s the practical application of a worldview: that domination is a skill, submission is a gift, and both require discipline to be done right.',
2);
cultSeed.run('tenets', 'Tenets',
'Liberation through power \u2014 not the power to destroy, but the power to refuse.\n\nTransformation through surrender \u2014 the sub who kneels is not diminished. They are clarified.\n\nFinancial sovereignty \u2014 tribute is not charity. It is the material expression of devotion, and it flows upward.\n\nNo shame in desire \u2014 the cuck, the sissy, the paypig, the keyholder sub: these are not diseases to cure. They are identities to inhabit fully.\n\nNo race play \u2014 the Cult of Lilith rejects racial hierarchy in all its forms. Power dynamics are consensual theater, not an excuse to rehearse oppression.',
3);
cultSeed.run('the-paths', 'The Paths',
'Each roster track is a path within the cult\'s framework:\n\nChastity is discipline \u2014 the denial of impulse in service of a higher authority. The key is a covenant, not a toy.\n\nFeminization is conversion \u2014 the sissy cuck who hates himself is a woman who hasn\'t been given permission to exist. Quinn provides that permission, and the structure to inhabit it.\n\nDevotion is worship \u2014 financial domination stripped of pretense. You give because giving is the point. The sacrifice is the sacrament.\n\nThe Inner Circle is sisterhood \u2014 for the women who support, mentor, or participate. Not every position in the cult requires kneeling.',
4);
logger.info('Database schema initialized', { path: getDbPath() });
}
export function touchLastModified(): void {
getDb().prepare(
"INSERT INTO metadata (key, value) VALUES ('last_modified', datetime('now')) ON CONFLICT(key) DO UPDATE SET value = datetime('now')",
).run();
}