infra(migrate-specific): 🧱 Introduce TypeScript migration script to extract, transform, and load data from quinn-admin to quinn-pg
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
5e4e26afe2
commit
4bf444c24b
1 changed files with 216 additions and 0 deletions
216
scripts/quinn-admin-to-quinn-pg-migrate.ts
Normal file
216
scripts/quinn-admin-to-quinn-pg-migrate.ts
Normal file
|
|
@ -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<T>(raw: string, fallback: T): T {
|
||||
try { return JSON.parse(raw) as T; } catch { return fallback; }
|
||||
}
|
||||
|
||||
async function migrateProviderProfile(): Promise<void> {
|
||||
const [identity] = await adminSql<IdentityRow[]>`SELECT * FROM identity WHERE id = 1`;
|
||||
const [physical] = await adminSql<PhysicalRow[]>`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<Array<{ provider_slug: string }>>`
|
||||
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<string[]>(identity.secondary_locations, []),
|
||||
languages: safeJsonParse<string[]>(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<Record<string, string>>(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<void> {
|
||||
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<AdminRateSection[]>`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<AdminRateEntry[]>`SELECT * FROM rate_entries ORDER BY id ASC`;
|
||||
const entriesBySection = new Map<number, AdminRateEntry[]>();
|
||||
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<Array<{ id: number }>>`
|
||||
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<void> {
|
||||
const redact = (u: string): string => u.replace(/:\/\/[^@]+@/, '://<creds>@');
|
||||
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();
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue