Files
gw-svelte/scripts/clean-legacy-onboarding.mjs
T
2026-05-26 08:30:08 +12:00

270 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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));