253 lines
9.5 KiB
Markdown
253 lines
9.5 KiB
Markdown
|
|
# 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 (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`.
|