cocottetech/@platform/codebase/@features/client-area/docs/client-onboarding.flow.md
2026-05-18 19:47:03 -07:00

205 lines
9.9 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.

# Flow — client onboarding (invite SMS → first document)
## Goal
A prospect receives a text from the provider, taps the link, authenticates with one OTP, and lands on their first document — without creating an account, setting a password, or providing any information beyond a phone number. The provider never shares a static URL that leaks document contents; access is phone-gated on every visit.
## Constraints
- Client has no prior account. The phone number is the identity key.
- OTP is delivered via `mac-sync` on plum — 3040s minimum latency. The magic link in the SMS body lets the client tap to autofill rather than type the code.
- Unknown phones receive a generic non-revealing error — no enumeration of whether the phone exists in the provider's client registry.
- Session cookie is `HttpOnly`; the SPA cannot read it. All auth state lives server-side.
- The magic-link URL encodes `action=openQuote&userDocNo=1` (or similar) so the SPA can navigate directly to the right document post-auth.
- `platform-api` resolves `provider_id` from the inbound `Host` header before any handler runs (`BrandDomainGuard`). The client never sends a provider identifier explicitly.
---
## Step-by-step flow
### Step 1 — Provider creates the client record
**Actor**: provider (via `provider-portal` or direct DB seed)
**Action**: `INSERT` into `clients` with `(provider_id, phone_e164, display_name)`.
**State change**: `clients` row exists; `doc_no` sequence for this client starts at 0.
**Logged**: provider action recorded in `agent_actions` (audit spine).
---
### Step 2 — Provider authors the document
**Actor**: provider (via `provider-portal` document editor)
**Action**: Create a `documents` row with `doctype='quote'`, `status='draft'`. The `doc_no` is assigned at INSERT: `COALESCE(MAX(doc_no) FILTER (WHERE doctype='quote'), 0) + 1` within a transaction scoped to `(client_id, doctype='quote')`. `doc_year` is extracted from `created_at`. The slug is provider-supplied (descriptive, URL-safe, year-scoped).
**State change**: `documents` row with `status='draft'`. Not yet visible to the client.
**Logged**: creation event in `agent_actions`.
---
### Step 3 — Provider sends the invite SMS
**Actor**: provider
**Action**: Sends a message (via iMessage, SMS, or email — outside the Client Area) containing the magic-link URL. Canonical format:
```
https://www.cocotte.club/login?redirect=documents&action=openDocument&doctype=quote&userDocNo=1
```
The provider also updates `documents.status` to `'sent'`.
**State change**: document `status='sent'`. Provider-side only; the client has not authenticated yet.
**Open question**: should the `status='sent'` transition be automated when the provider copies the magic-link URL, or should it remain a manual step in `provider-portal`? Manual is safer (provider may want to draft and re-draft before sending) but easy to forget.
---
### Step 4 — Client opens /login
**Actor**: client browser
**Action**: SPA loads. If `?oneTimePw=X&redirect=Y&...` is present in the query string, the SPA extracts the OTP and pre-fills the phone field (if `?phone=Z` is also present), then auto-submits step 6.
If arriving at `/login` without query params (fresh visit, expired session), the SPA shows the phone-number entry form.
**State change**: none (SPA load only).
---
### Step 5 — Client enters phone number and requests OTP
**Actor**: client
**Action**: Submits phone number. SPA calls `POST /client-area/auth/otp/request` with `{ phone: '+44...' }`.
**`platform-api` processing**:
1. `BrandDomainGuard` resolves `provider_id` from `Host` header via `provider_brand_domains`.
2. Look up `clients` WHERE `provider_id=$p AND phone_e164=$phone`.
3. If no row found → return `{ sent: true }` with no SMS sent. Generic response; no enumeration.
4. Count `otp_attempts` WHERE `provider_id=$p AND phone_e164=$phone AND created_at > now()-interval '10 minutes'`. Reject if ≥ 3 with HTTP 429.
5. Generate 6-digit OTP. Store `sha256(otp)` in `otp_attempts` with `expires_at = now()+10min`, `attempt_count = 0`.
6. Call `mac-sync` SMS adapter: INSERT into `outreach.scheduled_send` with `fire_at = now()+30s`.
7. Return `{ sent: true }`.
**State change**: `otp_attempts` row created. `outreach.scheduled_send` row created on plum's DB.
**Logged**: OTP request event (phone hash, not plaintext; provider_id).
---
### Step 6 — Client receives SMS and taps magic link (or enters code manually)
**Actor**: mac-sync (plum) → client
**mac-sync action**: scheduled-send-worker on plum polls `outreach.scheduled_send`, dispatches SMS via macOS Messages.app. SMS body:
```
Your code is 482910. Tap to open: https://www.cocotte.club/login?oneTimePw=482910&phone=%2B44...&redirect=documents&action=openDocument&doctype=quote&userDocNo=1
```
**Client action**: taps link → browser opens `/login` with query params → SPA auto-extracts `oneTimePw` and `phone`, auto-submits OTP verify (step 7). Alternatively, client types the 6-digit code into the OTP entry form.
**State change**: none yet (tap opens browser).
---
### Step 7 — OTP verification
**Actor**: SPA → `platform-api`
**Action**: `POST /client-area/auth/otp/verify` with `{ phone: '+44...', code: '482910' }`.
**`platform-api` processing**:
1. Resolve `provider_id` from `Host`.
2. Find active `otp_attempts` row WHERE `provider_id=$p AND phone_e164=$phone AND expires_at > now() AND consumed_at IS NULL`. If none → `{ error: 'invalid_or_expired' }`.
3. Increment `attempt_count`. If `attempt_count >= 5``{ error: 'too_many_attempts' }`. Hard stop; the row remains unconsumed so subsequent guesses also fail.
4. Compare `sha256(code) == code_hash`. If mismatch → return error without consuming the row.
5. On match: `UPDATE otp_attempts SET consumed_at = now()`.
6. Resolve `clients` row for `(provider_id, phone_e164)`.
7. Issue `cas_session` JWT (`client_id`, `provider_id`, 24h TTL). `Set-Cookie: cas_session=<jwt>; HttpOnly; SameSite=Strict; Domain=.cocotte.club; Path=/`.
8. Return `{ ok: true }`.
**State change**: `otp_attempts.consumed_at` set. `cas_session` cookie issued.
**Logged**: auth success event (client_id, provider_id, source: 'otp').
---
### Step 8 — SPA resolves post-auth action and navigates
**Actor**: SPA
**Action**: After successful verify, SPA reads the `action` + `userDocNo` (or `redirect`) from the original query params:
- `action=openDocument&doctype=quote&userDocNo=1` → call `GET /client-area/documents/by-number/quote/1` → redirect to `/documents/2026/london-first-weekend-together`.
- `redirect=documents` (no action) → navigate to `/documents` index.
- No query params → navigate to `/documents` index.
**State change**: none. Client is now authenticated and viewing their first document.
---
### Step 9 — Document loads
**Actor**: SPA → `platform-api`
**Action**: `GET /client-area/documents/:year/:slug` with session cookie.
**`platform-api` processing**:
1. Validate `cas_session` JWT. Reject if expired or signature invalid.
2. Resolve `client_id` from JWT. Verify `client_id.provider_id` matches `BrandDomainGuard`'s resolved `provider_id`.
3. `SELECT * FROM documents WHERE client_id=$cid AND doctype IS NOT NULL AND doc_year=$year AND slug=$slug AND provider_id=$pid`.
4. Run `sanitizeDocument()` to strip server-only fields.
5. Return document payload + `payment_methods` catalog (filtered by `payment_methods_config.visibleMethodIds` if set).
**State change**: none (read-only).
---
## Edge cases
### Unknown phone
`POST /auth/otp/request` returns `{ sent: true }` regardless. No SMS is sent. The client sees "We sent a code to your number." If they type the 6-digit code, verify will return `{ error: 'invalid_or_expired' }` (no active row). No enumeration of whether the phone exists.
### Expired OTP (10-minute window elapsed)
Verify returns `{ error: 'invalid_or_expired' }`. Client must request a new OTP. If within 10 minutes they request again, the send-count check allows up to 3 total sends; after that, 429 until the 10-minute window slides.
### Max verify attempts (5 attempts)
The `otp_attempts` row is not consumed; `attempt_count` is at 5. Every subsequent verify call hits the `attempt_count >= 5` guard and returns `{ error: 'too_many_attempts' }`. The client must request a new OTP (which starts a fresh row with a new code, reset `attempt_count`). The old expired row is ignored on future lookups (`AND consumed_at IS NULL`).
### mac-sync stalled (plum asleep or disconnected)
`GET /client-area/health` returns `{ ok: false, macSyncLastSeen: '<stale timestamp>' }`. The SPA can check this before showing the phone-entry form and surface a degraded-mode banner: "SMS delivery is delayed — codes may take longer to arrive." The OTP request still proceeds; the SMS will dispatch when mac-sync recovers.
### No documents yet
`GET /client-area/documents` returns `[]`. The SPA shows a "Nothing here yet" state. The client is authenticated but has nothing to view. This is expected if the provider has not yet authored any documents for this client.
### Multi-document index
`GET /client-area/documents` returns all documents for the client across all doctypes, sorted by `created_at DESC`. Each row includes `doctype`, `doc_no`, `doc_year`, `slug`, `title`, `status`. The SPA renders a simple list; clients can tap any document to view it.
---
## Out of scope
- Password-based login (not in scope; OTP is the only auth path)
- Social / OAuth login
- Client self-registration (providers create client records; clients cannot sign themselves up)
- Document content editing by the client
---
## Related
- [`client-area-architecture.md`](./client-area-architecture.md) — full OTP sequence diagram, endpoint specs, DB schema
- [`client-area.brief.md`](./client-area.brief.md) — feature overview, multi-tenancy, brand-domain config
- [`client-document-types.contract.md`](./client-document-types.contract.md) — doctype/doc_no contract
- [`client-quote-confirm.flow.md`](./client-quote-confirm.flow.md) — what happens after the client selects an option