#!/usr/bin/env node // Builds mail-api/legacy-clients-seed.json from the three cleaned legacy JSON // files. The seed file is shipped into the mail-api Docker image and merged // into _client_profiles on first boot — never overwriting a live entry. // // Inputs: // data/legacy-clients.json // data/legacy-onboarding.json // data/legacy-contracts.json // // Output: // mail-api/legacy-clients-seed.json (email -> profile dict) // // The output shape mirrors the live _client_profiles entries that the mail-api // already serializes to JSON, so the merge path doesn't need to translate // anything. import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const CLIENTS_FILE = resolve(ROOT, 'data/legacy-clients.json'); const ONBOARDING_FILE = resolve(ROOT, 'data/legacy-onboarding.json'); const CONTRACTS_FILE = resolve(ROOT, 'data/legacy-contracts.json'); const OUTPUT = resolve(ROOT, 'mail-api/legacy-clients-seed.json'); for (const p of [CLIENTS_FILE, ONBOARDING_FILE, CONTRACTS_FILE]) { if (!existsSync(p)) { console.error(`Required legacy file missing: ${p}`); process.exit(1); } } const clientsDoc = JSON.parse(readFileSync(CLIENTS_FILE, 'utf8')); const onboardingDoc = JSON.parse(readFileSync(ONBOARDING_FILE, 'utf8')); const contractsDoc = JSON.parse(readFileSync(CONTRACTS_FILE, 'utf8')); const onboardingByEntryId = new Map(); for (const rec of onboardingDoc.records ?? []) { if (rec?.legacy?.entryId) onboardingByEntryId.set(String(rec.legacy.entryId), rec); } const contractsByEntryId = new Map(); for (const rec of contractsDoc.records ?? []) { if (rec?.legacy?.entryId) contractsByEntryId.set(String(rec.legacy.entryId), rec); } const importedAt = new Date().toISOString(); function pickOnboarding(client) { // Take the latest onboarding entry referenced for this client. Entry IDs // are numeric — higher == newer. const ids = (client.onboardingEntryIds ?? []) .map((id) => String(id)) .filter((id) => onboardingByEntryId.has(id)) .sort((a, b) => Number(b) - Number(a)); return ids.length ? onboardingByEntryId.get(ids[0]) : null; } function pickContract(client) { const ids = (client.contractEntryIds ?? []) .map((id) => String(id)) .filter((id) => contractsByEntryId.has(id)) .sort((a, b) => Number(b) - Number(a)); return ids.length ? contractsByEntryId.get(ids[0]) : null; } function nonEmpty(v) { return typeof v === 'string' ? v.trim() : v; } function fullName(client) { const parts = [client.firstName, client.lastName].map(nonEmpty).filter(Boolean); return parts.join(' '); } function buildProfile(client) { const onboarding = pickOnboarding(client); const contract = pickContract(client); const dog = (client.dogs || [])[0] || {}; const dogName = dog.name || onboarding?.dog?.name || ''; const dogBreed = dog.breed || onboarding?.dog?.breed || ''; const dogDob = dog.dateOfBirth || onboarding?.dog?.dateOfBirth || ''; // Pick a submittedAt timestamp from the best signal we have. const submittedAt = onboarding?.declaration?.signedOn || onboarding?.legacy?.entryDate || contract?.consent?.signedOn || contract?.legacy?.entryDate || ''; // Build the snapshot the admin dashboard expects when viewing a completed // client. The shape matches what OnboardingPage.svelte writes today: // { submittedAt, sections: [{ title, icon, fields: [{label, value}] }] }. const sections = []; if (onboarding) { const yn = (v) => (v === true ? 'Yes' : v === false ? 'No' : '—'); const fv = (v) => (typeof v === 'string' && v.trim()) ? v.trim() : '—'; sections.push({ title: 'Owner Details', icon: 'fas fa-user', fields: [ { label: 'Owner First Name', value: fv(onboarding.owner?.firstName) }, { label: 'Owner Surname', value: fv(onboarding.owner?.lastName) }, { label: 'Email', value: fv(client.email) }, { label: 'Contact Number', value: fv(client.phoneRaw || client.phone) }, { label: 'Home Address', value: fv(client.address) }, ], }); sections.push({ title: 'Dog Details', icon: 'fas fa-paw', fields: [ { label: 'Dog Name', value: fv(onboarding.dog?.name) }, { label: 'Dog Surname', value: fv(onboarding.dog?.surname) }, { label: 'Breed', value: fv(onboarding.dog?.breed) }, { label: 'Date of Birth', value: fv(onboarding.dog?.dateOfBirth) }, ], }); sections.push({ title: 'Vet & Emergency', icon: 'fas fa-heart-pulse', fields: [ { label: 'Vet Name', value: fv(onboarding.vet?.name) }, { label: 'Vet Address', value: fv(onboarding.vet?.address) }, { label: 'Vet Phone', value: fv(onboarding.vet?.phoneRaw || onboarding.vet?.phone) }, { label: 'Emergency Contact Name', value: fv(onboarding.emergencyContact?.name) }, { label: 'Emergency Contact Phone', value: fv(onboarding.emergencyContact?.phoneRaw || onboarding.emergencyContact?.phone) }, ], }); sections.push({ title: 'Health', icon: 'fas fa-stethoscope', fields: [ { label: 'Vaccinations current?', value: yn(onboarding.health?.vaccinated) }, { label: 'Food allergies', value: onboarding.health?.foodAllergy?.present ? fv(onboarding.health.foodAllergy.detail) : yn(onboarding.health?.foodAllergy?.present) }, { label: 'Environmental allergies', value: onboarding.health?.environmentalAllergy?.present ? fv(onboarding.health.environmentalAllergy.detail) : yn(onboarding.health?.environmentalAllergy?.present) }, { label: 'Special diet', value: onboarding.health?.specialDiet?.present ? fv(onboarding.health.specialDiet.detail) : yn(onboarding.health?.specialDiet?.present) }, { label: 'Medication', value: onboarding.health?.medication?.present ? fv(onboarding.health.medication.detail) : yn(onboarding.health?.medication?.present) }, ], }); sections.push({ title: 'Behaviour', icon: 'fas fa-bone', fields: [ { label: 'Well socialised?', value: yn(onboarding.behaviour?.wellSocialised) }, { label: 'Dogs interacted with weekly', value: fv(onboarding.behaviour?.dogsInteractedWeekly) }, { label: 'Beach notes', value: fv(onboarding.behaviour?.beachNotes) }, { label: 'Dog park notes', value: fv(onboarding.behaviour?.dogParkNotes) }, { label: 'Bite history?', value: yn(onboarding.behaviour?.biteHistory) }, { label: 'Reactive to dogs?', value: yn(onboarding.behaviour?.reactiveToDogs) }, { label: 'Reactive to animals?', value: onboarding.behaviour?.reactiveToAnimals?.reactive ? fv(onboarding.behaviour.reactiveToAnimals.detail) : yn(onboarding.behaviour?.reactiveToAnimals?.reactive) }, { label: 'Reactive to children?', value: yn(onboarding.behaviour?.reactiveToChildren) }, { label: 'Reactive to people?', value: yn(onboarding.behaviour?.reactiveToPeople) }, { label: 'Desexed?', value: yn(onboarding.behaviour?.desexed) }, { label: 'Council registered?', value: yn(onboarding.behaviour?.councilRegistered) }, { label: 'Leash trained?', value: yn(onboarding.behaviour?.leashTrained) }, { label: 'Recall rating', value: onboarding.behaviour?.recallRating != null ? String(onboarding.behaviour.recallRating) : '—' }, { label: 'Ever run away?', value: yn(onboarding.behaviour?.ranAwayBefore) }, { label: 'Behaviour in car', value: fv(onboarding.behaviour?.carBehaviour) }, { label: 'Known commands', value: fv(onboarding.behaviour?.knownCommands) }, ], }); sections.push({ title: 'Other', icon: 'fas fa-file-signature', fields: [ { label: 'Additional notes', value: fv(onboarding.misc?.additionalNotes) }, { label: 'Social media', value: fv(onboarding.misc?.socialMediaAccount) }, { label: 'How did you hear?', value: fv(onboarding.misc?.howDidYouHear) }, { label: 'Referred by', value: fv(onboarding.misc?.referredBy) }, ], }); } const profile = { fullName: fullName(client), phone: client.phone || '', address: client.address || '', dogName: dogName || '', dogBreed: dogBreed || '', dogAge: dogDob || '', onboardingCompleted: Boolean(onboarding), onboardingSubmittedAt: submittedAt || '', onboardingSubmission: sections.length ? { submittedAt: submittedAt || '', sections } : undefined, // Mark legacy imports as archived by default — they pre-date the new // system and shouldn't pollute "active" outreach lists. The admin can // promote any of them back to 'active' from the dashboard. lifecycle: { status: 'archived', reason: 'Imported from legacy Gravity Forms data', changedAt: importedAt, changedBy: 'legacy-import', }, // Provenance so we can always trace what came from the migration. legacy: { source: 'gravity-forms-csv', importedAt, onboardingEntryIds: client.onboardingEntryIds ?? [], contractEntryIds: client.contractEntryIds ?? [], onboardingPdfUrl: onboarding?.legacy?.pdfUrl ?? null, contractPdfUrl: contract?.legacy?.pdfUrl ?? null, signatureUrl: onboarding?.declaration?.signatureUrl ?? contract?.legacy?.signatureUrl ?? null, }, }; // Drop any keys that are empty strings so the mail-api merge path doesn't // store noisy blanks. for (const k of Object.keys(profile)) { if (profile[k] === '' || profile[k] === undefined) delete profile[k]; } return profile; } const out = {}; let written = 0; for (const client of clientsDoc.clients ?? []) { const email = String(client.email || '').trim().toLowerCase(); if (!email) continue; out[email] = buildProfile(client); written++; } writeFileSync(OUTPUT, JSON.stringify(out, null, 2) + '\n', 'utf8'); console.log(JSON.stringify({ ok: true, emails: written, outputPath: OUTPUT, note: 'Ship this file in the mail-api Docker image. Merged on boot — never overwrites live entries.', }, null, 2));