From 4bf444c24bfbce26d17e41b9cb89bb3dfa433720 Mon Sep 17 00:00:00 2001 From: autocommit Date: Tue, 5 May 2026 15:16:53 -0700 Subject: [PATCH] =?UTF-8?q?infra(migrate-specific):=20=F0=9F=A7=B1=20Intro?= =?UTF-8?q?duce=20TypeScript=20migration=20script=20to=20extract,=20transf?= =?UTF-8?q?orm,=20and=20load=20data=20from=20quinn-admin=20to=20quinn-pg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- scripts/quinn-admin-to-quinn-pg-migrate.ts | 216 +++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 scripts/quinn-admin-to-quinn-pg-migrate.ts diff --git a/scripts/quinn-admin-to-quinn-pg-migrate.ts b/scripts/quinn-admin-to-quinn-pg-migrate.ts new file mode 100644 index 00000000..370c75f0 --- /dev/null +++ b/scripts/quinn-admin-to-quinn-pg-migrate.ts @@ -0,0 +1,216 @@ +#!/usr/bin/env bun +/** + * One-shot migration: quinn_admin Postgres → quinn Postgres (the unified backend). + * + * Copies the singleton + rate data that lives only in quinn_admin into the + * shapes quinn.api expects: + * + * quinn_admin.identity ─┐ + * quinn_admin.physical ─┴─► quinn.provider_profiles (provider_slug='quinn') + * + * quinn_admin.rate_sections ─► quinn.rate_cards + * quinn_admin.rate_entries ─► quinn.rate_entries (FK card_id ← new rate_cards.id) + * + * Idempotent: + * - provider_profiles: ON CONFLICT (provider_slug) DO NOTHING. + * If a row already exists for 'quinn' we leave it alone (re-run is safe but + * will not overwrite a populated profile). + * - rate_cards / rate_entries: skipped entirely if the target already has + * any rate_cards rows for provider_slug='quinn'. + * + * Prerequisites (run from this repo root): + * + * ssh -L 25435:127.0.0.1:6432 quinn-vps -fN + * + * Both DSNs default to that tunnel — override with env vars if you want to + * point elsewhere. + * + * bun run scripts/quinn-admin-to-quinn-pg-migrate.ts + * + * Note: quinn_admin password is not embedded — set ADMIN_DB_URL. + */ + +import postgres from 'postgres'; + +const ADMIN_DB_URL = + process.env['ADMIN_DB_URL'] ?? + (() => { throw new Error('ADMIN_DB_URL env var required (postgres://quinn_admin:...@127.0.0.1:25435/quinn_admin)'); })(); + +const QUINN_DB_URL = + process.env['QUINN_DB_URL'] ?? + 'postgres://quinn_api:5odj1WzCPk0BGtoVdHAIV7Z1veb2AzewM0k4sVqI@127.0.0.1:25435/quinn'; + +const PROVIDER_SLUG = process.env['PROVIDER_SLUG'] ?? 'quinn'; + +const adminSql = postgres(ADMIN_DB_URL, { max: 1, onnotice: () => { /* hush */ } }); +const quinnSql = postgres(QUINN_DB_URL, { max: 1, onnotice: () => { /* hush */ } }); + +const out = (msg: string): void => { process.stdout.write(msg + '\n'); }; +const err = (msg: string): void => { process.stderr.write(msg + '\n'); }; + +// --------------------------------------------------------------------------- +// identity + physical → provider_profiles +// --------------------------------------------------------------------------- + +interface IdentityRow { + name: string; pronouns: string; gender: string; location: string; + incall_city: string | null; tagline: string; + secondary_locations: string; languages: string; +} + +interface PhysicalRow { + age: string | null; height: string | null; body_type: string | null; + ethnicity: string | null; hair_color: string | null; hair_length: string | null; + eye_color: string | null; cup_size: string | null; + additional: string; +} + +function safeJsonParse(raw: string, fallback: T): T { + try { return JSON.parse(raw) as T; } catch { return fallback; } +} + +async function migrateProviderProfile(): Promise { + const [identity] = await adminSql`SELECT * FROM identity WHERE id = 1`; + const [physical] = await adminSql`SELECT * FROM physical WHERE id = 1`; + + if (!identity && !physical) { + out('SKIP provider_profile: no identity/physical rows in quinn_admin'); + return; + } + + const existing = await quinnSql>` + SELECT provider_slug FROM provider_profiles WHERE provider_slug = ${PROVIDER_SLUG} + `; + if (existing.length > 0) { + out(`SKIP provider_profile: quinn already has provider_profiles row for '${PROVIDER_SLUG}'`); + return; + } + + const identityJson = identity ? { + name: identity.name, + pronouns: identity.pronouns, + gender: identity.gender, + location: identity.location, + ...(identity.incall_city ? { incallCity: identity.incall_city } : {}), + secondaryLocations: safeJsonParse(identity.secondary_locations, []), + languages: safeJsonParse(identity.languages, []), + tagline: identity.tagline, + } : {}; + + const physicalJson = physical ? { + age: physical.age ?? '', + height: physical.height ?? '', + bodyType: physical.body_type ?? '', + ethnicity: physical.ethnicity ?? '', + hairColor: physical.hair_color ?? '', + hairLength: physical.hair_length ?? '', + eyeColor: physical.eye_color ?? '', + cupSize: physical.cup_size ?? '', + additional: safeJsonParse>(physical.additional, {}), + } : {}; + + await quinnSql` + INSERT INTO provider_profiles (provider_slug, identity_json, physical_json) + VALUES ( + ${PROVIDER_SLUG}, + ${JSON.stringify(identityJson)}, + ${JSON.stringify(physicalJson)} + ) + ON CONFLICT (provider_slug) DO NOTHING + `; + out(`INSERT provider_profiles: provider_slug='${PROVIDER_SLUG}' (identity=${!!identity}, physical=${!!physical})`); +} + +// --------------------------------------------------------------------------- +// rate_sections + rate_entries → rate_cards + rate_entries +// --------------------------------------------------------------------------- + +interface AdminRateSection { + id: number; section_type: string; title: string; + description: string | null; sort_order: number; +} + +interface AdminRateEntry { + id: number; section_id: number; service: string; + duration: string | null; price: number; price_max: number | null; + description: string | null; notes: string | null; sort_order: number; +} + +const QUINN_KINDS = new Set(['incall', 'outcall', 'addons', 'travel', 'touring', 'online']); + +async function migrateRates(): Promise { + const [{ c }] = await quinnSql<[{ c: number }]>` + SELECT count(*)::int AS c FROM rate_cards WHERE provider_slug = ${PROVIDER_SLUG} + `; + if (c > 0) { out(`SKIP rate_cards: quinn already has ${c} rows for '${PROVIDER_SLUG}'`); return; } + + const sections = await adminSql`SELECT * FROM rate_sections ORDER BY id ASC`; + if (sections.length === 0) { out('SKIP rate_sections: empty in quinn_admin'); return; } + + const entries = await adminSql`SELECT * FROM rate_entries ORDER BY id ASC`; + const entriesBySection = new Map(); + for (const e of entries) { + const list = entriesBySection.get(e.section_id) ?? []; + list.push(e); + entriesBySection.set(e.section_id, list); + } + + let cardsInserted = 0; + let entriesInserted = 0; + + for (const sec of sections) { + const kind = QUINN_KINDS.has(sec.section_type) ? sec.section_type : null; + if (!kind) { + out(`WARN rate_section id=${sec.id} section_type='${sec.section_type}' is not a valid quinn rate_card kind — skipping`); + continue; + } + + const [card] = await quinnSql>` + INSERT INTO rate_cards (kind, title, description, sort_order, provider_slug) + VALUES (${kind}, ${sec.title}, ${sec.description}, ${sec.sort_order}, ${PROVIDER_SLUG}) + RETURNING id + `; + if (!card) continue; + cardsInserted += 1; + + const sectionEntries = entriesBySection.get(sec.id) ?? []; + for (const e of sectionEntries) { + await quinnSql` + INSERT INTO rate_entries ( + card_id, service, duration, price, price_max, + description, notes, sort_order + ) VALUES ( + ${card.id}, ${e.service}, ${e.duration}, ${e.price}, ${e.price_max}, + ${e.description}, ${e.notes}, ${e.sort_order} + ) + `; + entriesInserted += 1; + } + } + + out(`INSERT rate_cards: ${cardsInserted} | rate_entries: ${entriesInserted}`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main(): Promise { + const redact = (u: string): string => u.replace(/:\/\/[^@]+@/, '://@'); + out(`Source: ${redact(ADMIN_DB_URL)}`); + out(`Target: ${redact(QUINN_DB_URL)}`); + out(`Provider slug: ${PROVIDER_SLUG}\n`); + + await migrateProviderProfile(); + await migrateRates(); + + out('\nDone.'); +} + +main().catch((e: unknown) => { + err(String(e)); + process.exit(1); +}).finally(async () => { + await adminSql.end(); + await quinnSql.end(); +});