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