lilith-platform.live/codebase/@features/admin/backend-api/src/migrate.ts
autocommit 77e0c695a3 db(admin): 🗃️ Remove payment_methods column from contact table and update migration logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-16 00:32:18 -07:00

514 lines
24 KiB
TypeScript

/**
* Migration script — populate Postgres from static data.ts + destinations.ts
*
* Reads the providerData from quinn.www data.ts and inserts all records
* into the admin Postgres database. Idempotent: clears existing data before insert.
*
* Usage: bun run src/migrate.ts
*/
import { initSchema, getDb, openDb, getDbUrl, touchLastModified } from './db';
import { logger } from './logger';
async function main(): Promise<void> {
openDb(getDbUrl());
await initSchema();
const sql = getDb();
logger.info('Loading static provider data...');
const dataModule = await import(
'../../../../../deployments/@domains/quinn.www/root/src/data'
);
const data = dataModule.providerData;
const destModule = await import(
'../../../../../deployments/@domains/quinn.www/root/src/destinations'
);
const destinations = destModule.destinations;
const specModule = await import(
'../../../../../deployments/@domains/quinn.www/root/src/specialties'
);
const specialties = specModule.specialties;
logger.info('Starting migration...');
await sql.begin(async (tx) => {
const tables = [
'identity', 'physical', 'contact', 'about',
'rate_sections', 'rate_entries',
'tour_stops', 'gallery_items',
'policy_sections', 'policy_items',
'etiquette_sections', 'etiquette_items',
'activity_menus', 'destinations', 'specialties',
];
for (const table of tables) {
await tx.unsafe(`DELETE FROM "${table}"`);
}
// Identity
await tx.unsafe(
`INSERT INTO identity
(id, name, pronouns, gender, location, incall_city, tagline, secondary_locations, languages)
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8)`,
[
data.identity.name,
data.identity.pronouns,
data.identity.gender,
data.identity.location,
data.identity.incallCity ?? null,
data.identity.tagline,
JSON.stringify(data.identity.secondaryLocations),
JSON.stringify(data.identity.languages),
],
);
// Physical
await tx.unsafe(
`INSERT INTO physical
(id, age, height, body_type, ethnicity, hair_color, eye_color, cup_size, additional)
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8)`,
[
data.physical.age,
data.physical.height,
data.physical.bodyType,
data.physical.ethnicity,
data.physical.hairColor,
data.physical.eyeColor,
data.physical.cupSize,
JSON.stringify(data.physical.additional),
],
);
// Contact
await tx.unsafe(
`INSERT INTO contact
(id, phone, whatsapp, email, instagram, twitter, threads, snapchat, youtube,
onlyfans, transfans, fansly, loyalfans, fancentro, fantime, tryst,
communication_note, response_time, availability_note)
VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`,
[
data.contact.phone,
data.contact.whatsapp ?? null,
data.contact.email ?? null,
data.contact.instagram ?? null,
data.contact.twitter ?? null,
data.contact.threads ?? null,
data.contact.snapchat ?? null,
data.contact.youtube ?? null,
data.contact.onlyfans ?? null,
data.contact.transfans ?? null,
data.contact.fansly ?? null,
data.contact.loyalfans ?? null,
data.contact.fancentro ?? null,
data.contact.fantime ?? null,
data.contact.tryst ?? null,
data.contact.communicationNote,
data.contact.responseTime,
data.contact.availabilityNote ?? null,
],
);
// About
await tx.unsafe(
`INSERT INTO about (id, bio, personality, available_for, available_to)
VALUES (1, $1, $2, $3, $4)`,
[
data.about.bio,
JSON.stringify(data.about.personality),
JSON.stringify(data.about.availableFor),
JSON.stringify(data.about.availableTo),
],
);
// Activity menus
if (data.about.activities) {
for (let i = 0; i < data.about.activities.length; i++) {
const act = data.about.activities[i];
await tx.unsafe(
'INSERT INTO activity_menus (category, items, sort_order) VALUES ($1, $2, $3)',
[act.category, JSON.stringify(act.items), i],
);
}
}
// Rate sections + entries
let sectionOrder = 0;
for (const section of data.rates) {
const type = section.title.toLowerCase().includes('out') ? 'outcall' : 'incall';
const rows = await tx.unsafe<Array<{ id: number }>>(
'INSERT INTO rate_sections (section_type, title, description, sort_order) VALUES ($1, $2, $3, $4) RETURNING id',
[type, section.title, section.description ?? null, sectionOrder++],
);
const sectionId = rows[0]!.id;
for (let j = 0; j < section.entries.length; j++) {
const e = section.entries[j];
await tx.unsafe(
'INSERT INTO rate_entries (section_id, service, duration, price, price_max, description, notes, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
[sectionId, e.service, e.duration ?? null, e.price, e.priceMax ?? null, e.description ?? null, e.notes ?? null, j],
);
}
}
// Add-Ons
const addOnRows = await tx.unsafe<Array<{ id: number }>>(
'INSERT INTO rate_sections (section_type, title, description, sort_order) VALUES ($1, $2, $3, $4) RETURNING id',
['addons', 'Add-Ons', data.addOns.description ?? null, sectionOrder++],
);
const addOnId = addOnRows[0]!.id;
for (let j = 0; j < data.addOns.entries.length; j++) {
const e = data.addOns.entries[j];
await tx.unsafe(
'INSERT INTO rate_entries (section_id, service, duration, price, price_max, description, notes, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
[addOnId, e.service, e.duration ?? null, e.price, e.priceMax ?? null, e.description ?? null, e.notes ?? null, j],
);
}
// Touring Packages
const tourRows = await tx.unsafe<Array<{ id: number }>>(
'INSERT INTO rate_sections (section_type, title, description, sort_order) VALUES ($1, $2, $3, $4) RETURNING id',
['touring', 'Touring Packages (FMTY)', data.touringPackages.description ?? null, sectionOrder++],
);
const tourSectionId = tourRows[0]!.id;
for (let j = 0; j < data.touringPackages.entries.length; j++) {
const e = data.touringPackages.entries[j];
await tx.unsafe(
'INSERT INTO rate_entries (section_id, service, duration, price, price_max, description, notes, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
[tourSectionId, e.service, e.duration ?? null, e.price, e.priceMax ?? null, e.description ?? null, e.notes ?? null, j],
);
}
// Online Services
const onlineRows = await tx.unsafe<Array<{ id: number }>>(
'INSERT INTO rate_sections (section_type, title, description, sort_order) VALUES ($1, $2, $3, $4) RETURNING id',
['online', 'Online Services', data.onlineServices.description ?? null, sectionOrder++],
);
const onlineId = onlineRows[0]!.id;
for (let j = 0; j < data.onlineServices.entries.length; j++) {
const e = data.onlineServices.entries[j];
await tx.unsafe(
'INSERT INTO rate_entries (section_id, service, duration, price, price_max, description, notes, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
[onlineId, e.service, e.duration ?? null, e.price, e.priceMax ?? null, e.description ?? null, e.notes ?? null, j],
);
}
// Tour stops
for (let i = 0; i < data.tour.length; i++) {
const t = data.tour[i];
await tx.unsafe(
'INSERT INTO tour_stops (city, state, country, start_date, end_date, status, availability_note, pricing_tiers, notes, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
[
t.city, t.state, t.country ?? 'USA',
t.startDate, t.endDate,
t.bookingStatus,
t.availabilityNote ?? null,
t.pricingTiers ? JSON.stringify(t.pricingTiers) : null,
t.notes ?? null,
i,
],
);
}
// Gallery
for (let i = 0; i < data.gallery.length; i++) {
const g = data.gallery[i];
const filename = g.src.split('/').pop() ?? g.src;
const webpFilename = g.webpSrc?.split('/').pop() ?? null;
await tx.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) ? 1 : 0, webpFilename, g.intrinsicWidth ?? null, g.intrinsicHeight ?? null, 'protected', i],
);
}
// Policies
for (let i = 0; i < data.policies.length; i++) {
const s = data.policies[i];
const pRows = await tx.unsafe<Array<{ id: number }>>(
'INSERT INTO policy_sections (title, sort_order) VALUES ($1, $2) RETURNING id',
[s.title, i],
);
const sId = pRows[0]!.id;
for (let j = 0; j < s.items.length; j++) {
await tx.unsafe(
'INSERT INTO policy_items (section_id, label, detail, sort_order) VALUES ($1, $2, $3, $4)',
[sId, s.items[j].label, s.items[j].detail, j],
);
}
}
// Etiquette
for (let i = 0; i < data.etiquette.length; i++) {
const s = data.etiquette[i];
const eRows = await tx.unsafe<Array<{ id: number }>>(
'INSERT INTO etiquette_sections (title, sort_order) VALUES ($1, $2) RETURNING id',
[s.title, i],
);
const sId = eRows[0]!.id;
for (let j = 0; j < s.items.length; j++) {
const item = s.items[j];
await tx.unsafe(
'INSERT INTO etiquette_items (section_id, label, detail, cta_href, cta_text, sort_order) VALUES ($1, $2, $3, $4, $5, $6)',
[sId, item.label, item.detail ?? null, item.ctaHref ?? null, item.ctaText ?? null, j],
);
}
}
// Destinations
for (let i = 0; i < destinations.length; i++) {
const d = destinations[i];
await tx.unsafe(
'INSERT INTO destinations (slug, city, country, region, fmty_tier, meta_title, meta_description, headline, intro, linked_tour_stop, experiences, note, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)',
[
d.slug, d.city, d.country, d.region ?? null, d.fmtyTier,
d.metaTitle, d.metaDescription, d.headline, d.intro,
d.linkedTourStop ?? false,
JSON.stringify(d.experiences ?? []),
d.note ?? null, i,
],
);
}
// Specialties
let specOrder = 0;
for (const cat of specialties) {
for (const item of cat.items) {
await tx.unsafe(
`INSERT INTO specialties
(category_slug, category_name, category_meta_title, category_meta_description, category_intro,
slug, name, meta_title, meta_description, headline, intro, includes, note, related_rate_type, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[
cat.slug, cat.name, cat.metaTitle, cat.metaDescription, cat.intro,
item.slug, item.name, item.metaTitle, item.metaDescription, item.headline, item.intro,
item.includes ? JSON.stringify(item.includes) : null,
item.note ?? null,
item.relatedRateType ?? null,
specOrder++,
],
);
}
}
// Site Text — seed all default UI strings
const siteTextDefaults: [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', 'touring_currently_in', 'Currently in {city}, {state} — see where I\'m headed →'],
['about', 'touring_heading_soon', 'Heading to {city}, {state} soon — full schedule →'],
['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 Quinn →'],
['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, contact directly for availability and logistics.'],
['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 to your city — domestic or international. Flights and accommodation are on her; the rate is fully all-inclusive. Text to discuss your location and dates; a deposit is required to secure the trip.'],
['tour', 'fmty_description_2', 'FMTY is available anywhere in the world, not limited to current tour stops. West Coast and Las Vegas rates apply to CA and NV cities. North America covers all other domestic travel. International is anywhere outside North America.'],
['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_availability', 'Availability'],
['contact', 'label_payment', 'Payment'],
['contact', 'label_whatsapp', 'WhatsApp'],
['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'],
];
for (const [ns, key, value] of siteTextDefaults) {
await tx.unsafe(
'INSERT INTO site_text (namespace, key, value) VALUES ($1, $2, $3) ON CONFLICT(namespace, key) DO NOTHING',
[ns, key, value],
);
}
await touchLastModified();
});
const count = async (table: string): Promise<number> => {
const rows = await sql.unsafe<Array<{ c: string }>>(`SELECT COUNT(*) as c FROM ${table}`);
return parseInt(rows[0]?.c ?? '0', 10);
};
logger.info('Migration complete', {
rateSections: await count('rate_sections'),
rateEntries: await count('rate_entries'),
tourStops: await count('tour_stops'),
galleryItems: await count('gallery_items'),
policySections: await count('policy_sections'),
policyItems: await count('policy_items'),
etiquetteSections: await count('etiquette_sections'),
etiquetteItems: await count('etiquette_items'),
activityMenus: await count('activity_menus'),
destinations: await count('destinations'),
specialties: await count('specialties'),
siteText: await count('site_text'),
});
}
main().catch((err: unknown) => {
logger.error('Migration failed', { error: String(err) });
process.exit(1);
});