9.5 KiB
Onboarding
How a Goodwalk client comes into the system, how their record is shaped, and how that record evolves over time.
Flow at a glance
- Enquiry / email claim — the user signs in to
clients.goodwalk.co.nzwith a one-time code. Aclient_profilesentry is created keyed by the lower-cased email. - Onboarding form (
src/lib/components/OnboardingPage.svelte) — a 5-step form: Owner Details → Dog Details → Health → Behaviour → Sign. Every question must be answered; there is no skip / mark-complete shortcut. - Submit — the form posts to
POST /api/onboarding-submit. The backend validates, stores asubmissionSnapshoton the profile, sends the owner an email + PDF, and flipsonboardingCompleted: true. - Admin dashboard (
src/lib/components/admin-dashboard/AdminDashboard.svelte) surfaces pending and completed clients, and now lets the owner change a client's lifecycle status.
Where data lives today
| Thing | Storage | Source of truth |
|---|---|---|
| Client profiles | mail-api/data/client_profiles.json (mirrored into admin_db KV) |
_client_profiles dict in main.py |
| Onboarding drafts (in-progress, per email) | mail-api/data/drafts.json |
_drafts dict |
| Submitted onboarding snapshot | Inside the client profile as onboardingSubmission / submissionSnapshot |
same |
| Activity feed (admin actions) | SQLite via admin_db.record_event |
same |
| Legacy historical data (Gravity Forms exports) | data/legacy-onboarding.json, data/legacy-contracts.json, data/legacy-clients.json |
regenerated from the CSVs in repo root |
The plan is to move the JSON file to Postgres later; the JSON shape is already relational-friendly (see Postgres target below).
Client profile shape
Keyed by lower-cased email. Fields are merged over time as the client moves through the funnel.
{
"fullName": "Shelley McCabe",
"phone": "+6421328907",
"address": "...",
"dogName": "Maisie",
"dogBreed": "...",
"dogAge": "2015-04-28",
"services": ["Tiny Gang Pack Walks"],
"onboardingCompleted": true,
"onboardingSubmittedAt": "2026-03-14T16:14:20",
"onboardingSubmission": { /* full 5-step snapshot */ },
"welcomePackSentAt": "...",
"welcomePackOffer": { "serviceType": "...", "priceDetails": "...", "startDate": "..." },
"birthdayAutoSend": false,
"birthdayEmailLastSentAt": "...",
"lastEnquiryAt": "...",
"lifecycle": {
"status": "active", // active | paused | cancelled | archived
"reason": "",
"changedAt": "2026-05-20T10:11:00",
"changedBy": "info@goodwalk.co.nz"
},
"lifecycleHistory": [
{ "status": "active", "reason": "", "changedAt": "...", "changedBy": "..." }
]
}
Lifecycle status
Soft-delete only. No client is ever removed, so we keep the full history for retention / newsletter work.
| Status | Meaning | Outreach included? |
|---|---|---|
active |
Currently walking with us. | Yes — pending + completed + birthdays |
paused |
Temporarily not walking (holiday, recovery, etc.). | Yes — still on outreach lists |
cancelled |
No longer a client. Kept on file. | No — excluded from outreach |
archived |
Long-term inactive. Hidden from default views. | No — excluded from outreach |
Defaults: every client starts as active. A missing lifecycle block is
treated as active by the API and the dashboard.
Filters that respect lifecycle
_client_is_reachable(profile) in mail-api/main.py is the single gate.
cancelled and archived are filtered out of:
GET /owner/pending-onboardingGET /owner/completed-onboardingGET /owner/birthdays
GET /owner/all-clients returns everyone, regardless of status, so the
admin can always find a former client. The status pill on each row makes the
state obvious.
Changing a client's status
Admin dashboard → Clients tab → row action "Change status". Opens a modal with the four status options and an optional 500-char reason. Saving calls:
POST /api/owner/client-status
Authorization: Bearer <admin session token>
Content-Type: application/json
{
"email": "owner@example.com",
"status": "paused",
"reason": "Moving house, back in June"
}
The backend writes the new lifecycle block, appends to lifecycleHistory
(capped at the last 50 entries), and records an
owner_client_status_changed event in the activity feed.
Legacy data migration
Two CSV exports from the old Gravity Forms install:
dog-enrolment-form-2026-05-20.csv— 72 onboarding submissions (2022–2026).goodwalk-contract-2026-05-20.csv— 39 contract signings (2022-06 → 2024-02).
Two cleansing scripts produce relational-friendly JSON:
node scripts/clean-legacy-onboarding.mjs # → data/legacy-onboarding.json
node scripts/clean-legacy-contracts.mjs # → data/legacy-contracts.json
# also enriches legacy-onboarding.json
# and writes data/legacy-clients.json
Importing legacy clients into production
The deploy pipeline ships these 36 clients into the live system on every deploy. The flow:
deploy.ps1runsscripts/build-legacy-seed.mjs, which folds the three legacy JSON files into one email-keyed dict shaped like the live_client_profilesentries. Output:mail-api/legacy-clients-seed.json.- The mail-api
DockerfileCOPYs that file to/app/legacy-clients-seed.jsonso it lives outside the persistentmail_api_datavolume — every deploy carries a fresh copy. - On boot,
_merge_legacy_seed_if_present()adds every email that is not already in_client_profiles, persists the result to JSON + admin_kv, and never overwrites a live entry. Re-running is idempotent.
Each imported client is created with lifecycle: { status: 'archived' } so
they don't pollute outreach lists. The admin can promote any of them back to
active from the dashboard. Provenance (entry IDs, signature URL, PDF URLs)
is preserved on the legacy field of every profile.
To skip the rebuild on a hotfix deploy use deploy.ps1 -SkipLegacySeed; the
previous seed file is reused as-is. To regenerate locally:
node scripts/build-legacy-seed.mjs
The contracts script backfills owner email + postal address onto onboarding records where a contract row matches (by owner name, then dog name, then a unique surname). At present: 38 of 72 onboarding rows now have an email attached; the rest were submitted after the contract form was retired in 2024-02 and will need an email-claim flow before they can sign in.
Known gaps in the legacy data
- 34 onboarding rows have no email (mostly post-2024-02).
- Dog breed was never collected by the old forms —
nulleverywhere. - Owner address was only collected on the contract form, not on onboarding directly.
- One vet phone number (
6307168) couldn't be normalised to E.164 — kept asphoneRawonly.
Postgres target
The JSON shapes already map cleanly onto a relational schema. Suggested tables once we migrate off the JSON file:
clients (
email TEXT PRIMARY KEY,
first_name, last_name, phone, phone_raw,
address, address_street, address_line2, address_suburb,
address_city, address_postal_code, address_country,
-- lifecycle
lifecycle_status TEXT NOT NULL DEFAULT 'active', -- enum: active|paused|cancelled|archived
lifecycle_reason TEXT,
lifecycle_changed_at TIMESTAMPTZ,
lifecycle_changed_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
client_status_history (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
status TEXT NOT NULL,
reason TEXT,
changed_at TIMESTAMPTZ NOT NULL,
changed_by TEXT NOT NULL
);
dogs (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
name TEXT, surname TEXT, date_of_birth DATE, breed TEXT
);
onboarding_submissions (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
dog_id INT REFERENCES dogs(id),
legacy_entry_id TEXT,
legacy_pdf_url TEXT,
signature_url TEXT,
vet_name TEXT, vet_address TEXT, vet_phone TEXT, vet_phone_raw TEXT,
emergency_contact_name TEXT, emergency_contact_phone TEXT,
health JSONB, -- vaccinated / allergies / diet / medication
behaviour JSONB, -- recall / reactivity / car / commands
misc JSONB, -- additional notes / referrals / socials
signed_on DATE,
submitted_at TIMESTAMPTZ
);
contracts (
id SERIAL PRIMARY KEY,
client_email TEXT REFERENCES clients(email),
legacy_entry_id TEXT,
signature_url TEXT,
pdf_url TEXT,
consent_text TEXT,
signed_on DATE
);
health / behaviour / misc stay as JSONB rather than exploding every
nullable yes/no into a column — it matches the existing
submissionSnapshot: dict[str, Any] shape in
mail-api/mail_api/models.py and makes the form easier to evolve.
Things that intentionally don't exist yet
- Hard delete of a client. Use
archivedinstead. Hard delete would also break the activity feed and the legacy import provenance. - A "former client" notes log. If we need anything richer than a 500-char
reason, the next step is to add a
client_notestable keyed by email — but hold off until the JSON → Postgres migration is done. - Re-engagement / win-back emails. The data is there (lifecycle history +
email), so this is unblocked whenever marketing wants to send a newsletter
to past clients.
cancelledandarchivedrows are still in/owner/all-clients.