# KPOD Digitization Monitor — API reference

Base URL: `/api/v1`

Every response uses the same envelope:

```json
{ "status": 200, "message": "…", "data": …, "errors": [] }
```

`errors` carries Laravel's field-keyed validation bag on a 422 and is `[]`
otherwise. Non-2xx responses put `(object) {}` in `data`.

Authentication is a Sanctum bearer token: `Authorization: Bearer <token>`.

## Addressing

- **Sites** are addressed by their **code** — `NOC-STN`, `BG-01`. That is what
  the frontend calls `site.id` and what its routes are keyed on. Codes are
  unique within a programme, not globally.
- **Tasks** are addressed by their **key** — `survey`, `dailydoc`, `r2sfinal`.
- **Programme** defaults to the first active one. Pass `?program=<id>` (query
  or body) to target another.

## Field naming

Payloads use the *frontend's* names (`prio`, `pStart`, `choiceVal`, `ops`,
`opsLog`), not the database's. This is deliberate: porting `appReducer` to API
calls should not also mean rewriting every component that reads a site.

---

## Auth

| Method | Path | Capability | Notes |
| --- | --- | --- | --- |
| POST | `/auth/login` | — | `{email, password, remember?}` → `{user, token, expires_at}`. Throttled 6/min. |
| GET | `/auth/me` | signed in | Session rehydration. |
| POST | `/auth/logout` | signed in | Revokes the calling token. |
| POST | `/auth/forgot-password` | — | Always 200, whether or not the address exists. |
| POST | `/auth/reset-password` | — | `{token, email, password, password_confirmation}`. Revokes all tokens. |

`remember: false` (the default) issues a token that expires in 24 hours;
`true` issues one with no expiry.

The user payload carries `role` (`viewer` \| `admin` \| `super`) and
`capabilities` — the same strings `utils/permissions.ts` gates rendering on.

## Read

| Method | Path | Returns |
| --- | --- | --- |
| GET | `/programs` | Programmes with `siteCount`. |
| GET | `/task-types` | The `stdTasks` registry, keyed by site type. |
| GET | `/sites` | The portfolio, filtered. **Not paginated.** |
| GET | `/sites/{code}` | One site with tasks, ops log, gallery and presentation blocks. |
| GET | `/dashboard/executive` | Board metrics, funnel, leaderboards, coverage. Programme-wide. |
| GET | `/dashboard/program` | KPI cards, status breakdown, ops snapshot. Honours filters. |

`/sites` is not paginated on purpose: the map, the KPI cards and the status
chart all read the same list, and a page of it would make every one of them
wrong.

### Filters (`/sites`, `/dashboard/program`)

`type` (`DOC` \| `3D` \| `all`), `prio` (`1`–`3` \| `all`),
`stage` (a status key \| `all`), `search` (matches name or code).

`all` — what the filter bar sends for a dropdown left alone — means "no
filter", as do empty and absent values. Filters combine with AND.

The column-backed three run through
[EloquentFilter](https://github.com/Tucker-Eric/EloquentFilter): see
`app/ModelFilters/SiteFilter.php`, invoked as `Site::filter($filters)`.

`stage` is the exception. It matches the *current task's* status — the first
task that is not complete — which is derived by walking the task list rather
than stored on the row, so it cannot be a where clause. It is applied in PHP
after loading (`Site::filterByStage`). Adding a `stage()` method to the filter
would be silently ignored, so don't.

## Write

Each route is gated by the capability in the third column; a caller without it
gets a 403 in the standard envelope.

| Method | Path | Capability | Reducer case it replaces |
| --- | --- | --- | --- |
| POST | `/programs` | `program.add` | `project/add` |
| POST | `/task-types` | `taskType.add` | `taskType/add` |
| POST | `/sites` | `site.add` | `site/add` |
| PATCH | `/sites/{code}` | `site.edit` | `site/patchInfo` |
| POST | `/sites/{code}/images` | `site.addImage` | `site/addImage` |
| PATCH | `/sites/{code}/readiness` | `task.write` | `site/setReadiness` |
| PUT | `/tasks` | `task.write` | `task/save` |
| PATCH | `/sites/{code}/tasks/{key}/choice` | `task.write` | `task/setChoice` |
| PATCH | `/sites/{code}/tasks/{key}/yes-no` | `task.write` | `task/setYesNo` |
| PATCH | `/sites/{code}/tasks/{key}/stages` | `task.write` | `task/setStage` |
| POST | `/sites/{code}/ops-log` | `ops.log` | `ops/logMetric` |
| POST | `/sites/{code}/tasks/{key}/submissions` | `ops.log` | `ops/logSubmission` |

### `PUT /tasks`

The task-entry form. Programme-level rather than site-level because one save
can target several sites at once.

```json
{
  "siteIds": ["KHB-01", "KHB-02"],
  "stdKey": "survey",
  "name": null,
  "status": "done",
  "pStart": "2025-09-15", "pEnd": "2025-10-15",
  "aStart": "2025-09-16", "aEnd": "2025-10-12",
  "desc": "…", "link": "https://…", "fileName": "evidence.zip",
  "readiness": { "clean": true, "access": true },
  "choiceVal": "Approved",
  "yesNo": "Yes",
  "stageStatus": ["done", "prog"],
  "setMetrics": { "totalScan": 2500 }
}
```

- `stdKey: null` + `name` creates an open-text task; its key is slugified from
  the name.
- Planned dates fall back to what is already on the task when omitted; actual
  dates do not — clearing one is a meaningful edit.
- An unknown site code fails the **whole** request with a 404. There is no
  partial apply.
- `readiness.clean` is ignored on `DOC` sites (they are ready on Accessible
  alone).
- `setMetrics` **overwrites** the site total — it is a correction of the
  figure, not a day's work.

Returns the updated sites.

### `POST /sites/{code}/ops-log`

```json
{ "metric": "dmsUpload", "value": 250, "date": "2026-07-20" }
```

The metric's mode decides what happens:

| Mode | Metrics | Effect |
| --- | --- | --- |
| `add` | boxes, formats, ocr, qaqc, metadata, dmsUpload, actScan, procReg, docsSubmitted | Cumulates onto the running total. |
| `set` | `totalDocs`, `totalScan` | **Overwrites** the total. |
| `site` | `clean`, `access` | Flips a readiness flag; never becomes an ops figure. |

Metrics are checked against the site type — a scan metric on a document site
is a 422, not a silently-ignored write. The entry lands on the site log and on
the task that declares the metric.

Returns the full updated site.

### `POST /sites/{code}/tasks/{key}/submissions`

```json
{ "docType": "Large format", "value": 120, "date": "2026-07-20" }
```

Only valid against a task that declares `docsSubmitted` (the Approval task);
anything else is a 422. Always cumulates.

## Status codes

| Code | When |
| --- | --- |
| 200 | Read or update succeeded. |
| 201 | Something was created (site, image, log entry, programme, task type). |
| 401 | No token, or a revoked/expired one. |
| 403 | Signed in but missing the capability. |
| 404 | Unknown programme, site code or task key. |
| 409 | Duplicate name (site, programme, task type). |
| 422 | Validation failure, or a business rule (wrong metric for the site type, wrong task kind, stage out of range). |
| 429 | Auth endpoint throttled. |

## Demo accounts

Seeded by `UserSeeder`, password `kpod1234` (`KPOD_DEMO_PASSWORD`):

| Email | Role | Capabilities |
| --- | --- | --- |
| `super@kpod.iq` | super | all seven |
| `admin@kpod.iq` | admin | `task.write`, `site.edit`, `site.addImage`, `ops.log` |
| `viewer@kpod.iq` | viewer | none — read-only |

The admin and viewer addresses match `DEMO_USERS` in the frontend's
`store/auth-context.ts`, so the login screen's prefilled credentials keep
working once it is pointed at the real API.
