# 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 1. **Enquiry / email claim** — the user signs in to `clients.goodwalk.co.nz` with a one-time code. A `client_profiles` entry is created keyed by the lower-cased email. 2. **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. 3. **Submit** — the form posts to `POST /api/onboarding-submit`. The backend validates, stores a `submissionSnapshot` on the profile, sends the owner an email + PDF, and flips `onboardingCompleted: true`. 4. **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. ```jsonc { "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-onboarding` - `GET /owner/completed-onboarding` - `GET /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 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: 1. `deploy.ps1` runs `scripts/build-legacy-seed.mjs`, which folds the three legacy JSON files into one email-keyed dict shaped like the live `_client_profiles` entries. Output: `mail-api/legacy-clients-seed.json`. 2. The mail-api `Dockerfile` `COPY`s that file to `/app/legacy-clients-seed.json` so it lives **outside** the persistent `mail_api_data` volume — every deploy carries a fresh copy. 3. 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 — `null` everywhere. - 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 as `phoneRaw` only. ## Postgres target The JSON shapes already map cleanly onto a relational schema. Suggested tables once we migrate off the JSON file: ```sql 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 `archived` instead. 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_notes` table 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. `cancelled` and `archived` rows are still in `/owner/all-clients`.