Files
gw-svelte/scripts/clean-legacy-onboarding.mjs
T

270 lines
10 KiB
JavaScript
Raw Normal View History

2026-05-26 08:30:08 +12:00
#!/usr/bin/env node
// Cleans the legacy Gravity Forms onboarding CSV export into a structured JSON
// file ready for later import into Postgres.
//
// Input: dog-enrolment-form-2026-05-20.csv (repo root)
// Output: data/legacy-onboarding.json
//
// Run: node scripts/clean-legacy-onboarding.mjs
import { readFileSync, writeFileSync, mkdirSync } 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 INPUT = resolve(ROOT, 'dog-enrolment-form-2026-05-20.csv');
const OUTPUT = resolve(ROOT, 'data/legacy-onboarding.json');
function parseCsv(text) {
const rows = [];
let cur = [];
let val = '';
let inQ = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQ) {
if (c === '"') {
if (text[i + 1] === '"') { val += '"'; i++; }
else inQ = false;
} else val += c;
} else {
if (c === '"') inQ = true;
else if (c === ',') { cur.push(val); val = ''; }
else if (c === '\n') { cur.push(val); rows.push(cur); cur = []; val = ''; }
else if (c === '\r') { /* skip */ }
else val += c;
}
}
if (val.length || cur.length) { cur.push(val); rows.push(cur); }
return rows;
}
const trimOrNull = (v) => {
const s = (v ?? '').trim();
if (!s) return null;
if (s.toLowerCase() === 'select') return null;
return s;
};
const yesNo = (v) => {
const s = (v ?? '').trim().toLowerCase();
if (!s || s === 'select') return null;
if (s === 'yes') return true;
if (s === 'no') return false;
return null;
};
// reactive-to-animals style: 'Yes'/'No'/'' or a free-text species ('Cats', 'Birds', 'Other').
const reactiveField = (v) => {
const s = (v ?? '').trim();
if (!s) return { reactive: null, detail: null };
const lower = s.toLowerCase();
if (lower === 'yes') return { reactive: true, detail: null };
if (lower === 'no') return { reactive: false, detail: null };
if (lower === 'select') return { reactive: null, detail: null };
return { reactive: true, detail: s };
};
const parseFloatOrNull = (v) => {
const s = (v ?? '').trim();
if (!s) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
};
// Best-effort NZ phone normalisation. Keeps the raw, adds an e164 when we can
// confidently produce one. Doesn't try to invent missing data.
function normalizePhone(raw) {
const original = (raw ?? '').trim();
if (!original) return { raw: null, e164: null };
let digits = original.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) {
return { raw: original, e164: '+' + digits.slice(1).replace(/\D/g, '') };
}
digits = digits.replace(/\D/g, '');
if (!digits) return { raw: original, e164: null };
if (digits.startsWith('64')) return { raw: original, e164: '+' + digits };
if (digits.startsWith('0')) return { raw: original, e164: '+64' + digits.slice(1) };
return { raw: original, e164: null };
}
const isoDateOrNull = (v) => {
const s = (v ?? '').trim();
if (!s) return null;
// CSV already uses YYYY-MM-DD or 'YYYY-MM-DD HH:MM:SS'
return s;
};
function emailGuess(ownerName, ownerSurname) {
// Legacy CSV doesn't include owner email. Leaving null so the Postgres
// import treats these as needing email-claim before they can sign in.
return null;
}
const raw = readFileSync(INPUT, 'utf8').replace(/^/, '');
const rows = parseCsv(raw);
const headers = rows[0];
const data = rows.slice(1).filter((r) => r.some((cell) => (cell ?? '').trim() !== ''));
// Column index lookups (defensive in case header order shifts).
const idx = Object.fromEntries(headers.map((h, i) => [h.trim(), i]));
const col = (row, name) => row[idx[name]] ?? '';
const records = data.map((row) => {
const hasFoodAllergy = yesNo(col(row, 'Does your dog have any food allergies?'));
const foodAllergyDetail = trimOrNull(col(row, 'Specify'));
// Two columns are labelled 'Specify'. The first is food, then env, then diet,
// then medication — pick them by position.
const specifyCols = headers
.map((h, i) => ({ h, i }))
.filter((x) => x.h.trim() === 'Specify')
.map((x) => x.i);
const [foodSpecifyIdx, envSpecifyIdx, dietSpecifyIdx, medSpecifyIdx] = specifyCols;
const phone = normalizePhone(col(row, 'Owner Contact'));
const emergencyPhone = normalizePhone(col(row, 'Emergency Contact Number'));
const vetPhone = normalizePhone(col(row, 'Vet Contact Number'));
const dogBirth = isoDateOrNull(col(row, "Dog's date of birth"));
const signedOn = isoDateOrNull(col(row, 'Date'));
const entryDate = isoDateOrNull(col(row, 'Entry Date'));
const updatedDate = isoDateOrNull(col(row, 'Date Updated'));
const ownerFirst = trimOrNull(col(row, 'Owner Name'));
const ownerLast = trimOrNull(col(row, 'Owner Surname'));
return {
legacy: {
entryId: trimOrNull(col(row, 'Entry Id')),
entryDate,
dateUpdated: updatedDate,
createdByUserId: trimOrNull(col(row, 'Created By (User Id)')),
sourceUrl: trimOrNull(col(row, 'Source Url')),
signatureUrl: trimOrNull(col(row, 'Signature')),
pdfUrl: trimOrNull(col(row, 'PDF: DogOnboardingForm')),
userAgent: trimOrNull(col(row, 'User Agent')),
userIp: trimOrNull(col(row, 'User IP')),
},
owner: {
firstName: ownerFirst,
lastName: ownerLast,
fullName: [ownerFirst, ownerLast].filter(Boolean).join(' ') || null,
email: emailGuess(ownerFirst, ownerLast),
phone: phone.e164,
phoneRaw: phone.raw,
address: null, // not collected in legacy form
},
emergencyContact: {
name: trimOrNull(col(row, 'Emergency Contact Name')),
phone: emergencyPhone.e164,
phoneRaw: emergencyPhone.raw,
},
dog: {
name: trimOrNull(col(row, 'Dog Name')),
surname: trimOrNull(col(row, 'Dog Surname')),
dateOfBirth: dogBirth,
breed: null, // not collected in legacy form
},
vet: {
name: trimOrNull(col(row, 'Vet Name')),
address: trimOrNull(col(row, 'Vet Address')),
phone: vetPhone.e164,
phoneRaw: vetPhone.raw,
},
health: {
vaccinated: yesNo(col(row, 'Is your dog vaccinated?')),
foodAllergy: {
present: hasFoodAllergy,
detail: hasFoodAllergy === true ? trimOrNull(row[foodSpecifyIdx]) : null,
},
environmentalAllergy: (() => {
const present = yesNo(col(row, 'Does your dog have any environmental allergy?'));
return {
present,
detail: present === true ? trimOrNull(row[envSpecifyIdx]) : null,
};
})(),
specialDiet: (() => {
const present = yesNo(col(row, 'Is your dog on a special diet?'));
return {
present,
detail: present === true ? trimOrNull(row[dietSpecifyIdx]) : null,
};
})(),
medication: (() => {
const present = yesNo(col(row, 'Is your dog taking any medication that could put him at risk during a walk'));
return {
present,
detail: present === true ? trimOrNull(row[medSpecifyIdx]) : null,
};
})(),
},
behaviour: {
wellSocialised: yesNo(col(row, 'Is your dog well socialised?')),
dogsInteractedWeekly: trimOrNull(col(row, 'How many dogs does your dog interact with weekly (excluding your family dogs)')),
beachNotes: trimOrNull(col(row, 'Does your dog visit the beach?')),
dogParkNotes: trimOrNull(col(row, 'Does your dog visit dog parks frequently - How many times a week?')),
biteHistory: yesNo(col(row, 'Does your dog have a bite history?')),
reactiveToDogs: yesNo(col(row, 'Is your dog reactive to other dogs?')),
reactiveToAnimals: reactiveField(col(row, 'Is your dog reactive to other animals?')),
reactiveToChildren: yesNo(col(row, 'Is your dog reactive to children?')),
reactiveToPeople: yesNo(col(row, 'Is your dog reactive to other people?')),
desexed: yesNo(col(row, 'Is your dog desexed?')),
councilRegistered: yesNo(col(row, 'Is your dog registered?')),
leashTrained: yesNo(col(row, 'Is your dog leash trained?')),
recallRating: parseFloatOrNull(col(row, "Rate your dog's recall from 1 to 5, with one being the lowest score and 5 the highest.")),
ranAwayBefore: yesNo(col(row, 'Has your dog ran away before?')),
carBehaviour: trimOrNull(col(row, "Please describe your dog's behaviour in the car")),
knownCommands: trimOrNull(col(row, 'List of commands your dog understands')),
},
misc: {
additionalNotes: trimOrNull(col(row, "Anything else you'd like to let us know?")),
socialMediaAccount: trimOrNull(col(row, 'Social Media Account Name')),
howDidYouHear: trimOrNull(col(row, 'How did you hear about Goodwalk?')),
referredBy: trimOrNull(col(row, "Person's name who reffered Goodwalk to you")),
},
declaration: {
signedOn,
signatureUrl: trimOrNull(col(row, 'Signature')),
// Legacy submissions implicitly accepted T&Cs when submitted.
termsAccepted: true,
emergencyVetConsent: true,
},
};
});
mkdirSync(dirname(OUTPUT), { recursive: true });
const payload = {
exportedAt: new Date().toISOString(),
source: {
file: 'dog-enrolment-form-2026-05-20.csv',
rows: data.length,
columns: headers.length,
},
notes: [
'Legacy Gravity Forms export. Owner email/address were not collected by the old form.',
'Dog breed not collected by the old form.',
'Phone numbers retained as raw + best-effort E.164 (+64). Verify before dialling.',
"Yes/No fields parsed to booleans; 'Select' and blanks become null.",
"'Reactive to other animals' values like Cats/Birds/Other map to { reactive: true, detail }.",
],
records,
};
writeFileSync(OUTPUT, JSON.stringify(payload, null, 2) + '\n', 'utf8');
// Print a small summary so the operator can sanity-check.
const summary = {
records: records.length,
withOwnerPhone: records.filter((r) => r.owner.phone).length,
withVetPhone: records.filter((r) => r.vet.phone).length,
withEmergencyPhone: records.filter((r) => r.emergencyContact.phone).length,
withSignature: records.filter((r) => r.declaration.signatureUrl).length,
vaccinatedTrue: records.filter((r) => r.health.vaccinated === true).length,
councilRegistered: records.filter((r) => r.behaviour.councilRegistered === true).length,
output: OUTPUT,
};
console.log(JSON.stringify(summary, null, 2));