Files
gw-svelte/docs/onboarding.md
T
2026-05-26 08:30:08 +12:00

253 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.