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