206 lines
9.9 KiB
Markdown
206 lines
9.9 KiB
Markdown
|
|
# 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 — 30–40s 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
|