# Client Portal — BAMS Legal

> Gives clients secure, read-only access to their own matters, invoices, and
> shared documents. Highest-scrutiny security surface in the product — every
> route is scoped to the logged-in client and nothing else.

---

## Table of Contents

1. [Schema](#schema)
2. [Admin Routes (managing portal access)](#admin-routes-managing-portal-access)
3. [Portal Auth Routes](#portal-auth-routes)
4. [Portal Data Routes](#portal-data-routes)
5. [Request & Response Examples](#request--response-examples)
6. [Access Lifecycle](#access-lifecycle)
7. [Validation Rules](#validation-rules)
8. [Notes & Guidelines](#notes--guidelines)

---

## Schema

### `portal_users`

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `firm_id` | FK | Yes | — | |
| `client_id` | FK | Yes | — | The client this login belongs to |
| `name` | String | Yes | — | |
| `email` | String | Yes | — | Unique login |
| `password` | String (hashed) | Yes | — | **Never exposed** |
| `status` | Enum | Yes | `active` | `active`, `disabled` |
| `last_login_at` | Timestamp | No | `null` | |

### `shared_documents`

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `document_id` | FK | Yes | — | |
| `client_id` | FK | Yes | — | |
| `shared_by` | FK (User) | Yes | — | Staff member who shared it |
| `shared_at` | Timestamp | Yes | — | |

### `portal_requests`

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `client_id` | FK | Yes | — | |
| `matter_id` | FK | No | `null` | |
| `type` | Enum | Yes | — | `appointment`, `inquiry` |
| `message` | Text | Yes | — | |
| `status` | Enum | No | `open` | `open`, `actioned` |

---

## Admin Routes (managing portal access)

**Base:** `/admin/clients/:clientId/portal-access` · **Auth:** session, permission `portal.manageAccess`

| Method | Endpoint | Description |
|---|---|---|
| `POST` | `/admin/clients/:id/portal-access` | Grant access — sends a signed, expiring invite link |
| `PATCH` | `/admin/clients/:id/portal-access/:portalUserId/status` | Disable/re-enable a portal login |
| `GET` | `/admin/clients/:id/portal-access` | List the client's portal users |
| `POST` | `/admin/documents/:id/share` | Share a document with a client (blocked for non-`standard` confidentiality) |
| `DELETE` | `/admin/documents/:id/share/:clientId` | Revoke sharing |

There is **no self-registration route** — a `PortalUser` only ever comes into
existence via this staff-initiated grant.

---

## Portal Auth Routes

**Base:** `/portal` · **Auth:** none required (these ARE the auth routes) · Guard: `portal` (entirely separate from the staff `web` guard)

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/portal/invite/:token` | Validate an invite link, show the set-password form |
| `POST` | `/portal/invite/:token` | Set password, activate the account |
| `POST` | `/portal/login` | Log in |
| `POST` | `/portal/logout` | Log out |
| `POST` | `/portal/forgot-password` | Request a reset link |
| `POST` | `/portal/reset-password` | Reset password via token |

---

## Portal Data Routes

**Base:** `/portal` · **Auth:** `auth:portal` guard, every query scoped server-side to `auth('portal')->user()->client_id`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/portal/dashboard` | Matter count, next hearing dates, outstanding balance, retainer balance |
| `GET` | `/portal/matters` | List own matters (sanitized) |
| `GET` | `/portal/matters/:ulid` | Own matter detail — stage, hearing dates + outcomes only, no internal remarks |
| `GET` | `/portal/invoices` | List own invoices |
| `GET` | `/portal/invoices/:ulid/pdf` | Download own invoice PDF |
| `GET` | `/portal/receipts` | List own receipts |
| `GET` | `/portal/documents` | List documents explicitly shared with this client |
| `GET` | `/portal/documents/:ulid/download` | Authorized, streamed, logged download |
| `POST` | `/portal/requests` | Submit an appointment/inquiry request |

> There are **no write routes** to matters, invoices, or documents. The portal
> is read-only except for submitting a request.

---

## Request & Response Examples

### Login — `POST /portal/login`

**Request Body:**
```json
{ "email": "director@zenith.ng", "password": "REDACTED" }
```

**Response `200 OK`:**
```json
{ "success": true, "message": "Logged in." }
```

**Response `401 Unauthorized` (neutral — no enumeration of which field was wrong):**
```json
{ "success": false, "error": { "code": "INVALID_CREDENTIALS", "message": "Incorrect email or password." } }
```

**Response `403 Forbidden` (disabled account — still neutral wording):**
```json
{ "success": false, "error": { "code": "INVALID_CREDENTIALS", "message": "Incorrect email or password." } }
```

> A disabled account returns the **same message** as a wrong password. This
> prevents an outsider from learning whether a given email has portal access
> at all.

---

### Attempt to access another client's matter — `GET /portal/matters/:ulid`

**Response `404 Not Found` (not `403` — never confirm the record exists):**
```json
{ "success": false, "error": { "code": "NOT_FOUND", "message": "Matter not found." } }
```

---

### Submit a request — `POST /portal/requests`

**Request Body:**
```json
{ "type": "appointment", "matter_ulid": "01J...", "message": "Could we schedule a call to discuss the next hearing date?" }
```

**Response `200 OK`:**
```json
{ "success": true, "message": "Your request has been sent to your legal team." }
```

---

### Share a document with a client (staff-side) — `POST /admin/documents/:id/share`

**Request Body:**
```json
{ "client_id": 88 }
```

**Response `200 OK`:**
```json
{ "success": true, "message": "Document shared with client." }
```

**Response `422 Unprocessable Entity` (confidential document — hard-blocked):**
```json
{ "success": false, "error": { "code": "CONFIDENTIALITY_BLOCKED", "message": "Confidential and privileged documents cannot be shared with the client portal." } }
```

---

## Access Lifecycle

```
Staff: POST /admin/clients/:id/portal-access
        │
        ▼
  portal_users row created, status = "active" (but no password set)
  Signed, expiring invite email sent
        │
        ▼ (client clicks the link)
  GET  /portal/invite/:token   → validates, shows set-password form
  POST /portal/invite/:token   → password set
        │
        ▼
  POST /portal/login  → session established (guard: portal, separate cookie)
        │
        ▼ (any time, staff-initiated)
  PATCH /admin/clients/:id/portal-access/:id/status  → disabled
        │
        ▼
  All existing portal sessions for that user invalidated immediately
```

**Invite link expiry:** 72 hours. An expired link prompts the client to ask
the firm to resend the invite (staff action, not self-service).

---

## Validation Rules

| Field | Rules |
|---|---|
| `email` (portal_users) | Required. Valid format. Unique across all portal users (not just per client). |
| `password` | Min 10 characters, at least one number — client-friendly but still meaningful. |
| `type` (portal_requests) | Required. One of `appointment`, `inquiry`. |
| `message` | Required. Min 5 characters, max 2000. |
| Invite token | Required. Must be unexpired and unused (single-use). |

---

## Notes & Guidelines

- **⚠️ Guard isolation is non-negotiable.** The `portal` guard must never, under
  any input or edge case, resolve a staff `User` model. This is covered by an
  explicit automated test, not just careful coding — guard-confusion is a
  classic and dangerous bug class.
- **⚠️ IDOR is tested on every single portal route**, not spot-checked: for
  each route, a portal user belonging to Client A must receive `404` (not
  `403`, so existence isn't confirmed) when guessing a ULID belonging to
  Client B.
- **Confidentiality is enforced at the Action layer, not the UI.** Attempting
  to call `ShareDocumentWithClient` directly on a confidential/privileged
  document fails even if some future UI bug lets the button render — there is
  no path around this check.
- Portal serialization uses dedicated Resource/DTO classes, entirely separate
  from staff-side serializers. Internal remarks, `matter_comments`, other
  parties' contact details, and non-client-facing financial detail are
  structurally absent from every portal response — not filtered out at
  render time, simply never included in the first place.
- All portal `POST` routes are rate-limited; login additionally locks out
  after 5 failed attempts per email+IP, mirroring the staff-side policy.
- Every portal login, document download, and request submission is written to
  the activity log with the `portal_user_id` as causer, distinct from staff
  activity, so an audit review can tell client-originated actions apart from
  staff-originated ones at a glance.
