From 4d018be80070cffb43f0c78c76f4f4701d434371 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 17 Jun 2026 18:28:01 +0200 Subject: [PATCH] feat(inquiries): one-off NocoDB historical contact-form import (idempotent, dry-run default) Co-Authored-By: Claude Fable 5 --- .../import-website-inquiries-from-nocodb.ts | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 scripts/import-website-inquiries-from-nocodb.ts diff --git a/scripts/import-website-inquiries-from-nocodb.ts b/scripts/import-website-inquiries-from-nocodb.ts new file mode 100644 index 00000000..23de7e7d --- /dev/null +++ b/scripts/import-website-inquiries-from-nocodb.ts @@ -0,0 +1,176 @@ +/** + * One-off import of historical "Website – Contact Form Submissions" from NocoDB + * into the CRM `website_submissions` table, so they show up in the Inquiries + * workbench alongside post-cutover submissions. + * + * The cutover migration imported interests / residential / berths / expenses but + * NOT the contact-form table — those general contact-page inquiries (the + * "broker"/"investor"/"owner" enquiries) were left behind in NocoDB. + * + * Idempotent: each row maps to a deterministic `submission_id` + * (`nocodb-cf-`) guarded by the unique index, plus a `migration_source_links` + * ledger row (`source_system='nocodb_website_submissions'`). Re-running is a + * no-op for already-imported rows. + * + * Usage: + * pnpm tsx scripts/import-website-inquiries-from-nocodb.ts # dry-run + * pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply # write + * pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply --port-slug port-nimara + * + * Requires NOCODB_URL + NOCODB_TOKEN in env (same as the migration). Writes to + * whatever DATABASE_URL points at — point it at prod ONLY with explicit approval. + */ + +import 'dotenv/config'; +import { eq } from 'drizzle-orm'; + +import { db, closeDb } from '@/lib/db'; +import { ports } from '@/lib/db/schema/ports'; +import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; +import { migrationSourceLinks } from '@/lib/db/schema/migration'; +import { + loadNocoDbConfig, + fetchAllRows, + NOCO_TABLES, + type NocoDbRow, +} from '@/lib/dedup/nocodb-source'; + +const SOURCE_SYSTEM = 'nocodb_website_submissions'; +const APPLIED_ID = 'import-website-inquiries'; + +function arg(name: string): string | undefined { + const hit = process.argv.find((a) => a.startsWith(`--${name}=`)); + if (hit) return hit.split('=')[1]; + const idx = process.argv.indexOf(`--${name}`); + if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1]!.startsWith('--')) { + return process.argv[idx + 1]; + } + return undefined; +} + +function str(row: NocoDbRow, ...keys: string[]): string { + for (const k of keys) { + const v = row[k]; + if (typeof v === 'string' && v.trim()) return v.trim(); + } + return ''; +} + +function parseDate(row: NocoDbRow): Date { + const raw = str(row, 'CreatedAt', 'created_at', 'Created At', 'createdAt'); + if (raw) { + const d = new Date(raw); + if (!Number.isNaN(d.getTime())) return d; + } + return new Date(); +} + +async function main() { + const apply = process.argv.includes('--apply'); + const portSlug = arg('port-slug') ?? 'port-nimara'; + + const [port] = await db + .select({ id: ports.id }) + .from(ports) + .where(eq(ports.slug, portSlug)) + .limit(1); + if (!port) throw new Error(`Unknown port slug: ${portSlug}`); + + const config = loadNocoDbConfig(); + console.log(`[import] Fetching contact-form submissions from NocoDB…`); + const rows = await fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config); + console.log(`[import] Fetched ${rows.length} rows from NocoDB.`); + + let inserted = 0; + let skipped = 0; + const samples: Array> = []; + + for (const row of rows) { + const legacyId = String(row.Id); + const submissionId = `nocodb-cf-${legacyId}`; + const fullName = str(row, 'Full Name', 'Name', 'full_name'); + const email = str(row, 'Email Address', 'Email', 'email'); + const interest = str(row, 'Type of Interest', 'interest'); + const comments = str(row, 'Comments', 'comments'); + const receivedAt = parseDate(row); + + const payload = { + name: fullName, + email, + interest, + comments, + imported_from: 'nocodb_contact_form', + legacy_nocodb_id: legacyId, + }; + + if (samples.length < 3) { + samples.push({ + submissionId, + fullName, + email, + interest, + receivedAt: receivedAt.toISOString(), + }); + } + + if (!apply) { + // Dry-run: count how many are not yet present. + const [existing] = await db + .select({ id: websiteSubmissions.id }) + .from(websiteSubmissions) + .where(eq(websiteSubmissions.submissionId, submissionId)) + .limit(1); + if (existing) skipped += 1; + else inserted += 1; + continue; + } + + const result = await db + .insert(websiteSubmissions) + .values({ + portId: port.id, + submissionId, + kind: 'contact_form', + payload, + contactName: fullName || null, + contactEmail: email || null, + legacyNocodbId: legacyId, + receivedAt, + triageState: 'open', + }) + .onConflictDoNothing({ target: websiteSubmissions.submissionId }) + .returning({ id: websiteSubmissions.id }); + + if (result[0]) { + inserted += 1; + await db + .insert(migrationSourceLinks) + .values({ + sourceSystem: SOURCE_SYSTEM, + sourceId: legacyId, + targetEntityType: 'website_submission', + targetEntityId: result[0].id, + appliedId: APPLIED_ID, + }) + .onConflictDoNothing(); + } else { + skipped += 1; + } + } + + console.log('\n[import] Sample rows:'); + for (const s of samples) console.log(' ', JSON.stringify(s)); + console.log( + `\n[import] ${apply ? 'APPLIED' : 'DRY-RUN'} — port=${portSlug}: ${inserted} ${ + apply ? 'inserted' : 'would insert' + }, ${skipped} skipped (already present).`, + ); + if (!apply) console.log('[import] Re-run with --apply to write these rows.'); + + await closeDb(); +} + +main().catch((err) => { + console.error('[import] FAILED:', err); + process.exitCode = 1; +});