v4.0.0.2
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
#!/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));
|
||||
Reference in New Issue
Block a user