235 lines
10 KiB
JavaScript
235 lines
10 KiB
JavaScript
#!/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));
|