594 lines
21 KiB
TypeScript
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();
|
|
}
|