778 lines
41 KiB
JavaScript
778 lines
41 KiB
JavaScript
/**
|
|
* Database — postgres.js connection and schema management.
|
|
*
|
|
* Lazy-initialized singleton. All tables created on first runMigrations() call.
|
|
* DB URL configurable via QUINN_ADMIN_DB_URL env var.
|
|
*/
|
|
import postgres from 'postgres';
|
|
import { logger } from './logger';
|
|
let singleton;
|
|
export function getDbUrl() {
|
|
const url = process.env['QUINN_ADMIN_DB_URL'];
|
|
if (!url)
|
|
throw new Error('QUINN_ADMIN_DB_URL environment variable is required');
|
|
return url;
|
|
}
|
|
export function openDb(url) {
|
|
if (singleton)
|
|
return singleton;
|
|
singleton = postgres(url, {
|
|
max: 10,
|
|
idle_timeout: 30,
|
|
connect_timeout: 10,
|
|
onnotice: () => { },
|
|
});
|
|
return singleton;
|
|
}
|
|
export function getDb() {
|
|
if (!singleton)
|
|
throw new Error('db not opened — call openDb() in server.ts first');
|
|
return singleton;
|
|
}
|
|
export async function closeDb() {
|
|
if (singleton) {
|
|
await singleton.end().catch(() => { });
|
|
singleton = undefined;
|
|
}
|
|
}
|
|
export async function runMigrations(sql, migrations) {
|
|
await sql `
|
|
CREATE TABLE IF NOT EXISTS _admin_migrations (
|
|
id TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`.catch((err) => {
|
|
throw new Error(`Failed to create admin migrations table: ${String(err)}`);
|
|
});
|
|
const rows = await sql `SELECT id FROM _admin_migrations`
|
|
.catch((err) => {
|
|
throw new Error(`Failed to read applied admin migrations: ${String(err)}`);
|
|
});
|
|
const applied = new Set(rows.map((r) => r.id));
|
|
for (const m of migrations) {
|
|
if (applied.has(m.id))
|
|
continue;
|
|
await sql.begin(async (tx) => {
|
|
await m.up(tx);
|
|
await tx `INSERT INTO _admin_migrations (id) VALUES (${m.id})`;
|
|
}).catch((err) => {
|
|
throw new Error(`Admin migration '${m.id}' failed: ${String(err)}`);
|
|
});
|
|
}
|
|
}
|
|
export async function touchLastModified() {
|
|
try {
|
|
const sql = getDb();
|
|
await sql `
|
|
INSERT INTO metadata (key, value) VALUES ('last_modified', now()::text)
|
|
ON CONFLICT (key) DO UPDATE SET value = now()::text
|
|
`;
|
|
}
|
|
catch (err) {
|
|
logger.warn('touchLastModified failed', { error: String(err) });
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// Schema migrations — all tables that were previously in initSchema()
|
|
// ---------------------------------------------------------------------------
|
|
export const adminMigrations = [
|
|
{
|
|
id: '2026-04-18_admin_core_tables',
|
|
async up(sql) {
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS admin_auth (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
passphrase_hash TEXT NOT NULL,
|
|
totp_secret TEXT,
|
|
totp_enabled SMALLINT NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS metadata (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS physical (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
age TEXT,
|
|
height TEXT,
|
|
body_type TEXT,
|
|
ethnicity TEXT,
|
|
hair_color TEXT,
|
|
hair_length TEXT,
|
|
eye_color TEXT,
|
|
cup_size TEXT,
|
|
additional TEXT NOT NULL DEFAULT '{}',
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS contact (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
phone TEXT NOT NULL DEFAULT '',
|
|
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 DEFAULT '',
|
|
response_time TEXT NOT NULL DEFAULT '',
|
|
availability_note TEXT,
|
|
payment_methods TEXT NOT NULL DEFAULT '[]',
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS about (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
bio TEXT NOT NULL DEFAULT '',
|
|
personality TEXT NOT NULL DEFAULT '[]',
|
|
available_for TEXT NOT NULL DEFAULT '[]',
|
|
available_to TEXT NOT NULL DEFAULT '[]',
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS activity_menus (
|
|
id SERIAL PRIMARY KEY,
|
|
category TEXT NOT NULL,
|
|
items TEXT NOT NULL DEFAULT '[]',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS rate_sections (
|
|
id SERIAL PRIMARY KEY,
|
|
section_type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS rate_entries (
|
|
id SERIAL PRIMARY KEY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS tour_stops (
|
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
country TEXT NOT NULL DEFAULT 'USA',
|
|
availability_note TEXT,
|
|
pricing_tiers TEXT
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS gallery_items (
|
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
filename TEXT NOT NULL,
|
|
alt TEXT NOT NULL,
|
|
category TEXT,
|
|
featured SMALLINT 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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS policy_sections (
|
|
id SERIAL PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS policy_items (
|
|
id SERIAL PRIMARY KEY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS destinations (
|
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
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 SMALLINT NOT NULL DEFAULT 0,
|
|
experiences TEXT NOT NULL DEFAULT '[]',
|
|
note TEXT,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
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,
|
|
relationship TEXT NOT NULL DEFAULT 'tour-aspirational',
|
|
super_region TEXT,
|
|
neighborhoods TEXT NOT NULL DEFAULT '[]',
|
|
local_incall_notes TEXT,
|
|
driving_time_mins INTEGER,
|
|
affluence_tier TEXT NOT NULL DEFAULT 'high'
|
|
)
|
|
`);
|
|
await sql.unsafe(`CREATE INDEX IF NOT EXISTS idx_adm_destinations_region ON destinations(region)`);
|
|
await sql.unsafe(`CREATE INDEX IF NOT EXISTS idx_adm_destinations_relationship ON destinations(relationship)`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS specialties (
|
|
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
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
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS site_text (
|
|
namespace TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
PRIMARY KEY (namespace, key)
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS etiquette_sections (
|
|
id SERIAL PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS etiquette_items (
|
|
id SERIAL PRIMARY KEY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS hobby_terms (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
term TEXT NOT NULL,
|
|
aliases TEXT NOT NULL DEFAULT '[]',
|
|
definition_md TEXT NOT NULL DEFAULT '',
|
|
definition_html TEXT NOT NULL DEFAULT '',
|
|
category TEXT NOT NULL DEFAULT 'general',
|
|
offered TEXT NOT NULL DEFAULT 'no',
|
|
safety_notes TEXT NOT NULL DEFAULT '',
|
|
related_terms TEXT NOT NULL DEFAULT '[]',
|
|
meta_title TEXT NOT NULL DEFAULT '',
|
|
meta_description TEXT NOT NULL DEFAULT '',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`CREATE INDEX IF NOT EXISTS idx_hobby_terms_category ON hobby_terms(category)`);
|
|
await sql.unsafe(`CREATE INDEX IF NOT EXISTS idx_hobby_terms_offered ON hobby_terms(offered)`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS regions (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
super_region TEXT,
|
|
tier TEXT NOT NULL DEFAULT 'region',
|
|
intro_md TEXT NOT NULL DEFAULT '',
|
|
intro_html TEXT NOT NULL DEFAULT '',
|
|
meta_title TEXT NOT NULL DEFAULT '',
|
|
meta_description TEXT NOT NULL DEFAULT '',
|
|
sibling_regions TEXT NOT NULL DEFAULT '[]',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`CREATE INDEX IF NOT EXISTS idx_regions_super ON regions(super_region)`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS positioning_tags (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
display TEXT NOT NULL,
|
|
description_md TEXT NOT NULL DEFAULT '',
|
|
description_html TEXT NOT NULL DEFAULT '',
|
|
target_markets TEXT NOT NULL DEFAULT '[]',
|
|
related_terms TEXT NOT NULL DEFAULT '[]',
|
|
similar_to TEXT NOT NULL DEFAULT '[]',
|
|
active SMALLINT NOT NULL DEFAULT 1,
|
|
meta_title TEXT NOT NULL DEFAULT '',
|
|
meta_description TEXT NOT NULL DEFAULT '',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`CREATE INDEX IF NOT EXISTS idx_positioning_tags_active ON positioning_tags(active)`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS touring_subscribers (
|
|
id SERIAL PRIMARY KEY,
|
|
email TEXT NOT NULL UNIQUE,
|
|
city_interest TEXT,
|
|
source TEXT NOT NULL,
|
|
source_city TEXT,
|
|
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS link_values (
|
|
id SERIAL PRIMARY KEY,
|
|
event_name TEXT NOT NULL,
|
|
label TEXT NOT NULL,
|
|
score INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE (event_name, label)
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS bookings (
|
|
id SERIAL PRIMARY KEY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS booking_email_templates (
|
|
id SERIAL PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
subject TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS shop_listings (
|
|
id SERIAL PRIMARY KEY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS shop_listing_photos (
|
|
id SERIAL PRIMARY KEY,
|
|
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
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS photo_protection_runs (
|
|
id SERIAL PRIMARY KEY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
completed_at TIMESTAMPTZ
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS photo_deploy_runs (
|
|
id SERIAL PRIMARY KEY,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
log TEXT NOT NULL DEFAULT '',
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
completed_at TIMESTAMPTZ
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS verified_profiles (
|
|
id SERIAL PRIMARY KEY,
|
|
platform TEXT NOT NULL,
|
|
href TEXT NOT NULL,
|
|
img_src TEXT NOT NULL,
|
|
img_alt TEXT NOT NULL,
|
|
embed_html TEXT NOT NULL DEFAULT '',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS cult_of_lilith (
|
|
id SERIAL PRIMARY KEY,
|
|
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 TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS mail_thread_state (
|
|
inbox TEXT NOT NULL,
|
|
uid TEXT NOT NULL,
|
|
is_read SMALLINT NOT NULL DEFAULT 0,
|
|
is_archived SMALLINT NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (inbox, uid)
|
|
)
|
|
`);
|
|
await sql.unsafe(`
|
|
CREATE TABLE IF NOT EXISTS hero_strip_items (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL CHECK(type IN ('tour_stop', 'cta')),
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
city TEXT,
|
|
state TEXT,
|
|
start_date TEXT,
|
|
end_date TEXT,
|
|
booking_status TEXT,
|
|
availability_note TEXT,
|
|
label TEXT,
|
|
subtitle TEXT,
|
|
href TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
},
|
|
},
|
|
{
|
|
id: '2026-04-18_admin_seed_link_values',
|
|
async up(sql) {
|
|
const seeds = [
|
|
['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_link', 'sms', 8],
|
|
['contact_link', 'whatsapp', 6],
|
|
['contact_link', 'email', 5],
|
|
['tour_contact', 'sms', 8],
|
|
['tour_contact', 'whatsapp', 6],
|
|
['tour_contact', 'email', 5],
|
|
['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],
|
|
['payment_link', 'cashapp', 6],
|
|
['payment_link', 'venmo', 5],
|
|
['payment_link', 'zelle', 5],
|
|
['booking_link', 'sms', 9],
|
|
['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],
|
|
];
|
|
for (const [eventName, label, score] of seeds) {
|
|
await sql.unsafe(`INSERT INTO link_values (event_name, label, score) VALUES ($1, $2, $3) ON CONFLICT (event_name, label) DO NOTHING`, [eventName, label, score]);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: '2026-04-18_admin_seed_booking_templates',
|
|
async up(sql) {
|
|
const templates = [
|
|
{
|
|
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>",
|
|
},
|
|
];
|
|
for (const t of templates) {
|
|
await sql.unsafe(`INSERT INTO booking_email_templates (slug, name, subject, body) VALUES ($1, $2, $3, $4) ON CONFLICT (slug) DO NOTHING`, [t.slug, t.name, t.subject, t.body]);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: '2026-04-18_admin_seed_roster_tracks',
|
|
async up(sql) {
|
|
const tracks = [
|
|
{
|
|
slug: 'chastity',
|
|
name: 'Chastity',
|
|
meta_title: 'Chastity \u2014 Key-Holding by Quinn',
|
|
meta_description: 'Apply for chastity key-holding with Quinn. Limited roster spots. Total control, structured denial, and accountability.',
|
|
hero_line: 'Your key. My decision.',
|
|
description: JSON.stringify(['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.']),
|
|
what_to_expect: JSON.stringify(['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']),
|
|
interests_config: JSON.stringify([{ 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' }]),
|
|
sort_order: 1,
|
|
},
|
|
{
|
|
slug: 'feminization',
|
|
name: 'Feminization',
|
|
meta_title: 'Forced Feminization \u2014 Conversion by Quinn',
|
|
meta_description: 'Apply for forced feminization and sissification with Quinn. Sissy cuck, humiliation, SPH, and transformation through domination. Limited roster.',
|
|
hero_line: 'She was always in there. Quinn will find her.',
|
|
description: JSON.stringify(["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.']),
|
|
what_to_expect: JSON.stringify(['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']),
|
|
interests_config: JSON.stringify([{ 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' }]),
|
|
sort_order: 2,
|
|
},
|
|
{
|
|
slug: 'devotion',
|
|
name: 'Devotion',
|
|
meta_title: 'Devotion \u2014 Financial Domination & Worship',
|
|
meta_description: 'Apply to worship Quinn through financial devotion. Findom, tributes, and worship for paypigs and devoted admirers. Limited roster.',
|
|
hero_line: "Worship costs. That's the point.",
|
|
description: JSON.stringify(['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."]),
|
|
what_to_expect: JSON.stringify(['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']),
|
|
interests_config: JSON.stringify([{ 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' }]),
|
|
sort_order: 3,
|
|
},
|
|
{
|
|
slug: 'circle',
|
|
name: 'Inner Circle',
|
|
meta_title: "Quinn's Inner Circle \u2014 Partners & Supporters",
|
|
meta_description: "Apply to join Quinn's inner circle. For partners, mentees, wives, and girlfriends who want to support or learn from a working Domme.",
|
|
hero_line: 'Not every position requires submission.',
|
|
description: JSON.stringify(["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.']),
|
|
what_to_expect: JSON.stringify(['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"]),
|
|
interests_config: JSON.stringify([{ value: 'partner', label: 'Partner / wife / girlfriend' }, { value: 'mentee', label: 'Mentee' }, { value: 'worship', label: 'Worship & support' }, { value: 'other', label: 'Other' }]),
|
|
sort_order: 4,
|
|
},
|
|
];
|
|
for (const t of tracks) {
|
|
await sql.unsafe(`INSERT INTO roster_track_content (slug, name, meta_title, meta_description, hero_line, description, what_to_expect, interests_config, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (slug) DO NOTHING`, [t.slug, t.name, t.meta_title, t.meta_description, t.hero_line, t.description, t.what_to_expect, t.interests_config, t.sort_order]);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: '2026-04-18_admin_seed_verified_profiles',
|
|
async up(sql) {
|
|
await sql.unsafe(`INSERT INTO verified_profiles (id, platform, href, img_src, img_alt, embed_html, description, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING`, [
|
|
1, 'Tryst.link', 'https://tryst.link/escort/transquinnftw',
|
|
'https://tryst.link/embed/banner/transquinnftw.jpg',
|
|
"Quinn's Tryst.link profile",
|
|
"<a href=\"https://tryst.link/escort/transquinnftw\" rel=\"noopener\" target=\"_blank\" title=\"Quinn's Tryst.link profile\" style=\"border:none\"><img src=\"https://tryst.link/embed/banner/transquinnftw.jpg\" alt=\"Quinn's Tryst.link profile\" style=\"width:300px;height:auto;max-width:100%\"></a>",
|
|
'Verified provider on Tryst.link \u2014 the industry-standard directory for independent escorts.',
|
|
1,
|
|
]);
|
|
await sql.unsafe(`INSERT INTO verified_profiles (id, platform, href, img_src, img_alt, embed_html, description, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO NOTHING`, [
|
|
2, 'AdultLook', 'https://www.adultlook.com/p/3373548',
|
|
'https://www.adultlook.com/images/static/er-m-1.jpg',
|
|
"Quinn's AdultLook profile",
|
|
'<a href="https://www.adultlook.com/p/3373548" title="Escorts" border="0"><img src="https://www.adultlook.com/images/static/er-m-1.jpg" alt="AdultLook"></a>',
|
|
'Listed on AdultLook \u2014 a directory for independent adult service providers.',
|
|
2,
|
|
]);
|
|
},
|
|
},
|
|
{
|
|
id: '2026-04-20_seed_gallery_from_provider_data',
|
|
async up(sql) {
|
|
const countRows = await sql.unsafe('SELECT COUNT(*) as n FROM gallery_items');
|
|
const existingCount = parseInt(countRows[0]?.n ?? '0', 10);
|
|
if (existingCount > 0)
|
|
return;
|
|
try {
|
|
const dataModule = await import('../../../../../deployments/@domains/quinn.www/root/src/data');
|
|
const { gallery } = dataModule.providerData;
|
|
if (!gallery?.length)
|
|
return;
|
|
for (let i = 0; i < gallery.length; i++) {
|
|
const g = gallery[i];
|
|
const filename = g.src.split('/').pop() ?? g.src;
|
|
const webpFilename = g.webpSrc?.split('/').pop() ?? null;
|
|
await sql.unsafe('INSERT INTO gallery_items (filename, alt, category, featured, webp_filename, intrinsic_width, intrinsic_height, protection_status, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)', [filename, g.alt, g.category ?? null, g.featured ?? false, webpFilename, g.intrinsicWidth ?? null, g.intrinsicHeight ?? null, 'protected', i]);
|
|
}
|
|
}
|
|
catch {
|
|
// Silently skip if provider data is unavailable
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: '2026-04-18_admin_seed_cult_of_lilith',
|
|
async up(sql) {
|
|
const sections = [
|
|
{ key: 'intro', title: 'Origin', body: '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.', sort_order: 1 },
|
|
{ key: 'priestess-role', title: "Quinn's Role", body: "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.", sort_order: 2 },
|
|
{ key: 'tenets', title: 'Tenets', body: 'Female supremacy \u2014 the Cult of Lilith is matriarchal. Women lead. This is not metaphor. It is structure.\n\nLiberation 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 believes in gender hierarchy, not racial hierarchy. Matriarchy is the point. Racial domination is not, and never will be, part of this practice.', sort_order: 3 },
|
|
{ key: 'the-paths', title: 'The Paths', body: "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.", sort_order: 4 },
|
|
];
|
|
for (const s of sections) {
|
|
await sql.unsafe(`INSERT INTO cult_of_lilith (section_key, title, body, sort_order) VALUES ($1, $2, $3, $4) ON CONFLICT (section_key) DO NOTHING`, [s.key, s.title, s.body, s.sort_order]);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: '2026-05-12_admin_gallery_tags',
|
|
async up(sql) {
|
|
await sql.unsafe(`
|
|
ALTER TABLE gallery_items
|
|
ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'
|
|
`);
|
|
await sql.unsafe(`
|
|
UPDATE gallery_items
|
|
SET tags = ARRAY[category]
|
|
WHERE category IS NOT NULL AND (tags = '{}' OR tags IS NULL)
|
|
`);
|
|
},
|
|
},
|
|
];
|
|
export async function initSchema() {
|
|
const sql = getDb();
|
|
await runMigrations(sql, adminMigrations);
|
|
logger.info('Admin database schema initialized');
|
|
}
|