# Foundation & Administration — BAMS Legal

> Core tenancy, authentication, RBAC, firm settings, offices, and reference data
> that every other module depends on. Staff-only — nothing here is public.

---

## Table of Contents

1. [Firm](#firm)
2. [User](#user)
3. [Role & Permission](#role--permission)
4. [Office](#office)
5. [Reference Data](#reference-data)
6. [Setting](#setting)
7. [Notes & Guidelines](#notes--guidelines)

---

## Firm

Tenant root. Single firm at launch; every domain table carries `firm_id`.

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `name` | String | Yes | — | Display name |
| `legal_name` | String | No | `null` | Registered legal name |
| `case_prefix` | String(10) | No | `MAT` | Matter numbering prefix |
| `logo_path` | String | No | `null` | Branding logo — **admin only** |
| `primary_color` | String(7) | No | `null` | Hex color for theming |
| `accent_color` | String(7) | No | `null` | Hex color for theming |
| `timezone` | String | No | `Africa/Lagos` | Firm timezone |
| `created_at` / `updated_at` | Timestamp | Auto | — | UTC |

### Admin Routes

**Base:** `/admin/firm` · **Auth:** Bearer/session, permission `firm.manage`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/firm` | View firm profile |
| `PATCH` | `/admin/firm` | Update firm profile & branding |
| `POST` | `/admin/firm/logo` | Upload/replace logo |

### Validation Rules

| Field | Rules |
|---|---|
| `name` | Required. Max 255. |
| `primary_color` / `accent_color` | Optional. Regex `^#[0-9A-Fa-f]{6}$`. |
| `case_prefix` | Optional. Alphanumeric, max 10, uppercased on save. |

### Notes & Guidelines

- There is no public or portal route for `Firm` — branding is read server-side
  and injected into layouts, never fetched client-side by an unauthenticated caller.
- `logo_path` points to a private disk path; served through an authorized
  streaming route, never a public asset URL, so branding changes can't leak
  internal file structure.

---

## User

Staff accounts. Distinct from `PortalUser` (M11) — no code path resolves one
guard's model from the other's session.

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `firm_id` | FK | Yes | — | Owning firm |
| `name` | String | Yes | — | Full name |
| `email` | String | Yes | — | Unique login |
| `password` | String (hashed) | Yes | — | Argon2id hash — **never exposed** |
| `two_factor_secret` | String (encrypted) | No | `null` | TOTP secret — **admin only, encrypted at rest** |
| `two_factor_recovery_codes` | JSON (encrypted) | No | `null` | Hashed recovery codes — **never exposed** |
| `status` | Enum | Yes | `active` | `active`, `suspended` |
| `last_login_at` | Timestamp | No | `null` | Last successful login |
| `created_at` / `updated_at` | Timestamp | Auto | — | UTC |

### Admin Routes

**Base:** `/admin/users` · **Auth:** session, permission `users.manage`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/users` | List users (paginated, filterable by role/status) |
| `GET` | `/admin/users/:id` | Get a single user record |
| `POST` | `/admin/users` | Invite a new user (email invite, no password set server-side) |
| `PATCH` | `/admin/users/:id` | Update name/email/roles |
| `PATCH` | `/admin/users/:id/status` | Suspend/reactivate |
| `POST` | `/admin/users/:id/force-logout` | Invalidate all sessions |
| `POST` | `/admin/users/:id/reset-2fa` | Reset 2FA — **requires a logged reason** |
| `DELETE` | `/admin/users/:id` | Soft-delete (never hard delete — audit trail) |

### Request & Response Examples

**Invite a user** — `POST /admin/users`

```json
{ "name": "Aisha Bello", "email": "aisha@firm.com", "roles": ["Counsel"] }
```

```json
{
  "success": true,
  "data": { "id": 42, "ulid": "01J...", "email": "aisha@firm.com", "status": "active" },
  "message": "Invitation sent. Aisha will receive an email to set her password."
}
```

**Reset 2FA** — `POST /admin/users/:id/reset-2fa`

```json
{ "reason": "Lost device, verified identity via phone call" }
```

```json
{ "success": true, "message": "2FA reset. User will be prompted to re-enrol on next login." }
```

### Validation Rules

| Field | Rules |
|---|---|
| `email` | Required. Valid format. Unique per firm. |
| `name` | Required. Max 255. |
| `roles` | Required on invite. Must exist in the firm's seeded role list. |
| `reason` (2FA reset) | Required, min 10 characters — stored in the security audit log. |

### Notes & Guidelines

- **⚠️ No self-registration.** Users only exist via an admin invite; the invite
  email carries a signed, expiring link to set a password.
- **Password/2FA secrets never appear in any API response**, including admin
  views — only booleans like `has_2fa_enabled` are exposed.
- Suspending a user does not delete their historical records (activity log,
  authored matters, time entries) — `status = suspended` blocks login only.
- Every action on this endpoint group is written to the security events log
  (see M12 Audit Review), not just the general activity log.

---

## Role & Permission

Spatie-backed RBAC, teamed by `firm_id`. See `docs/04-SECURITY.md` for the full
permission matrix; this section covers the management API only.

### Schema (Role)

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `firm_id` | FK | Yes | — | Owning firm (Spatie teams mode) |
| `name` | String | Yes | — | e.g. `Counsel`, `Accounts` |
| `permissions` | Array (relation) | No | `[]` | Assigned permission names |

### Admin Routes

**Base:** `/admin/roles` · **Auth:** session, permission `roles.manage` (requires 2FA)

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/roles` | List roles with permission counts |
| `GET` | `/admin/roles/:id` | Get a role's full permission list |
| `POST` | `/admin/roles` | Create a custom role |
| `PATCH` | `/admin/roles/:id/permissions` | Update a role's permission set |
| `DELETE` | `/admin/roles/:id` | Delete a role (blocked if users are assigned) |

### Validation Rules

| Field | Rules |
|---|---|
| `name` | Required. Unique per firm. Max 100. |
| `permissions` | Array of valid permission names from the seeded matrix only — no ad hoc permission strings. |

### Notes & Guidelines

- The seeded default roles (`Managing Partner`, `Partner`, `Counsel`,
  `Paralegal`, `Accounts`, `Firm Admin`) cannot be deleted, only have their
  permissions adjusted.
- Deleting a role with assigned users is blocked — reassign users first
  (`409 Conflict` with the count of affected users).
- This entire route group requires 2FA on the acting admin (`requires_2fa`
  flag from `docs/04-SECURITY.md`).

---

## Office

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `firm_id` | FK | Yes | — | Owning firm |
| `name` | String | Yes | — | e.g. "Head Office — Kano" |
| `address` | String | Yes | — | Street address |
| `city` / `state` | String | No | `null` | |
| `latitude` / `longitude` | Decimal | No | `null` | For map display |
| `phone` / `email` | String | No | `null` | |
| `is_head_office` | Boolean | No | `false` | |
| `is_active` | Boolean | No | `true` | |

### Admin Routes

**Base:** `/admin/offices` · **Auth:** session, permission `offices.manage`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/offices` | List offices |
| `POST` | `/admin/offices` | Create an office |
| `PATCH` | `/admin/offices/:id` | Update an office |
| `DELETE` | `/admin/offices/:id` | Deactivate (soft delete) |

### Validation Rules

| Field | Rules |
|---|---|
| `name` | Required. Max 255. |
| `address` | Required. |
| `latitude` / `longitude` | Optional. Must be provided as a pair, not one alone. |

### Notes & Guidelines

- Exactly one office may hold `is_head_office = true` — enforced server-side
  (setting it on one office unsets it on others in the same transaction).
- Offices feed the M11 client portal's office list and the M03 matter
  `office_id` field — deactivating an office does not affect matters already
  linked to it.

---

## Reference Data

Covers `courts`, `practice_areas`, `matter_stages`, `contact_categories`,
`document_types` — all share one shape and one route pattern.

### Schema (generic shape)

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `ulid` | ULID | Auto | — | Public identifier |
| `firm_id` | FK | Yes | — | Owning firm |
| `name` | String | Yes | — | Display name |
| `slug` | String | No | `null` | URL-safe key |
| `sort_order` | Integer | No | `0` | Display order |
| `is_active` | Boolean | No | `true` | Soft-hide without deleting |

`courts` additionally has `type` (CourtType enum) and `location`.

### Admin Routes

**Base:** `/admin/{courts|practice-areas|matter-stages|contact-categories|document-types}`
**Auth:** session, permission `reference_data.manage`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/{resource}` | List all (active + inactive) |
| `POST` | `/admin/{resource}` | Create an entry |
| `PATCH` | `/admin/{resource}/:id` | Update an entry |
| `DELETE` | `/admin/{resource}/:id` | Deactivate (never hard-deleted if referenced) |

### Validation Rules

| Field | Rules |
|---|---|
| `name` | Required. Unique per firm per resource type. Max 255. |
| `type` (courts only) | Required. One of the seeded `CourtType` enum values. |

### Notes & Guidelines

- **Referenced reference-data rows cannot be hard-deleted** — deletion only
  sets `is_active = false`. A matter or hearing pointing at an inactive court/
  practice area still displays correctly; only the create/edit dropdowns hide it.
- Cached indefinitely (`Cache::tags("firm:{id}:reference")`), busted on any
  write — these lists rarely change and are read on almost every page.

---

## Setting

Firm-level configuration key-value store (VAT rate, WHT rate, reminder
offsets, appeal-window days, numbering prefixes).

### Schema

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `id` | Integer | Auto | — | Primary key |
| `firm_id` | FK | Yes | — | Owning firm |
| `key` | String | Yes | — | e.g. `vat_rate`, `wht_rate` |
| `value` | JSON | No | `null` | Typed value |

### Admin Routes

**Base:** `/admin/settings` · **Auth:** session, permission `settings.manage`

| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/admin/settings` | Get all settings, grouped by section |
| `PATCH` | `/admin/settings` | Bulk-update settings |

### Request & Response Example

**Update settings** — `PATCH /admin/settings`

```json
{ "vat_rate": 7.5, "wht_rate": 5, "deadline_reminder_offsets": [30, 14, 7, 1] }
```

```json
{ "success": true, "message": "Settings updated." }
```

### Notes & Guidelines

- Settings are cached per firm and invalidated on write — read-heavy, write-rare.
- Only a fixed, known set of keys is accepted (validated against an allow-list
  in `SettingsService`); arbitrary keys are rejected to prevent config sprawl.
