346 lines
19 KiB
Markdown
346 lines
19 KiB
Markdown
# 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 } ────────────────────── │ │
|
||
│ │ │ │ │
|
||
│ (30–40s 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: 30–40s (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
|