# Hooks

Hooks let your resource block, rewrite or react to MDT actions, no matter where they come from: the tablet, the web portal, a server export or an automatic job.

```lua title="server.lua"
local hookId = exports['koja-mdt']:registerHook(name, function(payload, src)
end, { stage = 'before', priority = 0 })
```

| Option | Default | Notes |
|---|---|---|
| `stage` | `'after'` | `'before'` runs before the action is saved and can cancel or change it. `'after'` runs once it is done. |
| `priority` | `0` | Lower runs first. Equal priorities run in registration order. |

`registerHook` returns the hook id, or `nil, 'unknown_hook'` / `nil, 'invalid_function'`. `removeHook(hookId)` removes it (only the resource that registered it can). Hooks are removed automatically when your resource stops. `GetHookNames()` lists every name.

## Semantics

:::steps
### Before hooks

- Return `false` to cancel. Return a reason as the second value to show the officer a specific error: `return false, 'over_limit'` (64 chars max). The first hook that cancels wins and later hooks do not run.
- Return a table to change the payload. Its keys are merged into the payload and passed to the next hook. Only the fields marked **modifiable** below are applied; everything else is informational. Values are validated again after your hook (lengths, ranges, enums).
- Payloads are **copies** when they cross resources, so changing `payload` in place has no effect. Return the changes.
- Returning nothing (or `true`) lets the action continue unchanged.
- Errors are caught and printed to the console; a failing hook never blocks the MDT.

### After hooks

After hooks run asynchronously once the action is saved. Return values are ignored. Each after stage is also fired as a server event, see the event bridge below.

### Arguments

`src` is the acting player's server id, or `nil` for web portal, export and system actions. Most payloads carry `by` (or `officer` / `unit`): `{ source, identifier, name, callsign, department, system }`. `system = true` marks System and export actions.
:::

:::hint{type="info" title="Cancel reasons"}
When the table says "cancel with reason", the reason you return reaches the caller (the tablet error toast, or the second return value of the export). "Cancel only" hooks stop the action but report a generic failure.
:::

## Event bridge

Every after stage is also triggered as a **server-local** event named `koja-mdt:<hook>` with `(payload, src)`. Use it if you prefer events over hooks. It cannot cancel anything.

```lua title="server.lua"
AddEventHandler('koja-mdt:warrant:approve', function(payload, src)
    print(('warrant #%d approved by %s'):format(payload.id, payload.by and payload.by.name or 'System'))
end)
```

Use `AddEventHandler`, never `RegisterNetEvent`: clients cannot trigger these.

## Dispatch

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `dispatch:create` | A call is created (911, panic, ALPR, exports, tablet). | before: the new call `{ code, title, message, coords, street, priority, caller, departments = { lspd = true }, blip, status, units, notes, createdAt }`; after: the public [call](/new/mdt/api/overview). | Cancel only. Modifiable: `title`, `message`, `priority`, `departments`, `blip`, `street`, `coords`, `caller`. |
| `dispatch:update` | `UpdateDispatch` edits a call. | before `{ callId, title, message, priority, street, coords, code, by }`; after `{ callId, call, by }`. | Cancel with reason. Modifiable: `title`, `message`, `priority`, `street`, `coords`, `code`. |
| `dispatch:note` | A note is added (tablet or `AddDispatchNote`). | before `{ callId, text, author, by }`; after adds `call`. | Cancel with reason. Modifiable: `text`. |
| `dispatch:assign` | A unit attaches or is assigned to a call. | `{ callId, source, unit }`, after adds `call`. | Cancel only. |
| `dispatch:detach` | A unit leaves a call. | `{ callId, source, unit }`. | After only. |
| `dispatch:close` | A call is closed. | `{ callId, call, by }`. | Cancel only. |
| `dispatch:panic` | An officer presses panic. | before `{ source, officer, street }`; after adds `callId`, `call`. | Cancel only. |
| `unit:status` | A unit changes status. | `{ source, status, previous, unit }`. | Cancel with reason. |

```lua title="server.lua"
exports['koja-mdt']:registerHook('dispatch:create', function(call)
    if call.code == '10-90' then return { priority = 1, message = (call.message or '') .. ' [bank]' } end
end, { stage = 'before' })
```

## Records

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `incident:create` | A report is created. | before `{ title, type, status, location, narrative, tags, images, restricted, department, by }` (`tags` / `images` are JSON strings, `restricted` is `0` / `1`); after `{ id, incident, by }`. | Cancel with reason. Modifiable: `title`, `type`, `status`, `location`, `narrative`, `restricted`. |
| `incident:update` | A report is saved again (tablet, `UpdateIncident`, `AddPersonToIncident`, …). `AddChargeToIncident` fires the after stage only. | Same as `incident:create`, plus `id`. | Same as `incident:create`. |
| `incident:delete` | A report is deleted. | `{ id, incident, by }`. | Cancel with reason. |
| `incident:process` | A suspect is processed (fine / jail). | `{ incidentId, personId, identifier, name, target, fine, months, applyFine, applyJail, by }`. | Cancel with reason. Modifiable: `fine`, `months`, `applyFine`, `applyJail`. |
| `warrant:create` | A warrant is requested or issued. | before `{ type, identifier, name, plate, reason, charges, hours, status, department, by }`; after `{ warrant, by }`. | Cancel with reason. Modifiable: `reason`, `hours`. |
| `warrant:approve` | A judge approves a pending warrant. | `{ id, warrant, note, by }`. | Cancel with reason. |
| `warrant:deny` | A judge denies a pending warrant. | `{ id, warrant, note, by }`. | Cancel with reason. |
| `warrant:serve` | A warrant is marked served (tablet or `ServeWarrant`). Processing a suspect also serves arrest warrants, without this hook. | `{ id, warrant, note, by }`. | Cancel with reason. |
| `warrant:cancel` | A warrant is cancelled. | `{ id, warrant, note, by }`. | Cancel with reason. |
| `bolo:create` | A BOLO is created. | before `{ type, title, description, identifier, plate, image, priority, department, by }`; after `{ bolo, by }`. | Cancel with reason. Modifiable: `description`, `priority`. |
| `bolo:update` | A BOLO is edited. | before `{ id, bolo, type, title, description, identifier, plate, image, priority, by }` (`bolo` is the old version); after `{ id, bolo, by }`. | Cancel with reason. Modifiable: `description`, `priority`. |
| `bolo:close` | A BOLO is closed. | `{ id, bolo, by }`. | Cancel with reason. |
| `citation:create` | A citation is issued (tablet, citation book, export). | before `{ identifier, name, department, violations, total, points, plate, location, notes, coords, by }`; after `{ citation, by }`. | Cancel with reason. Modifiable: `total`, `points`, `notes`. |
| `citation:pay` | A citation is paid or marked paid. | `{ id, citation, manual, by }`. | Cancel with reason. |
| `citation:void` | A citation is voided. | `{ id, citation, reason, by }`. | Cancel with reason. |
| `evidence:create` | Evidence is logged. | before `{ incidentId, label, type, serial, description, location, by }`; after `{ id, evidence, by }`. | Cancel with reason (`AddEvidence` returns `nil`). Modifiable: `label`, `description`, `location`. |
| `evidence:update` | Evidence is edited. | before `{ id, evidence, incidentId, label, type, serial, description, location, status, by }`; after `{ id, evidence, by }`. | Cancel with reason. Modifiable: `label`, `description`, `location`. |
| `evidence:transfer` | Evidence moves in the chain of custody. | before `{ id, evidence, action, status, location, to, note, by }`; after `{ id, evidence, action, to, note, by }`. | Cancel with reason. |
| `evidence:delete` | Evidence is deleted. | `{ id, evidence, by }`. | Cancel with reason. |
| `weapon:save` | A weapon is registered or edited (tablet, `RegisterWeapon`, `SetWeaponStatus`). | `{ serial, identifier, owner, model, label, status, notes, by }`. | Cancel with reason. Modifiable: `status`, `notes`, `label`. |
| `vehicle:flags` | Vehicle flags or points change (tablet, `UpdateVehicle`, `SetVehicleFlag`). | `{ plate, flags, points?, flag?, state?, by }`. | Cancel with reason from the tablet, cancel only from `SetVehicleFlag`. |
| `citizen:update` | A profile is edited, a note or flag added. | `{ identifier, notes?, tags?, image?, fingerprint?, dna?, note?, tag?, state?, by }`. | Cancel with reason from the tablet, cancel only from `AddCitizenNote` / `SetCitizenFlag`. |
| `citizen:license` | A license is granted or revoked through the MDT. | before `{ identifier, type, grant, by }`; after adds `licenses`. | Cancel with reason. |

```lua title="server.lua"
exports['koja-mdt']:registerHook('incident:process', function(payload)
    if payload.months > 60 then return { months = 60 } end
end, { stage = 'before' })

exports['koja-mdt']:registerHook('citation:create', function(payload, src)
    if payload.total > 5000 then return false, 'over_limit' end
end, { stage = 'before' })
```

## Officers and tablet

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `officer:duty` | Duty on or off (tablet toggle, command, `SetDuty`, framework duty, ambulance sync). | `{ source, onDuty, department, officer }`. | Cancel only, and only for the tablet toggle, the duty command and `SetDuty`. Framework-driven changes fire the after stage only. |
| `mdt:open` | The tablet is about to open. | `{ source, officer }`. | Cancel only. |
| `mdt:close` | The tablet was closed (reported by the client). | `{ source, officer }`. | After only. |

```lua title="server.lua"
exports['koja-mdt']:registerHook('mdt:open', function(payload)
    return exports.ox_inventory:Search(payload.source, 'count', 'mdt_tablet') > 0
end, { stage = 'before' })
```

## Communication

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `mail:send` | MDT mail is sent. | `{ subject, body, recipients, identifiers, by }`, after adds `id`. | Cancel with reason. |
| `notification:send` | Any notification center entry is created (exports, mail, court, citations, roster, calendar, …). | `{ kind, title, body, actor, link, department, identifiers }`. | Cancel only. Modifiable: `title`, `body`. |
| `bulletin:create` | A bulletin is posted. | `{ title, content, department, pinned, by }`, after adds `id`. | Cancel with reason. |
| `bulletin:update` | A bulletin is edited. | before `{ id, bulletin, title, content, pinned, by }`; after `{ id, bulletin, by }`. | Cancel with reason. Modifiable: `title`, `content`, `pinned`. |
| `bulletin:delete` | A bulletin is deleted. | `{ id, bulletin, by }`. | Cancel with reason. |
| `calendar:create` | A calendar event is created. | before `{ title, description, category, location, startAt, endAt, allDay, department, divisionId, recurrence, by }`; after `{ id, title, category, start, department, by }`. | Cancel with reason. Modifiable: `title`, `description`, `location`. |
| `calendar:cancel` | An event is cancelled or restored. | `{ id, title, restore, reason, by }`. | Cancel with reason. |
| `calendar:delete` | An event is deleted. | `{ id, title, by }`. | Cancel with reason. |

:::hint{type="warning" title="Busy hook"}
`notification:send` fires for every notification the MDT creates. Keep the handler cheap.
:::

## Custody

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `jail:create` | A jail record is stored (MDT processing, court sentence, `ReportJail`). | before `{ identifier, name, months, reason, charges, incidentId, caseId, facility, system }`; after `{ id, record }`. | Cancel only: the record is not stored. Modifiable: `months`, `reason`, `facility`. |
| `jail:release` | A sentence ends (MDT early release, `ReportRelease`, transfer). | `{ id, status, reason, record, by }`; `status` is `released`, `escaped` or `transferred`. | Only for MDT early releases (tablet, `ReleaseInmate`). Cancel with reason. |
| `impound:create` | A vehicle is impounded. | before `{ plate, reason, fee, hours, lot, by }`; after `{ id, plate, record, system }`. | Only for MDT impounds (tablet, `ImpoundVehicle`). Cancel with reason. Modifiable: `reason`, `fee`, `hours`. |
| `impound:release` | An impound is released. | `{ id, plate, feeStatus, record, by }`. | Only for MDT releases (tablet, `ReleaseImpound`). Cancel with reason. |

:::hint{type="info" title="Reports are facts"}
`ReportJail`, `ReportRelease`, `ReportImpound` and `ReportImpoundRelease` describe something that already happened in another script, so they never run a cancellable `before` stage except `jail:create`, which only decides whether the MDT keeps a record. When the same sentence is reported twice within 15 seconds (for example by the jail adapter and by your script), the second report is merged into the first record and the after stage of `jail:create` does not fire again.
:::

## Court

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `court:create` | A case is filed. | before `{ title, caseType, incidentId, defendantIdentifier, defendantName, judge, prosecutor, defense, courtroom, sealed, notes, by }`; after `{ id, case, by }`. | Cancel with reason. Modifiable: `title`, `courtroom`, `notes`. |
| `court:status` | The case status is changed with the status action (`SetCourtCaseStatus`). Automatic moves after pleas, hearings and verdicts do not fire it. | before `{ id, case, status, previous, note, by }`; after replaces `case` with the new version. | Cancel with reason. |
| `court:verdict` | A verdict is entered. | before `{ id, case, verdict, by }`; after `{ id, verdict, note, case, by }`. | Cancel with reason. |
| `court:sentence` | A stored sentence is applied (`ApplyCourtSentence`). | before `{ id, case, identifier, target, fine, months, by }`; after `{ id, identifier, target, fine, months, fined, jailed, by }`. | Cancel with reason. Modifiable: `fine`, `months`. |

## EMS

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `treatment:create` | An ePCR is created. | before `{ id, identifier, name, status, type, location, complaint, transport, hospital, priority, outcome, narrative, callId, department, by }`; after `{ id, treatment, by }`. | Cancel with reason. Modifiable: `location`, `complaint`, `narrative`. |
| `treatment:update` | An ePCR is saved again. | Same as `treatment:create`. | Same. |
| `bill:create` | A medical bill is issued. | before `{ treatmentId, identifier, name, department, items, total, by }`; after `{ id, bill, by }`. | Cancel with reason. Modifiable: `total`. |
| `bill:status` | A bill is marked paid, voided or charged. | `{ id, status, reason, charged, bill, by }`. `charged = true` when the money was taken from the patient. | Only for mark paid / void. Cancel with reason. |
| `hospital:admit` | A patient is admitted. | before `{ hospital, bed, identifier, name, treatmentId, state, reason, by }`; after `{ id, admission, by }`. | Cancel with reason. |
| `hospital:discharge` | A patient is discharged. | `{ id, admission, note, by }`. | Cancel with reason. |

## ANPR

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `anpr:hit` | A plate hit is about to be logged (in-car ALPR, CCTV ANPR, `ReportAnprRead`). Cooldown-suppressed reads do not fire it. | before `{ plate, source, cameraId, cameraLabel, model, street, coords, reasons, result, officer }`; after `{ id, hit, reasons, dispatchId }`. | Cancel only: nothing is logged and no call is created. |

## Bodycam recordings

| Hook | Fires when | Payload | Before stage |
|---|---|---|---|
| `bodycam:recordingSaved` | Once per session, about 45 seconds after the recording stops, when the last frames are in. Not fired when no frames were stored. | `{ sessionId, identifier, frames, bytes, format, firstAt, lastAt, intervalMs }`. | After only. |
| `bodycam:recordingDeleted` | Footage is removed. | `{ sessionId, reason, frames, bytes, by }`. `reason`: `manual`, `session_deleted`, `retention`, `disk_cap`; `by` is set for manual deletes. | After only. |

```lua title="server.lua"
exports['koja-mdt']:registerHook('bodycam:recordingSaved', function(payload)
    print(('bodycam session %s saved, %d frames'):format(payload.sessionId, payload.frames))
end)
```

## Recipes

```lua title="server.lua"
exports['koja-mdt']:registerHook('warrant:create', function(payload, src)
    if payload.type == 'search' and not payload.reason:find('affidavit') then
        return false, 'affidavit_required'
    end
end, { stage = 'before' })

exports['koja-mdt']:registerHook('jail:create', function(payload)
    if payload.months > 120 then return { months = 120, reason = (payload.reason or '') .. ' (capped)' } end
end, { stage = 'before' })

exports['koja-mdt']:registerHook('hospital:admit', function(payload)
    exports['my-hospital']:ReserveBed(payload.admission.hospital, payload.admission.bed)
end)
```
