# M04 — Litigation: Hearings, Roster & Deadlines

Phase 2. The highest-liability module in the system: missing a court date or a
limitation deadline is malpractice. Built as a first-class engine, not a
side-feature.

## Dependencies
M03 (matters), M01 (courts, users, offices).

## Part A — Hearings

### `hearings`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('matter_id')->constrained()->cascadeOnDelete();
$table->foreignId('court_id')->nullable()->constrained()->nullOnDelete();
$table->date('hearing_date'); $table->time('hearing_time')->nullable();
$table->string('type');                 // HearingType enum
$table->string('purpose')->nullable();
$table->string('coram')->nullable();
$table->string('outcome')->nullable();   // HearingOutcome enum, null until held
$table->text('proceedings')->nullable();
$table->text('remarks')->nullable();
$table->date('next_adjourned_date')->nullable();
$table->string('next_purpose')->nullable();
$table->string('status')->default('scheduled');  // scheduled/held/missed
$table->foreignId('created_by')->constrained('users');
$table->timestamps(); $table->softDeletes();
$table->index(['firm_id', 'hearing_date']);
$table->index(['matter_id', 'hearing_date']);
```

### `witnesses` / `testimonies`
```php
// witnesses
$table->id();
$table->foreignId('matter_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('side')->nullable();
$table->string('phone')->nullable(); $table->text('summary')->nullable();
$table->string('status')->default('pending');  // pending/testified/discharged
$table->timestamps();

// testimonies
$table->id();
$table->foreignId('witness_id')->constrained()->cascadeOnDelete();
$table->foreignId('hearing_id')->nullable()->constrained()->nullOnDelete();
$table->date('testified_on'); $table->text('testimony');
$table->text('cross_examination')->nullable();
$table->foreignId('recorded_by')->constrained('users');
$table->timestamps();
```

### The adjournment chain (`app/Actions/Litigation/RecordHearingOutcome.php`)

Transactional, single Action, called from one route:
1. Set `status = held`, save outcome/proceedings/remarks.
2. If `next_adjourned_date` present → create the next `hearings` row
   (`status = scheduled`), copying court/matter/next_purpose.
3. Append `matter_events` ("hearing_held").
4. Fire `HearingHeld` event → notification to matter team (M09).
5. If outcome ∈ {Judgment, StruckOut, Settled} → suggest (don't force) a
   matter status change via a flash prompt.

## Part B — Court Roster

### `hearing_attendances`
```php
$table->id();
$table->foreignId('hearing_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default('lead');   // lead/supporting/holding_brief
$table->string('status')->default('assigned'); // assigned/confirmed/attended/missed/covered
$table->foreignId('covered_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['hearing_id', 'user_id']);
```

`RosterService::assign(User, Hearing)`: clash detection — same user, same
`hearing_date`, different `court_id` → warning (not a hard block) naming the
clashing matter; override recorded in `notes`. Missed-without-cover → escalation
notification to `dashboard.executive` holders.

Views: roster board (day/week grid, courts as rows), calendar, printable daily
cause list (queued DomPDF job), per-lawyer signed iCal feed.

## Part C — Deadlines & Limitation Engine (build this carefully — highest risk)

### `matter_deadlines`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('matter_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('type');           // limitation / filing / appeal_window / undertaking / custom
$table->date('due_date');
$table->json('reminder_offsets')->default('[30,14,7,1]');  // days-before
$table->string('status')->default('open');   // open/completed/missed/waived
$table->dateTime('completed_at')->nullable();
$table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete();
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['firm_id', 'due_date', 'status']);
```

### `deadline_reminders_sent` (idempotency guard)
```php
$table->foreignId('matter_deadline_id')->constrained()->cascadeOnDelete();
$table->unsignedSmallInteger('offset_days');
$table->dateTime('sent_at');
$table->unique(['matter_deadline_id', 'offset_days']);
```
Prevents double-sending if the scheduled command runs more than once in a
window — this table is the correctness backbone of the whole engine; write its
test before the command.

### Appeal-window auto-suggestion
When `RecordHearingOutcome` records a `Judgment` outcome, offer (modal, not
forced) to auto-create an appeal-window deadline: `due_date = hearing_date +
config('legal.appeal_window_days', 90)` (configurable per court type in
Settings).

### Command: `legal:deadline-reminders` (hourly via scheduler)
For every open deadline where `today = due_date - offset_days` for any configured
offset AND no row exists in `deadline_reminders_sent` for that offset → notify
`owner_id` (fallback: matter team) + write the guard row. Deadlines ≤ 14 days
show a **red banner** on the matter page regardless of reminder state (visual
safety net independent of the notification pipeline).

## Routes

RESTful `hearings` nested under matters + roster/deadline routes:
```
POST /hearings/{hearing}/outcome         hearings.outcome
POST /matters/{matter}/witnesses         witnesses.store
POST /witnesses/{witness}/testimonies    testimonies.store
GET  /roster                             roster.index
POST /hearings/{hearing}/assign          roster.assign
POST /attendances/{attendance}/status    roster.attendance.update
GET  /roster/ical/{user}                 roster.ical      (signed URL)
GET  /roster/print                       roster.print     (queued PDF)
RESTful /matters/{matter}/deadlines      (matter_deadlines resource)
```

## Acceptance criteria

- [ ] Recording an outcome with a next date auto-creates the next hearing
      (transactional; test rollback on failure leaves no partial state)
- [ ] Witness/testimony records attach correctly; testimony can link a hearing
- [ ] Roster clash detection warns on cross-court same-day double-booking;
      override is recorded
- [ ] iCal feed only accessible via signed URL; validates in a calendar client
- [ ] Deadline reminders fire exactly once per offset (idempotency test:
      run the command twice, assert one notification)
- [ ] Deadlines ≤14 days show the red banner on the matter page independent of
      whether a reminder job has run
- [ ] Judgment outcome offers (not forces) appeal-window deadline creation
- [ ] Missed-without-cover attendance escalates to executive-dashboard holders

## Claude Code kickoff prompt

> Read CLAUDE.md, docs/03-DATA-MODEL.md, docs/04-SECURITY.md, and this file.
> Build Part A (hearings + adjournment chain) → Part B (roster + clash
> detection) → Part C (deadlines) in that order, each with Pest tests before
> moving on. For Part C, write the idempotency test for
> `deadline_reminders_sent` FIRST — this is the module where a bug directly
> causes client harm, so do not skip the reminder-idempotency and
> concurrent-reminder-dispatch tests.
