# M02 — Contacts & Clients

Phase 1. Unified contact model powering clients, opposing parties, witnesses,
and portal users — one place for a person/organization, many roles across the
system.

## Dependencies
M01 (firm, RBAC, reference data).

## Design decision: unified `contacts`

Rather than separate Client/OpposingParty/Witness tables (as in many legacy
systems), one `contacts` table with a `type` and role-tables linking contacts
into context (matter parties, witnesses). This avoids duplicate data entry when
the same person appears as, say, an opposing party in one matter and a client's
director in another.

## Database

### `contacts`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->string('kind');              // Individual / Organization
$table->string('name');              // full name or org name
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->string('address')->nullable();
$table->string('city')->nullable(); $table->string('state')->nullable();
$table->foreignId('contact_category_id')->nullable()->constrained()->nullOnDelete();
$table->string('nin_encrypted')->nullable();      // encrypted cast
$table->string('rc_number')->nullable();          // CAC RC number for organizations
$table->text('notes')->nullable();
$table->timestamps(); $table->softDeletes();
$table->fullText(['name', 'notes']);
```

### `clients`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('contact_id')->constrained()->restrictOnDelete();
$table->string('client_number')->unique();   // CLI/2026/0001 via NumberingService
$table->string('status')->default('active'); // active/inactive
$table->foreignId('relationship_partner_id')->nullable()->constrained('users')->nullOnDelete();
$table->boolean('kyc_completed')->default(false);
$table->timestamps(); $table->softDeletes();
```

### `client_contacts` (organization client's individual reps)
```php
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->foreignId('contact_id')->constrained()->cascadeOnDelete();
$table->string('designation')->nullable();   // "Managing Director"
$table->boolean('is_primary')->default(false);
```

### `kyc_documents`
Uses `media` (medialibrary) collection `kyc` on `Client`, with a small
`kyc_checklist` JSON on `clients` (ID, CAC docs, proof of address, mandate
letter — checkbox state) rather than a bespoke table.

## Conflict-of-interest engine

`app/Services/ConflictCheckService.php::check(string $name, ?int $excludeMatterId = null): Collection`
Fuzzy-searches (`SOUNDEX`/`LIKE` combination, or a trigram-similarity query if
available) across:
- `contacts.name` (existing clients/parties)
- `matter_parties.name` snapshot field (opposing parties on other matters)
- `matters.opposing_counsel`

Returns matches with the matter/client they're linked to and the role, so staff
can judge relevance. **Never auto-blocks** — it's an informed-decision tool;
result + decision (proceed/declined/escalated) is logged (`conflict_checks` table:
`firm_id, searched_name, matter_id (nullable, if run during matter intake),
result_count, decision, decided_by, decided_at`).

## Routes & controllers

Standard resourceful CRUD for `contacts` and `clients` (index/create/store/
edit/update/show/destroy — RESTful naming here, unlike legacy custom verbs,
since this is a fresh build). Plus:
```
GET  /conflict-check                 conflict-check.search   (AJAX, live-as-you-type)
POST /conflict-check/decide          conflict-check.decide
GET  /clients/{client}/export        clients.export           (NDPR data export, PDF)
POST /clients/{client}/kyc           clients.kyc.update
```

## Views

`contacts/index` (KPI: total, individuals, organizations, added this month;
filters: category, kind), `contacts/show` (contact card + "appears in" list:
which clients/matters/roles reference them), `clients/index` (KPI: active,
inactive, new this month; relationship partner filter), `clients/show` (tabs:
Overview | Matters | Invoices | Documents | KYC). Client creation form includes
an inline conflict-check widget (type a name, see live matches) before submit.

## Acceptance criteria

- [ ] Contact CRUD with full-text search on name; category filter works
- [ ] Client creation auto-generates `client_number`; links to a contact
      (existing or new, created inline)
- [ ] Conflict check returns matches from parties, opposing counsel, and
      existing clients; decision is logged with the deciding user
- [ ] Organization client can have multiple linked individual contacts with
      designations and a primary flag
- [ ] KYC checklist + document upload works; `kyc_completed` reflects checklist state
- [ ] NDPR data export produces a PDF of everything held on a client
- [ ] Assigned-only visibility respected where relevant (relationship partner
      sees their clients prominently; others via `contacts.viewAll`)
- [ ] Cross-firm isolation + permission-denial tests green

## Claude Code kickoff prompt

> Read CLAUDE.md, docs/03-DATA-MODEL.md, docs/04-SECURITY.md, and this file.
> Implement M02: migrations (contacts, clients, client_contacts, conflict_checks)
> → models + `Client::visibleTo()` scope → ConflictCheckService (write its test
> first: seed overlapping names across contacts/matter_parties/opposing_counsel,
> assert correct matches) → FormRequests → controllers → views per 05-UI-UX.md
> patterns → NDPR export action → Pest tests (CRUD, conflict check, isolation,
> permissions). Matters (M03) will link to `clients` next, so keep the Client
> model's public API (`->contact`, `->matters()`) clean for that.
