lilith-platform.live/codebase/@features/admin/backend-api/src/db.ts
2026-04-05 00:08:16 -07:00

594 lines
21 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 { Database } from 'bun:sqlite';
import { logger } from './logger';
const DB_PATH = process.env['DB_PATH'] ?? './data/quinn.db';
let db: Database | null = null;
export function getDbPath(): string {
return DB_PATH;
}
export function getDb(): Database {
if (!db) {
db = new Database(DB_PATH, { create: true });
db.run('PRAGMA journal_mode = WAL');
db.run('PRAGMA foreign_keys = ON');
db.run('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.run(`
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.run(`
CREATE TABLE IF NOT EXISTS metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
d.run(`
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.run(`
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.run(`
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.run(`ALTER TABLE contact ADD COLUMN ${col} TEXT`); } catch { /* already exists */ }
}
d.run(`
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.run(`
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.run(`
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.run(`
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.run(`
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.run(`ALTER TABLE tour_stops DROP COLUMN ${col}`); } catch { /* already dropped or never existed */ }
}
d.run(`
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.run(`
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.run(`
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.run(`
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.run(`
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.run(`
CREATE TABLE IF NOT EXISTS site_text (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (namespace, key)
)
`);
d.run(`
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.run(`
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,
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
d.run(`
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.run(`
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],
// 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.'],
];
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.run(`
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.run(`
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);
}
logger.info('Database schema initialized', { path: DB_PATH });
}
export function touchLastModified(): void {
getDb().query(
"INSERT INTO metadata (key, value) VALUES ('last_modified', datetime('now')) ON CONFLICT(key) DO UPDATE SET value = datetime('now')",
).run();
}