# M11 — Client Portal & Public Website

Phase 6. The two client-facing surfaces. Highest-scrutiny security work in the
project (portal) plus the firm's public-facing brand presence (site).

## Dependencies
M02 (clients), M10 (`ClientMatterReportService`), M08 (shared documents).

## Part A — Public Website

Own layout (`layouts.site`), own route file (`routes/site.php`), no auth
required, branding pulled from `firms`/`SettingsService`.

| Page | Route | Content |
|---|---|---|
| Home | `/` | Hero, practice areas grid, stats, CTA |
| About | `/about` | Firm profile & history |
| Practice areas | `/practice-areas`, `/practice-areas/{slug}` | Driven by `practice_areas` + new `is_public`, `slug`, `public_description` columns |
| Our team | `/team`, `/team/{slug}` | Lawyers flagged `show_on_website` (added to `users`/`staff`), with `public_bio`, `public_title`, photo |
| News/Insights | `/news`, `/news/{slug}` | New `posts` table: title, slug, body, cover_image, published_at, author_id |
| Offices | `/offices` | Branch directory + map (Part C) |
| Contact | `/contact` | Form → `contact_inquiries` + notifies admins |
| Client portal entry | `/portal/login` | Links into Part B |

### `posts`
```php
$table->id(); $table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->string('title'); $table->string('slug')->unique();
$table->text('body'); $table->string('cover_image')->nullable();
$table->foreignId('author_id')->constrained('users');
$table->dateTime('published_at')->nullable();
$table->timestamps();
```

### `contact_inquiries`
```php
$table->id(); $table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->string('name'); $table->string('email'); $table->string('phone')->nullable();
$table->string('subject')->nullable(); $table->text('message');
$table->string('status')->default('new');  // new/handled
$table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
```
Anti-spam: honeypot field + `throttle` middleware; no external CAPTCHA
dependency by default.

## Part B — Client Portal

Own guard `portal`, own layout, own route file (`routes/portal.php`), Sanctum
optional (only if a future mobile client needs API tokens — v1 is session-based).

### `portal_users`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('email')->unique();
$table->string('password');
$table->string('status')->default('active');  // active/disabled
$table->dateTime('last_login_at')->nullable();
$table->rememberToken();
$table->timestamps();
```
No self-registration — staff-side "Grant portal access" action (M02 client
page) sends a signed, expiring invite link to set a password. Staff can
disable access instantly.

### `shared_documents`
```php
$table->foreignId('document_id')->constrained()->cascadeOnDelete();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->foreignId('shared_by')->constrained('users');
$table->dateTime('shared_at');
$table->unique(['document_id', 'client_id']);
```
Sharing is an explicit staff action (M08 document page → "Share with client").
**`confidentiality != standard` documents cannot be shared — hard-blocked in
the `ShareDocumentWithClient` Action itself**, not merely hidden in the UI, so
there is no code path that leaks a confidential document to the portal.

### `portal_requests` (appointment/inquiry requests, staff-actioned)
```php
$table->id();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->foreignId('matter_id')->nullable()->constrained()->nullOnDelete();
$table->string('type');  // appointment / inquiry
$table->text('message');
$table->string('status')->default('open'); // open/actioned
$table->timestamps();
```
Notifies the matter's assigned lawyers (or firm-wide if no matter); portal
users cannot write anything else — no direct writes to matters/invoices.

### Portal pages (`middleware: auth:portal`)

| Page | Content |
|---|---|
| Dashboard | matter count, next hearing dates, outstanding balance, retainer balance |
| My matters | list → detail via `ClientMatterReportService` (M10) — sanitized: stage, hearing dates + outcomes only, shared documents |
| Invoices & receipts | own invoices with status + PDF download, receipts, retainer statement |
| Documents | only rows in `shared_documents` |
| Requests | submit appointment/inquiry |

### Security rules (build the tests before the views)

- Every portal controller extends a `PortalController` base that injects
  `auth('portal')->user()->client_id` — **never** trusts a route parameter for
  scoping.
- IDOR test suite: for every portal route, assert portal-user A cannot reach
  client B's matter/invoice/document by ID/ULID guessing.
- Portal serialization uses dedicated Resource/DTO classes distinct from staff
  serializers — internal remarks, comments, other parties' contacts, and
  non-client-facing financials are structurally absent from the response, not
  filtered at render time.
- Rate limiting on all portal POST routes; portal session separate cookie name
  from staff session; portal login never resolves a staff `User` model under
  any circumstance (test this explicitly — a classic guard-confusion bug).

## Part C — Branch & Office Mapping

Reuses `offices` (M01). Public page (`/offices`) and portal display: list +
Google Maps embed if `GOOGLE_MAPS_KEY` is configured; graceful degradation to a
static address list + "Open in Google Maps" deep links
(`https://maps.google.com/?q={lat},{lng}`) when no key is set, so the feature
demos without a billing dependency.

## Acceptance criteria

- [ ] Public site renders all pages under firm branding; practice area/team/
      news content is admin-manageable
- [ ] Contact form stores inquiry, notifies staff, honeypot blocks bot
      submissions (test)
- [ ] Portal invite → set password → login flow works; disabled accounts
      blocked immediately
- [ ] Full IDOR test suite green across every portal route
- [ ] Document sharing blocks non-standard confidentiality at the Action layer
      (test attempts to bypass via direct Action call, not just UI)
- [ ] Portal guard/session never resolves a staff account under any input
      (explicit test)
- [ ] Client dashboard/matter view exactly matches `ClientMatterReportService`
      sanitization (shared implementation with M10, not a re-serialization)
- [ ] Offices map/directory renders correctly with and without a Maps key

## Claude Code kickoff prompt

> Read CLAUDE.md, docs/04-SECURITY.md §6, docs/modules/M10-reporting.md (for
> `ClientMatterReportService`), and this file. Build Part A → C → B, but for
> Part B write the full IDOR test suite and the guard-confusion test FIRST,
> before a single portal view exists — the portal is the highest-risk surface
> in the product and must be provably safe before it's usable.
