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`.
|