177 lines
5.5 KiB
TypeScript
177 lines
5.5 KiB
TypeScript
/**
|
||
* 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-<id>`) 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<Record<string, unknown>> = [];
|
||
|
||
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;
|
||
});
|