#!/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));