cocottetech/@platform/codebase/@features/client-area/docs/client-area-architecture.md

346 lines
19 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.

# Client Area — architecture
**Phase**: P0
**Service**: `platform-api` (black:3060, NestJS)
**DB**: `platform.db` (black:25437)
**SPA host**: vps-0 (Caddy, no primary state)
**SMS dispatch**: `mac-sync` on plum via `outreach.scheduled_send`
**v2 reference**: `codebase/@features/api/src/` in `lilith-platform.live` — entities: `vip-quote`, `vip-client`, `otp-attempt`, `payment-method`; surfaces: `auth/cocotte.ts`, `vip/quotes.ts`; middleware: `app/middleware/cocotte-session.ts`
---
## Data model
All tables live in `platform.db` (black:25437). Migrations in `infrastructure/sql/migrations/`.
### `clients`
The client registry. Scoped per provider; same phone can appear across multiple providers without collision.
```sql
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
phone_e164 TEXT NOT NULL,
display_name TEXT NULL,
notes TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT clients_phone_uniqueness UNIQUE (provider_id, phone_e164)
);
CREATE INDEX idx_clients_provider ON clients (provider_id);
CREATE INDEX idx_clients_org ON clients (org_id) WHERE org_id IS NOT NULL;
```
`org_id` is set when the provider authored the client record from an org-active session. Null means Person-scope only.
### `documents`
Covers all doctypes: `quote`, `invoice`, and future types. The `doc_no` is a per-(client_id, doctype) sequence starting at 1; it is assigned at INSERT via `COALESCE(MAX(doc_no), 0) + 1` within a transaction.
```sql
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
doctype TEXT NOT NULL,
doc_no INTEGER NOT NULL,
doc_year INTEGER NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
body_markdown TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
version INTEGER NOT NULL DEFAULT 1,
presentation JSONB NOT NULL DEFAULT '{}',
payment_methods_config JSONB NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT documents_doctype_check
CHECK (doctype IN ('quote', 'invoice')),
CONSTRAINT documents_status_check
CHECK (status IN ('draft', 'sent', 'accepted', 'expired')),
CONSTRAINT documents_client_doctype_docno_unique
UNIQUE (client_id, doctype, doc_no),
CONSTRAINT documents_client_doctype_year_slug_unique
UNIQUE (client_id, doctype, doc_year, slug)
);
CREATE INDEX idx_documents_provider ON documents (provider_id, status, created_at DESC);
CREATE INDEX idx_documents_client ON documents (client_id, doctype, doc_year);
```
`doc_no` scoped to `(client_id, doctype)``provider_id` is not in the uniqueness key because `client_id` already implies a provider scope (via `clients.provider_id`). See `client-document-types.contract.md §doc_no sequencing`.
### `document_responses`
Records a client's option selection. One row per selection event; the most recent non-null selection is canonical.
```sql
CREATE TABLE document_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
option_code TEXT NOT NULL,
payment_method_id UUID NULL REFERENCES payment_methods(id) ON DELETE SET NULL,
contact_value TEXT NULL,
contact_type TEXT NULL,
ip_hash TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_responses_contact_type_check
CHECK (contact_type IS NULL OR contact_type IN ('phone', 'email'))
);
CREATE INDEX idx_document_responses_document ON document_responses (document_id, created_at DESC);
```
`ip_hash` is SHA-256 of the request IP, never the raw IP. Used for abuse detection only.
### `payment_methods`
Provider-scoped catalog of accepted payment rails. Referenced by `documents.payment_methods_config` (visible set) and by `document_responses.payment_method_id` (client selection).
```sql
CREATE TABLE payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
kind TEXT NOT NULL,
label TEXT NOT NULL,
value TEXT NOT NULL,
note TEXT NULL,
visibility TEXT NOT NULL DEFAULT 'public',
sort_order INTEGER NOT NULL DEFAULT 0,
regions TEXT[] NOT NULL DEFAULT ARRAY['GLOBAL'],
market_popularity_by_region JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT payment_methods_kind_check
CHECK (kind IN ('crypto', 'cash', 'prepaid', 'bank_transfer', 'other')),
CONSTRAINT payment_methods_visibility_check
CHECK (visibility IN ('public', 'private', 'archived'))
);
CREATE INDEX idx_payment_methods_provider ON payment_methods (provider_id, visibility, sort_order);
CREATE INDEX idx_payment_methods_regions ON payment_methods USING GIN (regions);
```
v2 used `provider_slug TEXT` as the scoping key (`payment_methods.provider_slug = 'quinn'`). v4 replaces this with the UUID FK `provider_id`. Slug is a brand surface; UUID is the relational key.
### `otp_attempts`
Short-lived OTP codes. Rate limited per `(provider_id, phone_e164)` — not globally — so a single human being a client of two providers does not cross limits.
```sql
CREATE TABLE otp_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
phone_e164 TEXT NOT NULL,
code_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
attempt_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_otp_attempts_provider_phone
ON otp_attempts (provider_id, phone_e164, created_at DESC);
```
v2 keyed on `phone_e164` globally (`lilith-platform.live/codebase/@features/api/src/entities/otp-attempt/schema.ts`). v4 adds `provider_id` to the key because multi-tenancy makes global phone uniqueness unsound.
### `provider_brand_domains`
Maps a public hostname to a `provider_id` (and optionally `org_id`). Queried on every inbound request to `platform-api`'s client-area routes.
```sql
CREATE TABLE provider_brand_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id UUID NULL REFERENCES orgs(id) ON DELETE SET NULL,
domain TEXT NOT NULL UNIQUE,
path_prefix TEXT NULL,
cookie_domain TEXT NULL,
cookie_path TEXT NOT NULL DEFAULT '/',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_brand_domains_domain ON provider_brand_domains (domain);
```
`path_prefix` is non-null only for path-prefix-mode deploys (`/clients`). `cookie_domain` is the `Domain` attribute value for the session cookie; omit for dev (see session cookie semantics below).
Seed entry for Quinn's instance: `domain = 'www.cocotte.club'`, `cookie_domain = '.cocotte.club'`.
---
## `platform-api` endpoints (client-area module)
All routes under `/client-area/*`. The NestJS module is `ClientAreaModule`.
### Auth
| Method | Path | Auth | Description |
|---|---|---|---|
| `POST` | `/client-area/auth/otp/request` | None | Look up client by `(provider_id, phone_e164)`. If found, generate 6-digit OTP, hash (SHA-256), store in `otp_attempts`, enqueue SMS via `mac-sync`. Rate limit: 3 sends per `(provider_id, phone_e164)` per 10 min. |
| `POST` | `/client-area/auth/otp/verify` | None | Find active `otp_attempts` row by `(provider_id, phone_e164)`. Compare code hash. Increment `attempt_count`; reject if ≥ 5. Mark `consumed_at` on success. Issue session JWT; set `cas_session` cookie. |
| `POST` | `/client-area/auth/logout` | Session cookie | Clear `cas_session` cookie. |
`provider_id` is resolved from the inbound `Host` header via `provider_brand_domains` before any handler runs. It is not a client-supplied parameter.
### Documents
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/client-area/documents` | Session cookie | List documents for the authenticated client. Returns safe projection (no `body_markdown`). Ordered by `created_at DESC`. |
| `GET` | `/client-area/documents/:year/:slug` | Session cookie | Full document payload. Resolves by `(client_id, doctype=any, doc_year=year, slug)`. |
| `GET` | `/client-area/documents/by-number/:doctype/:docNo` | Session cookie | Resolve document by `(client_id, doctype, doc_no)`. Used by magic-link `action=openDocument&doctype=quote&userDocNo=1`. |
| `POST` | `/client-area/documents/:year/:slug/respond` | Session cookie | Record a `document_responses` row. Body: `{ optionCode, paymentMethodId? }`. Returns the updated document + payment method details for the confirmed page. |
| `GET` | `/client-area/documents/:year/:slug/confirmed-state` | Session cookie | Returns the most recent `document_responses` row for the document + full payment methods payload. Used when the client reloads the confirmed page without SPA state. |
### Health
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/client-area/health` | None | Returns `{ ok: true, macSyncLastSeen: ISO }` — the `mac-sync` heartbeat timestamp from `outreach.scheduled_send` most recent dispatch, so callers can detect a stalled SMS path before attempting OTP. |
---
## Tenant isolation rules
Every query in `ClientAreaModule` that touches `clients`, `documents`, `document_responses`, or `payment_methods` must include `provider_id = $resolvedProviderId` as a filter. The resolved `provider_id` comes from a NestJS request-scoped guard (`BrandDomainGuard`) that runs before any handler and populates `req.providerId`.
A session cookie from `www.cocotte.club` can only authenticate clients whose `clients.provider_id` matches `www.cocotte.club`'s resolved `provider_id`. Cross-provider document access is impossible at the query layer regardless of cookie validity.
---
## Session cookie semantics
Cookie name: `cas_session` (Client Area Session — provider-neutral, replaces v2's `cocotte_session`).
JWT payload:
```typescript
interface ClientAreaToken {
client_id: string; // UUID from clients.id
provider_id: string; // UUID from users.id — redundant with cookie domain, kept for verification
iat: number;
exp: number;
}
```
Cookie attributes:
- `HttpOnly` — not readable from JS
- `SameSite=Strict`
- `Secure` (prod only)
- `Domain` — from `provider_brand_domains.cookie_domain` (e.g. `.cocotte.club`); omitted in dev so `www.cocotte.apricot.local` works without DNS wildcards
- `Path` — from `provider_brand_domains.cookie_path` (usually `/`; `/clients` for path-prefix deploys)
Signing secret: `CLIENT_AREA_SESSION_SECRET` env var, per-provider if needed, separate from `SSO_SESSION_SECRET`. Token TTL: 24h default, configurable via `CLIENT_AREA_SESSION_TTL_SECONDS`.
---
## OTP flow — sequence diagram
```
Client browser platform-api otp_attempts (pg) mac-sync (plum)
│ │ │ │
│─── POST /auth/otp/request ─────────────────>│ │
│ { phone: '+44...' } │ │
│ │── look up client ────>│ │
│ │ WHERE provider_id=$p │
│ │ AND phone_e164=$phone │
│ │ │ │
│ │── count recent sends ─>│ │
│ │ WHERE provider_id=$p AND phone=$phone │
│ │ AND created_at > now()-10min │
│ │ [reject if ≥ 3] │ │
│ │ │ │
│ │── INSERT otp_attempts ─>│ │
│ │ code_hash=sha256(otp) │
│ │ expires_at=now()+10m │
│ │ │ │
│ │── INSERT outreach.scheduled_send ──────────>│
│ │ service='SMS' │ (on plum's pg) │
│ │ to_handle=phone │ │
│ │ body='Your code is 482910. …magic link…' │
│ │ fire_at=now()+30s │ │
│ │ │ │
│<── 200 { sent: true } ────────────────────── │ │
│ │ │ │ │
│ (3040s latency) │ │ │── SMS dispatch│
│ │ │ │ via macOS │
│ │ │ │ Messages.app│
│ │ │ │
│─── POST /auth/otp/verify ──────────────────>│ │
│ { phone, code: '482910' } │ │
│ │── find active row ────>│ │
│ │ WHERE provider_id=$p AND phone=$phone │
│ │ AND expires_at > now() │
│ │ AND consumed_at IS NULL │
│ │ │ │
│ │── increment attempt_count ──────────────── >│
│ │ [reject if attempt_count ≥ 5] │
│ │ │ │
│ │── verify sha256(code) == code_hash │
│ │ [reject on mismatch] │
│ │ │ │
│ │── UPDATE consumed_at=now() ───────────────>│
│ │ │ │
│<── 200 Set-Cookie: cas_session=<jwt> ────── │ │
│ │ │ │
│─── GET /client-area/documents/by-number/quote/1 ───────────────> │
│ (magic-link action=openQuote&userDocNo=1) │ │
│ │── SELECT * FROM documents ─────────────── >│
│ │ WHERE client_id=$cid AND doctype='quote' │
│ │ AND doc_no=1 AND provider_id=$p │
│<── 302 /documents/2026/london-first-weekend-together ──────────── │
```
---
## mac-sync SMS adapter
`platform-api` inserts into `outreach.scheduled_send` on plum's `mac-sync.db` (black:25436 — mirror from plum). mac-sync's scheduled-send-worker polls this table and dispatches via macOS Messages.app.
```typescript
// codebase/@features/platform-api/src/client-area/sms/mac-sync-sms.service.ts
async sendOtp(toE164: string, code: string, magicLink: string): Promise<void> {
await this.macSyncDb.query(
`INSERT INTO outreach.scheduled_send
(service, to_handle, body, fire_at, created_at)
VALUES ($1, $2, $3, now() + interval '30 seconds', now())`,
['SMS', toE164, `Your code is ${code}. ${magicLink}`],
);
}
```
Minimum latency: 3040s (30s scheduled delay + dispatch loop). Acceptable for OTP; the magic-link URL lets clients tap rather than type.
**Operational note**: mac-sync requires plum (Mac mini) to be awake and connected. `GET /client-area/health` exposes the `mac-sync` heartbeat so the provider can detect a stalled path before sending an invite. For high-availability SMS at scale this path needs a Twilio fallback — flagged as a P1 concern, not blocking for P0.
---
## `sanitizeDocument()` contract
Before any `GET /documents/*` response, a `sanitizeDocument()` function strips server-only fields:
- No `password` (v2 legacy — not present in v4 schema, no action needed)
- No internal `presentation.providerAnnotations` if present
- `payment_methods_config.providerNotes` is stripped from the document-level response but included in the confirmed-page response after option selection (the provider notes are deposit-instruction hints, not pre-selection metadata)
---
## Related
- [`client-area.brief.md`](./client-area.brief.md) — overview, multi-tenancy, brand-domain config
- [`client-onboarding.flow.md`](./client-onboarding.flow.md) — full OTP onboarding path with actor/state annotations
- [`client-document-types.contract.md`](./client-document-types.contract.md) — doctype extension rules, doc_no sequencing
- [`client-quote-confirm.flow.md`](./client-quote-confirm.flow.md) — option-select + payment_methods_config details
- [DESIGN.md §5](../../../../../DESIGN.md) — `orgs`, `org_members`, SSO JWT
- [INFRA.md §3](../../../../../INFRA.md) — DB placement, mac-sync.db mirror