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

9.9 KiB
Raw Blame History

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


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