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
+252
View File
@@ -0,0 +1,252 @@
# 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 <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 (20222026).
- `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`.