This commit is contained in:
2026-05-26 08:30:08 +12:00
parent 005aab8139
commit 135a5a3b83
75 changed files with 22417 additions and 4288 deletions
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
// Builds mail-api/legacy-clients-seed.json from the three cleaned legacy JSON
// files. The seed file is shipped into the mail-api Docker image and merged
// into _client_profiles on first boot — never overwriting a live entry.
//
// Inputs:
// data/legacy-clients.json
// data/legacy-onboarding.json
// data/legacy-contracts.json
//
// Output:
// mail-api/legacy-clients-seed.json (email -> profile dict)
//
// The output shape mirrors the live _client_profiles entries that the mail-api
// already serializes to JSON, so the merge path doesn't need to translate
// anything.
import { readFileSync, writeFileSync, existsSync } 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 CLIENTS_FILE = resolve(ROOT, 'data/legacy-clients.json');
const ONBOARDING_FILE = resolve(ROOT, 'data/legacy-onboarding.json');
const CONTRACTS_FILE = resolve(ROOT, 'data/legacy-contracts.json');
const OUTPUT = resolve(ROOT, 'mail-api/legacy-clients-seed.json');
for (const p of [CLIENTS_FILE, ONBOARDING_FILE, CONTRACTS_FILE]) {
if (!existsSync(p)) {
console.error(`Required legacy file missing: ${p}`);
process.exit(1);
}
}
const clientsDoc = JSON.parse(readFileSync(CLIENTS_FILE, 'utf8'));
const onboardingDoc = JSON.parse(readFileSync(ONBOARDING_FILE, 'utf8'));
const contractsDoc = JSON.parse(readFileSync(CONTRACTS_FILE, 'utf8'));
const onboardingByEntryId = new Map();
for (const rec of onboardingDoc.records ?? []) {
if (rec?.legacy?.entryId) onboardingByEntryId.set(String(rec.legacy.entryId), rec);
}
const contractsByEntryId = new Map();
for (const rec of contractsDoc.records ?? []) {
if (rec?.legacy?.entryId) contractsByEntryId.set(String(rec.legacy.entryId), rec);
}
const importedAt = new Date().toISOString();
function pickOnboarding(client) {
// Take the latest onboarding entry referenced for this client. Entry IDs
// are numeric — higher == newer.
const ids = (client.onboardingEntryIds ?? [])
.map((id) => String(id))
.filter((id) => onboardingByEntryId.has(id))
.sort((a, b) => Number(b) - Number(a));
return ids.length ? onboardingByEntryId.get(ids[0]) : null;
}
function pickContract(client) {
const ids = (client.contractEntryIds ?? [])
.map((id) => String(id))
.filter((id) => contractsByEntryId.has(id))
.sort((a, b) => Number(b) - Number(a));
return ids.length ? contractsByEntryId.get(ids[0]) : null;
}
function nonEmpty(v) {
return typeof v === 'string' ? v.trim() : v;
}
function fullName(client) {
const parts = [client.firstName, client.lastName].map(nonEmpty).filter(Boolean);
return parts.join(' ');
}
function buildProfile(client) {
const onboarding = pickOnboarding(client);
const contract = pickContract(client);
const dog = (client.dogs || [])[0] || {};
const dogName = dog.name || onboarding?.dog?.name || '';
const dogBreed = dog.breed || onboarding?.dog?.breed || '';
const dogDob = dog.dateOfBirth || onboarding?.dog?.dateOfBirth || '';
// Pick a submittedAt timestamp from the best signal we have.
const submittedAt =
onboarding?.declaration?.signedOn ||
onboarding?.legacy?.entryDate ||
contract?.consent?.signedOn ||
contract?.legacy?.entryDate ||
'';
// Build the snapshot the admin dashboard expects when viewing a completed
// client. The shape matches what OnboardingPage.svelte writes today:
// { submittedAt, sections: [{ title, icon, fields: [{label, value}] }] }.
const sections = [];
if (onboarding) {
const yn = (v) => (v === true ? 'Yes' : v === false ? 'No' : '—');
const fv = (v) => (typeof v === 'string' && v.trim()) ? v.trim() : '—';
sections.push({
title: 'Owner Details',
icon: 'fas fa-user',
fields: [
{ label: 'Owner First Name', value: fv(onboarding.owner?.firstName) },
{ label: 'Owner Surname', value: fv(onboarding.owner?.lastName) },
{ label: 'Email', value: fv(client.email) },
{ label: 'Contact Number', value: fv(client.phoneRaw || client.phone) },
{ label: 'Home Address', value: fv(client.address) },
],
});
sections.push({
title: 'Dog Details',
icon: 'fas fa-paw',
fields: [
{ label: 'Dog Name', value: fv(onboarding.dog?.name) },
{ label: 'Dog Surname', value: fv(onboarding.dog?.surname) },
{ label: 'Breed', value: fv(onboarding.dog?.breed) },
{ label: 'Date of Birth', value: fv(onboarding.dog?.dateOfBirth) },
],
});
sections.push({
title: 'Vet & Emergency',
icon: 'fas fa-heart-pulse',
fields: [
{ label: 'Vet Name', value: fv(onboarding.vet?.name) },
{ label: 'Vet Address', value: fv(onboarding.vet?.address) },
{ label: 'Vet Phone', value: fv(onboarding.vet?.phoneRaw || onboarding.vet?.phone) },
{ label: 'Emergency Contact Name', value: fv(onboarding.emergencyContact?.name) },
{ label: 'Emergency Contact Phone', value: fv(onboarding.emergencyContact?.phoneRaw || onboarding.emergencyContact?.phone) },
],
});
sections.push({
title: 'Health',
icon: 'fas fa-stethoscope',
fields: [
{ label: 'Vaccinations current?', value: yn(onboarding.health?.vaccinated) },
{ label: 'Food allergies', value: onboarding.health?.foodAllergy?.present ? fv(onboarding.health.foodAllergy.detail) : yn(onboarding.health?.foodAllergy?.present) },
{ label: 'Environmental allergies', value: onboarding.health?.environmentalAllergy?.present ? fv(onboarding.health.environmentalAllergy.detail) : yn(onboarding.health?.environmentalAllergy?.present) },
{ label: 'Special diet', value: onboarding.health?.specialDiet?.present ? fv(onboarding.health.specialDiet.detail) : yn(onboarding.health?.specialDiet?.present) },
{ label: 'Medication', value: onboarding.health?.medication?.present ? fv(onboarding.health.medication.detail) : yn(onboarding.health?.medication?.present) },
],
});
sections.push({
title: 'Behaviour',
icon: 'fas fa-bone',
fields: [
{ label: 'Well socialised?', value: yn(onboarding.behaviour?.wellSocialised) },
{ label: 'Dogs interacted with weekly', value: fv(onboarding.behaviour?.dogsInteractedWeekly) },
{ label: 'Beach notes', value: fv(onboarding.behaviour?.beachNotes) },
{ label: 'Dog park notes', value: fv(onboarding.behaviour?.dogParkNotes) },
{ label: 'Bite history?', value: yn(onboarding.behaviour?.biteHistory) },
{ label: 'Reactive to dogs?', value: yn(onboarding.behaviour?.reactiveToDogs) },
{ label: 'Reactive to animals?', value: onboarding.behaviour?.reactiveToAnimals?.reactive ? fv(onboarding.behaviour.reactiveToAnimals.detail) : yn(onboarding.behaviour?.reactiveToAnimals?.reactive) },
{ label: 'Reactive to children?', value: yn(onboarding.behaviour?.reactiveToChildren) },
{ label: 'Reactive to people?', value: yn(onboarding.behaviour?.reactiveToPeople) },
{ label: 'Desexed?', value: yn(onboarding.behaviour?.desexed) },
{ label: 'Council registered?', value: yn(onboarding.behaviour?.councilRegistered) },
{ label: 'Leash trained?', value: yn(onboarding.behaviour?.leashTrained) },
{ label: 'Recall rating', value: onboarding.behaviour?.recallRating != null ? String(onboarding.behaviour.recallRating) : '—' },
{ label: 'Ever run away?', value: yn(onboarding.behaviour?.ranAwayBefore) },
{ label: 'Behaviour in car', value: fv(onboarding.behaviour?.carBehaviour) },
{ label: 'Known commands', value: fv(onboarding.behaviour?.knownCommands) },
],
});
sections.push({
title: 'Other',
icon: 'fas fa-file-signature',
fields: [
{ label: 'Additional notes', value: fv(onboarding.misc?.additionalNotes) },
{ label: 'Social media', value: fv(onboarding.misc?.socialMediaAccount) },
{ label: 'How did you hear?', value: fv(onboarding.misc?.howDidYouHear) },
{ label: 'Referred by', value: fv(onboarding.misc?.referredBy) },
],
});
}
const profile = {
fullName: fullName(client),
phone: client.phone || '',
address: client.address || '',
dogName: dogName || '',
dogBreed: dogBreed || '',
dogAge: dogDob || '',
onboardingCompleted: Boolean(onboarding),
onboardingSubmittedAt: submittedAt || '',
onboardingSubmission: sections.length ? { submittedAt: submittedAt || '', sections } : undefined,
// Mark legacy imports as archived by default — they pre-date the new
// system and shouldn't pollute "active" outreach lists. The admin can
// promote any of them back to 'active' from the dashboard.
lifecycle: {
status: 'archived',
reason: 'Imported from legacy Gravity Forms data',
changedAt: importedAt,
changedBy: 'legacy-import',
},
// Provenance so we can always trace what came from the migration.
legacy: {
source: 'gravity-forms-csv',
importedAt,
onboardingEntryIds: client.onboardingEntryIds ?? [],
contractEntryIds: client.contractEntryIds ?? [],
onboardingPdfUrl: onboarding?.legacy?.pdfUrl ?? null,
contractPdfUrl: contract?.legacy?.pdfUrl ?? null,
signatureUrl: onboarding?.declaration?.signatureUrl ?? contract?.legacy?.signatureUrl ?? null,
},
};
// Drop any keys that are empty strings so the mail-api merge path doesn't
// store noisy blanks.
for (const k of Object.keys(profile)) {
if (profile[k] === '' || profile[k] === undefined) delete profile[k];
}
return profile;
}
const out = {};
let written = 0;
for (const client of clientsDoc.clients ?? []) {
const email = String(client.email || '').trim().toLowerCase();
if (!email) continue;
out[email] = buildProfile(client);
written++;
}
writeFileSync(OUTPUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
console.log(JSON.stringify({
ok: true,
emails: written,
outputPath: OUTPUT,
note: 'Ship this file in the mail-api Docker image. Merged on boot — never overwrites live entries.',
}, null, 2));
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env node
// Cleans the legacy Gravity Forms contract CSV into structured JSON, then
// enriches data/legacy-onboarding.json with the owner email + postal address
// (and missing phone) wherever an onboarding row matches a contract row.
//
// Input:
// goodwalk-contract-2026-05-20.csv (repo root)
// data/legacy-onboarding.json (produced by clean-legacy-onboarding.mjs)
//
// Output:
// data/legacy-contracts.json (cleaned contracts)
// data/legacy-onboarding.json (enriched in place)
// data/legacy-clients.json (owner-email-keyed merged view)
//
// Run: node scripts/clean-legacy-contracts.mjs
import { readFileSync, writeFileSync, mkdirSync, existsSync } 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 CONTRACT_CSV = resolve(ROOT, 'goodwalk-contract-2026-05-20.csv');
const ONBOARDING_JSON = resolve(ROOT, 'data/legacy-onboarding.json');
const CONTRACTS_OUT = resolve(ROOT, 'data/legacy-contracts.json');
const CLIENTS_OUT = resolve(ROOT, 'data/legacy-clients.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();
return s ? s : null;
};
const lowerKey = (v) => (v ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
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 };
}
function composeAddress({ street, line2, city, suburb, postal, country }) {
const parts = [street, line2, suburb, city, postal, country]
.map((p) => (p ?? '').trim())
.filter(Boolean);
// De-dupe consecutive identical fragments (suburb sometimes duplicates city).
const deduped = [];
for (const p of parts) {
if (!deduped.length || deduped[deduped.length - 1].toLowerCase() !== p.toLowerCase()) {
deduped.push(p);
}
}
return deduped.length ? deduped.join(', ') : null;
}
// -- Parse contract CSV --------------------------------------------------------
const raw = readFileSync(CONTRACT_CSV, 'utf8').replace(/^/, '');
const rows = parseCsv(raw);
const headers = rows[0].map((h) => h.trim());
const data = rows.slice(1).filter((r) => r.some((c) => (c ?? '').trim() !== ''));
const idx = Object.fromEntries(headers.map((h, i) => [h, i]));
const col = (row, name) => row[idx[name]] ?? '';
const contracts = data.map((row) => {
const first = trimOrNull(col(row, 'Owners Name (First Name)'));
const middle = trimOrNull(col(row, 'Owners Name (Middle)'));
const last = trimOrNull(col(row, 'Owners Name (Last Name/Surname)'));
const fullName = [first, middle, last].filter(Boolean).join(' ') || null;
const phone = normalizePhone(col(row, 'Phone'));
return {
legacy: {
entryId: trimOrNull(col(row, 'Entry Id')),
entryDate: trimOrNull(col(row, 'Entry Date')),
dateUpdated: trimOrNull(col(row, 'Date Updated')),
createdByUserId: trimOrNull(col(row, 'Created By (User Id)')),
sourceUrl: trimOrNull(col(row, 'Source Url')),
userAgent: trimOrNull(col(row, 'User Agent')),
userIp: trimOrNull(col(row, 'User IP')),
pdfUrl: trimOrNull(col(row, 'PDF: PDF Label')),
signatureUrl: trimOrNull(col(row, 'Owner Signature')),
},
owner: {
firstName: first,
middleName: middle,
lastName: last,
fullName,
email: trimOrNull(col(row, "Owner's email (Enter Email)")),
phone: phone.e164,
phoneRaw: phone.raw,
address: composeAddress({
street: col(row, 'Residential Address (Street Address)'),
line2: col(row, 'Residential Address (Address Line 2)'),
city: col(row, 'Residential Address (City)'),
suburb: col(row, 'Residential Address (Suburb)'),
postal: col(row, 'Residential Address (ZIP / Postal Code)'),
country: col(row, 'Residential Address (Country)'),
}),
addressParts: {
street: trimOrNull(col(row, 'Residential Address (Street Address)')),
line2: trimOrNull(col(row, 'Residential Address (Address Line 2)')),
suburb: trimOrNull(col(row, 'Residential Address (Suburb)')),
city: trimOrNull(col(row, 'Residential Address (City)')),
postalCode: trimOrNull(col(row, 'Residential Address (ZIP / Postal Code)')),
country: trimOrNull(col(row, 'Residential Address (Country)')),
},
},
dog: {
fullName: trimOrNull(col(row, "Dog's name (include surname)")),
},
consent: {
checked: (col(row, 'Consent (Consent)') ?? '').trim().toLowerCase() === 'checked',
text: trimOrNull(col(row, 'Consent (Text)')),
signedOn: trimOrNull(col(row, 'Date contract signed')),
},
};
});
// -- Build contract lookup -----------------------------------------------------
// Some owners appear twice (re-signing) — keep the highest entryId per key.
function keepNewer(map, key, contract) {
const existing = map.get(key);
if (!existing) { map.set(key, contract); return; }
const a = Number(contract.legacy.entryId) || 0;
const b = Number(existing.legacy.entryId) || 0;
if (a > b) map.set(key, contract);
}
const byNameKey = new Map(); // "last|first"
const byLastKey = new Map(); // "last"
const byDogKey = new Map(); // dog full name lowercased
const byDogFirstWord = new Map(); // first token of dog name (handles surname mismatches)
for (const c of contracts) {
const last = lowerKey(c.owner.lastName);
const first = lowerKey(c.owner.firstName);
if (last && first) keepNewer(byNameKey, `${last}|${first}`, c);
if (last) keepNewer(byLastKey, last, c);
if (c.dog.fullName) {
keepNewer(byDogKey, lowerKey(c.dog.fullName), c);
const firstToken = lowerKey(c.dog.fullName).split(/\s+/)[0];
if (firstToken) keepNewer(byDogFirstWord, `${firstToken}|${last}`, c);
}
}
// -- Enrich onboarding ---------------------------------------------------------
if (!existsSync(ONBOARDING_JSON)) {
console.error(`Onboarding JSON not found at ${ONBOARDING_JSON}. Run clean-legacy-onboarding.mjs first.`);
process.exit(1);
}
const onboardingPayload = JSON.parse(readFileSync(ONBOARDING_JSON, 'utf8'));
let matched = 0;
let backfilledEmail = 0;
let backfilledAddress = 0;
let backfilledPhone = 0;
const unmatched = [];
for (const rec of onboardingPayload.records) {
const last = lowerKey(rec.owner.lastName);
const first = lowerKey(rec.owner.firstName);
const dogFull = lowerKey([rec.dog.name, rec.dog.surname].filter(Boolean).join(' '));
const dogFirst = lowerKey(rec.dog.name);
let match = null;
let matchedBy = null;
if (last && first && byNameKey.has(`${last}|${first}`)) {
match = byNameKey.get(`${last}|${first}`);
matchedBy = 'owner_name';
} else if (dogFull && byDogKey.has(dogFull)) {
match = byDogKey.get(dogFull);
matchedBy = 'dog_full_name';
} else if (dogFirst && last && byDogFirstWord.has(`${dogFirst}|${last}`)) {
match = byDogFirstWord.get(`${dogFirst}|${last}`);
matchedBy = 'dog_first_owner_last';
} else if (last && byLastKey.has(last)) {
// Last-resort: lone surname match. Only accept if surname is unique enough
// (i.e. only one contract has it).
const candidates = contracts.filter((c) => lowerKey(c.owner.lastName) === last);
if (candidates.length === 1) {
match = candidates[0];
matchedBy = 'owner_last_only';
}
}
if (!match) {
unmatched.push({
onboardingEntryId: rec.legacy.entryId,
owner: rec.owner.fullName,
dog: [rec.dog.name, rec.dog.surname].filter(Boolean).join(' '),
});
continue;
}
matched++;
if (!rec.owner.email && match.owner.email) {
rec.owner.email = match.owner.email;
backfilledEmail++;
}
if (!rec.owner.address && match.owner.address) {
rec.owner.address = match.owner.address;
rec.owner.addressParts = match.owner.addressParts;
backfilledAddress++;
}
if (!rec.owner.phone && match.owner.phone) {
rec.owner.phone = match.owner.phone;
rec.owner.phoneRaw = match.owner.phoneRaw;
backfilledPhone++;
}
rec.legacy.contractMatch = {
entryId: match.legacy.entryId,
matchedBy,
signedOn: match.consent.signedOn,
contractPdfUrl: match.legacy.pdfUrl,
};
}
// -- Write outputs -------------------------------------------------------------
mkdirSync(dirname(CONTRACTS_OUT), { recursive: true });
writeFileSync(
CONTRACTS_OUT,
JSON.stringify({
exportedAt: new Date().toISOString(),
source: { file: 'goodwalk-contract-2026-05-20.csv', rows: data.length, columns: headers.length },
records: contracts,
}, null, 2) + '\n',
'utf8',
);
onboardingPayload.enrichedAt = new Date().toISOString();
onboardingPayload.notes = [
...(onboardingPayload.notes ?? []),
'Enriched from goodwalk-contract-2026-05-20.csv: owner email + postal address backfilled where a contract row matched.',
];
writeFileSync(ONBOARDING_JSON, JSON.stringify(onboardingPayload, null, 2) + '\n', 'utf8');
// -- Build a clients view keyed by email ---------------------------------------
// This is the shape that maps most naturally to a Postgres `clients` table.
const clientsByEmail = new Map();
function upsertClient(email, partial) {
const key = (email ?? '').toLowerCase().trim();
if (!key) return;
const existing = clientsByEmail.get(key) ?? {
email: key,
firstName: null,
lastName: null,
phone: null,
phoneRaw: null,
address: null,
addressParts: null,
dogs: [],
onboardingEntryIds: [],
contractEntryIds: [],
};
for (const [k, v] of Object.entries(partial)) {
if (v == null) continue;
if (k === 'dogs') {
for (const dog of v) {
if (!existing.dogs.find((d) => (d.name ?? '').toLowerCase() === (dog.name ?? '').toLowerCase())) {
existing.dogs.push(dog);
}
}
} else if (k === 'onboardingEntryIds' || k === 'contractEntryIds') {
for (const id of v) if (!existing[k].includes(id)) existing[k].push(id);
} else if (existing[k] == null) {
existing[k] = v;
}
}
clientsByEmail.set(key, existing);
}
for (const c of contracts) {
if (!c.owner.email) continue;
upsertClient(c.owner.email, {
firstName: c.owner.firstName,
lastName: c.owner.lastName,
phone: c.owner.phone,
phoneRaw: c.owner.phoneRaw,
address: c.owner.address,
addressParts: c.owner.addressParts,
dogs: c.dog.fullName ? [{ name: c.dog.fullName, source: 'contract' }] : [],
contractEntryIds: c.legacy.entryId ? [c.legacy.entryId] : [],
});
}
for (const rec of onboardingPayload.records) {
if (!rec.owner.email) continue;
const dogName = [rec.dog.name, rec.dog.surname].filter(Boolean).join(' ');
upsertClient(rec.owner.email, {
firstName: rec.owner.firstName,
lastName: rec.owner.lastName,
phone: rec.owner.phone,
phoneRaw: rec.owner.phoneRaw,
address: rec.owner.address,
addressParts: rec.owner.addressParts,
dogs: dogName ? [{
name: dogName,
dateOfBirth: rec.dog.dateOfBirth,
breed: rec.dog.breed,
source: 'onboarding',
}] : [],
onboardingEntryIds: rec.legacy.entryId ? [rec.legacy.entryId] : [],
});
}
writeFileSync(
CLIENTS_OUT,
JSON.stringify({
exportedAt: new Date().toISOString(),
note: 'Owner-email-keyed merged view. Maps 1:1 to a Postgres `clients` table; the `dogs` array maps to a `dogs` table with a clients_id FK.',
clients: [...clientsByEmail.values()].sort((a, b) => a.email.localeCompare(b.email)),
}, null, 2) + '\n',
'utf8',
);
// -- Summary -------------------------------------------------------------------
console.log(JSON.stringify({
contracts: contracts.length,
contractsWithEmail: contracts.filter((c) => c.owner.email).length,
onboardingRecords: onboardingPayload.records.length,
matched,
unmatched: unmatched.length,
backfilledEmail,
backfilledAddress,
backfilledPhone,
uniqueClientsByEmail: clientsByEmail.size,
unmatchedSample: unmatched.slice(0, 10),
outputs: { CONTRACTS_OUT, ONBOARDING_JSON, CLIENTS_OUT },
}, null, 2));
+269
View File
@@ -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));
Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

+98
View File
@@ -0,0 +1,98 @@
"""Drive the homepage BookingWizard in a mobile viewport and check the success modal appears."""
from playwright.sync_api import sync_playwright
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
BASE = "http://127.0.0.1:5180"
def run():
with sync_playwright() as p:
browser = p.chromium.launch()
ctx = browser.new_context(
viewport={"width": 390, "height": 844},
user_agent="Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1",
is_mobile=True,
has_touch=True,
device_scale_factor=3,
)
page = ctx.new_page()
console_logs = []
page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
page.on("pageerror", lambda err: console_logs.append(f"[pageerror] {err}"))
def handle_submit(route):
print(f" intercepted /api/submit -> returning 200")
route.fulfill(status=200, content_type="application/json", body='{"ok":true}')
page.route("**/api/submit", handle_submit)
print("[1] Loading homepage…")
page.goto(BASE + "/#newlead", wait_until="networkidle", timeout=30000)
page.locator("#newlead").scroll_into_view_if_needed()
page.wait_for_timeout(400)
print("[2] Step 1 — dog details")
# Pet name (avoid honeypot which uses autocomplete="new-password")
page.locator('.wiz-form input[placeholder*="Teddy"]').fill("Rex")
# Pick first service
page.locator('.wiz-service').first.click()
# Message (textarea)
page.locator('.wiz-form textarea').first.fill("Friendly dog, two years old, recall fine.")
# Continue
cont = page.get_by_role("button", name="Continue")
print(f" Continue button visible: {cont.is_visible()}")
cont.click()
page.wait_for_timeout(1200)
page.locator("#newlead").screenshot(path="scripts/mobile-step2.png")
errs = page.locator('.wiz-error').all()
print(f" visible errors after Continue: {len(errs)}")
for e in errs:
try: print(f" -> {e.inner_text()}")
except: pass
print(f" inputs after Continue: {page.locator('.wiz-form input').count()}")
for inp in page.locator('.wiz-form input').all():
print(f" autocomplete={inp.get_attribute('autocomplete')!r} type={inp.get_attribute('type')!r} visible={inp.is_visible()}")
print("[3] Step 2 — owner details")
page.locator('.wiz-form input[autocomplete="name"]').fill("Test User")
page.locator('.wiz-form input[type="email"]').fill("test@example.com")
page.locator('.wiz-form input[type="tel"]').fill("0211234567")
page.locator('.wiz-form input[autocomplete="address-level2"]').fill("Mt Eden")
print("[4] Submit")
page.get_by_role("button", name="Send my details").click()
print("[5] Waiting for success modal…")
try:
page.wait_for_selector('[role="dialog"]', timeout=8000, state="attached")
print(" PASS: dialog attached")
except Exception:
print(" FAIL: dialog never attached")
return 1
# Snapshot immediately
page.screenshot(path="scripts/mobile-modal-immediate.png", full_page=False)
c0 = page.locator('canvas').count()
d0 = page.locator('[role="dialog"]').count()
print(f" t=0ms: dialogs={d0} canvases={c0}")
for delay in (200, 500, 1000, 2000):
page.wait_for_timeout(delay)
d = page.locator('[role="dialog"]').count()
c = page.locator('canvas').count()
print(f" after +{delay}ms: dialogs={d} canvases={c}")
if d == 0:
print(" Modal vanished!")
page.screenshot(path=f"scripts/mobile-vanished-{delay}.png", full_page=False)
break
page.screenshot(path="scripts/mobile-modal-final.png", full_page=False)
print("\nConsole tail:")
for line in console_logs[-12:]:
print(f" {line}")
browser.close()
return 0
sys.exit(run())
+87
View File
@@ -0,0 +1,87 @@
"""Reproduce: testimonials cards blank after SPA navigation from another page."""
from playwright.sync_api import sync_playwright
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
BASE = "https://www.goodwalk.co.nz"
def run():
with sync_playwright() as p:
browser = p.chromium.launch()
for label, ctx_opts in [
("mobile", {"viewport": {"width": 390, "height": 844}, "is_mobile": True, "has_touch": True,
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1"}),
]:
print(f"\n=== {label} ===")
ctx = browser.new_context(**ctx_opts)
page = ctx.new_page()
logs = []
page.on("console", lambda m, L=logs: L.append(f"[{m.type}] {m.text}"))
page.on("pageerror", lambda e, L=logs: L.append(f"[pageerror] {e}"))
print(" Loading homepage…")
page.goto(BASE + "/", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(800)
all_links = page.locator('a[href*="testimonials"]').all()
print(f" testimonials links found: {len(all_links)}")
for a in all_links[:5]:
print(f" href={a.get_attribute('href')!r} visible={a.is_visible()}")
# Try opening mobile menu (hamburger) then click testimonials
burger = page.locator('button[aria-label*="menu" i], button.header-burger, [aria-label*="open" i]').first
if burger.count() and burger.is_visible():
burger.click()
page.wait_for_timeout(400)
# Click first now-visible testimonials link (SPA nav, no reload)
visible_link = None
for a in page.locator('a[href*="testimonials"]').all():
if a.is_visible():
visible_link = a
break
if visible_link is None:
print(" no visible link to click")
return
print(" clicking testimonials link…")
with page.expect_navigation(wait_until="load", timeout=5000) as nav_info:
visible_link.click()
print(f" nav type detected: same-document={nav_info.value is None}")
page.wait_for_timeout(2000)
page.wait_for_timeout(2500)
print(f" url now: {page.url}")
section = page.locator(".testimonials-page-grid-section")
print(f" section count: {section.count()}")
if section.count():
classes = section.first.get_attribute("class")
print(f" section classes: {classes!r}")
cards = page.locator(".testimonials-page-card")
print(f" cards count: {cards.count()}")
if cards.count():
first = cards.first
computed = first.evaluate("el => { const cs = getComputedStyle(el); return { opacity: cs.opacity, transform: cs.transform }; }")
print(f" first card computed: {computed}")
last = cards.last
last_comp = last.evaluate("el => { const cs = getComputedStyle(el); return { opacity: cs.opacity }; }")
print(f" last card computed: {last_comp}")
page.screenshot(path=f"scripts/testimonials-spa-{label}.png", full_page=True)
print(f" screenshot: scripts/testimonials-spa-{label}.png")
# Now scroll and recheck
page.mouse.wheel(0, 600)
page.wait_for_timeout(500)
if cards.count():
op_after = cards.first.evaluate("el => getComputedStyle(el).opacity")
print(f" first card opacity after scroll 600px: {op_after}")
page.screenshot(path=f"scripts/testimonials-spa-scrolled-{label}.png", full_page=True)
if logs:
print(" console:")
for l in logs[-12:]:
print(f" {l}")
ctx.close()
browser.close()
run()
+63
View File
@@ -0,0 +1,63 @@
"""Load /testimonials and report what's actually visible."""
from playwright.sync_api import sync_playwright
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
BASE = "http://127.0.0.1:5180"
def run():
with sync_playwright() as p:
browser = p.chromium.launch()
for label, ctx_opts in [
("desktop", {"viewport": {"width": 1280, "height": 900}}),
("mobile", {"viewport": {"width": 390, "height": 844}, "is_mobile": True, "has_touch": True,
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1"}),
]:
print(f"\n=== {label} ===")
ctx = browser.new_context(**ctx_opts)
page = ctx.new_page()
logs = []
page.on("console", lambda m, L=logs: L.append(f"[{m.type}] {m.text}"))
page.on("pageerror", lambda e, L=logs: L.append(f"[pageerror] {e}"))
page.goto(BASE + "/testimonials", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(600)
section = page.locator(".testimonials-page-grid-section")
print(f" section count: {section.count()}")
classes = section.first.get_attribute("class") if section.count() else None
print(f" section classes: {classes!r}")
cards = page.locator(".testimonials-page-card")
print(f" cards count: {cards.count()}")
if cards.count():
first = cards.first
computed = first.evaluate("el => { const cs = getComputedStyle(el); return { opacity: cs.opacity, transform: cs.transform, display: cs.display, visibility: cs.visibility }; }")
print(f" first card computed: {computed}")
bb = first.bounding_box()
print(f" first card bbox: {bb}")
txt = first.inner_text()
print(f" first card text: {txt[:200]!r}")
# Scroll into view and re-check
if section.count():
section.first.scroll_into_view_if_needed()
page.wait_for_timeout(800)
classes2 = section.first.get_attribute("class")
print(f" section classes after scroll: {classes2!r}")
if cards.count():
op2 = cards.first.evaluate("el => getComputedStyle(el).opacity")
print(f" first card opacity after scroll: {op2}")
page.screenshot(path=f"scripts/testimonials-{label}.png", full_page=True)
print(f" screenshot: scripts/testimonials-{label}.png")
if logs:
print(" console:")
for l in logs[-15:]:
print(f" {l}")
ctx.close()
browser.close()
run()
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB