# ShopDB Flask - API Reference

Auto-inventoried across core + plugins (362 endpoints). Auth legend: none = public, jwt = login required, jwt-optional = works either way, admin = admin role, permission:x = named RBAC permission, api-key = X-API-Key managed token.

## core-identity

### POST /api/auth/login
**Auth:** none
**Params:** body: `username`, `password` (both required)
**Purpose:** Authenticate and issue JWT access+refresh tokens; per-IP fixed-window rate limit (429) and 5-strike/15-min account lockout.

```bash
curl -X POST http://localhost:5001/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"secret123"}'
```

### POST /api/auth/refresh
**Auth:** JWT (refresh token)
**Params:** none; refresh token in Authorization header
**Purpose:** Exchange a refresh token for a new access token (rejects inactive/deleted users).

```bash
curl -X POST http://localhost:5001/api/auth/refresh -H "Authorization: Bearer $REFRESH_TOKEN"
```

### GET /api/auth/me
**Auth:** JWT
**Params:** none
**Purpose:** Return the authenticated user's profile, roles, permissions, `mustchangepassword` flag.

```bash
curl http://localhost:5001/api/auth/me -H "Authorization: Bearer $TOK"
```

### POST /api/auth/change-password
**Auth:** JWT
**Params:** body: `new_password` (min 8, required), `current_password` (required unless `mustchangepassword` is set)
**Purpose:** Self-service password change; forced first-login change skips `current_password`; clears lockout state.

```bash
curl -X POST http://localhost:5001/api/auth/change-password -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"current_password":"old","new_password":"newpass123"}'
```

### POST /api/auth/logout
**Auth:** JWT
**Params:** none
**Purpose:** Logout stub for frontend token cleanup (no server-side blacklist yet).

```bash
curl -X POST http://localhost:5001/api/auth/logout -H "Authorization: Bearer $TOK"
```

### GET /api/users
**Auth:** JWT + role `admin`
**Params:** none
**Purpose:** List all users ordered by username.

```bash
curl http://localhost:5001/api/users -H "Authorization: Bearer $TOK"
```

### GET /api/users/&lt;userid&gt;
**Auth:** JWT (admin or self, checked inline)
**Params:** path: `userid` (int)
**Purpose:** Get one user; non-admins may only fetch their own record (403 otherwise).

```bash
curl http://localhost:5001/api/users/7 -H "Authorization: Bearer $TOK"
```

### POST /api/users
**Auth:** JWT + role `admin`
**Params:** body: `username`, `email`, `password` (required); `firstname`, `lastname`, `isactive`, `roles` [roleids], `mustchangepassword` (default true), `sendwelcome` (default true)
**Purpose:** Create a user, assign roles, audit-log, and best-effort send a welcome email with the temp password.

```bash
curl -X POST http://localhost:5001/api/users -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"username":"jdoe","email":"jdoe@example.com","password":"Temp1234","roles":[2]}'
```

### PUT /api/users/&lt;userid&gt;
**Auth:** JWT (admin or self; `isactive`/`roles`/`unlock` fields admin-only)
**Params:** path: `userid`; body: `email`, `firstname`, `lastname`, `password`; admin-only: `isactive`, `roles` [roleids], `unlock` (bool)
**Purpose:** Update a user; email uniqueness enforced; admins can also change active state, roles, and unlock the account; changes audit-logged.

```bash
curl -X PUT http://localhost:5001/api/users/7 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"firstname":"Jane","unlock":true}'
```

### DELETE /api/users/&lt;userid&gt;
**Auth:** JWT + role `admin`
**Params:** path: `userid` (cannot be your own account)
**Purpose:** Delete a user; revokes their API tokens and detaches (nulls `userid` on) their audit-log rows first.

```bash
curl -X DELETE http://localhost:5001/api/users/7 -H "Authorization: Bearer $TOK"
```

### GET /api/users/permissions
**Auth:** JWT
**Params:** none
**Purpose:** List assignable permissions (core + enabled plugins) both flat and grouped by category, for the role grid.

```bash
curl http://localhost:5001/api/users/permissions -H "Authorization: Bearer $TOK"
```

### GET /api/users/roles
**Auth:** JWT
**Params:** none
**Purpose:** List all roles with description, color, user count, permission names, and `isadmin` flag.

```bash
curl http://localhost:5001/api/users/roles -H "Authorization: Bearer $TOK"
```

### POST /api/users/roles
**Auth:** JWT + role `admin`
**Params:** body: `rolename` (required), `description`, `color`, `permissions` [names]
**Purpose:** Create a role and assign permissions by name; 409 if `rolename` exists.

```bash
curl -X POST http://localhost:5001/api/users/roles -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"rolename":"viewer","permissions":["assets.view"]}'
```

### PUT /api/users/roles/&lt;roleid&gt;
**Auth:** JWT + role `admin`
**Params:** path: `roleid`; body: `description`, `color`, `permissions` [names] (permissions immutable on the admin role)
**Purpose:** Update a role's description/color/permissions; admin role's permission set cannot be modified; audit-logged.

```bash
curl -X PUT http://localhost:5001/api/users/roles/3 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"color":"#0057b8","permissions":["assets.view","assets.edit"]}'
```

### DELETE /api/users/roles/&lt;roleid&gt;
**Auth:** JWT + role `admin`
**Params:** path: `roleid`
**Purpose:** Delete a role; refuses for the admin role or any role still assigned to users.

```bash
curl -X DELETE http://localhost:5001/api/users/roles/3 -H "Authorization: Bearer $TOK"
```

### GET /api/apitokens
**Auth:** JWT
**Params:** query: `all=true` (admin only, lists everyone's tokens with owner info)
**Purpose:** List the caller's own API tokens (never returns hashes or secrets); admins may list all.

```bash
curl 'http://localhost:5001/api/apitokens?all=true' -H "Authorization: Bearer $TOK"
```

### POST /api/apitokens
**Auth:** JWT + permission `apitokens.create`
**Params:** body: `name` (required), `expiresat` (date/datetime), `scopes` [permission names, ceiling = owner's permissions], `resourcescopes` [resource names, plugin-defined]
**Purpose:** Create a personal API token for the caller; the full secret is returned once in this response and never again.

```bash
curl -X POST http://localhost:5001/api/apitokens -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"name":"import-script","scopes":["assets.edit"],"expiresat":"2026-12-31"}'
```

### PUT /api/apitokens/&lt;tokenid&gt;
**Auth:** JWT + permission `apitokens.create` (own token, or any if admin)
**Params:** path: `tokenid`; body: `name`, `isactive` (bool), `scopes` [names, validated against the token owner's permissions], `resourcescopes` [names]
**Purpose:** Rename, rescope, or (de)activate a token; scope ceiling is always the owner, even when an admin edits.

```bash
curl -X PUT http://localhost:5001/api/apitokens/4 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"isactive":false}'
```

### DELETE /api/apitokens/&lt;tokenid&gt;
**Auth:** JWT + permission `apitokens.create` (own token, or any if admin)
**Params:** path: `tokenid`
**Purpose:** Revoke (deactivate, not delete) a token; audit-logged.

```bash
curl -X DELETE http://localhost:5001/api/apitokens/4 -H "Authorization: Bearer $TOK"
```

### GET /api/setup/needs-admin
**Auth:** none
**Params:** none
**Purpose:** Return `{needsadmin: true}` when zero users exist, so the login screen can offer first-run setup.

```bash
curl http://localhost:5001/api/setup/needs-admin
```

### POST /api/setup/create-admin
**Auth:** none (only functions while zero users exist; 403 afterwards)
**Params:** body: `username`, `email`, `password` (all required)
**Purpose:** Bootstrap the very first admin account (creates the admin role if missing).

```bash
curl -X POST http://localhost:5001/api/setup/create-admin -H 'Content-Type: application/json' -d '{"username":"admin","email":"admin@example.com","password":"ChangeMe123"}'
```

### POST /api/setup/seed-reference
**Auth:** JWT + role `admin`
**Params:** none
**Purpose:** Idempotently seed core reference data, permissions, and default settings (runs the flask seed CLI routines).

```bash
curl -X POST http://localhost:5001/api/setup/seed-reference -H "Authorization: Bearer $TOK"
```

### POST /api/setup/seed-starter
**Auth:** JWT + role `admin`
**Params:** none
**Purpose:** Idempotently add a starter list of common hardware vendors (Dell, HP, Lenovo, ...).

```bash
curl -X POST http://localhost:5001/api/setup/seed-starter -H "Authorization: Bearer $TOK"
```

### POST /api/setup/complete
**Auth:** JWT + role `admin`
**Params:** none
**Purpose:** Set the `setup_complete` setting to true, marking the first-run wizard finished.

```bash
curl -X POST http://localhost:5001/api/setup/complete -H "Authorization: Bearer $TOK"
```

### GET /api/settings
**Auth:** JWT optional
**Params:** query: `category` (filter)
**Purpose:** List settings; unauthenticated callers see only the public allowlist (branding+map categories plus named bootstrap keys); secrets always masked as `********`.

```bash
curl 'http://localhost:5001/api/settings?category=branding'
```

### POST /api/settings
**Auth:** JWT + permission `settings.edit`
**Params:** body: `key` (required), `value`, `valuetype` (default string), `category` (default general), `description`
**Purpose:** Create a new setting; 409 if the key exists.

```bash
curl -X POST http://localhost:5001/api/settings -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"key":"facility_name","value":"West Jefferson","category":"site"}'
```

### GET /api/settings/&lt;key&gt;
**Auth:** JWT optional
**Params:** path: `key`
**Purpose:** Get one setting; non-public keys return 404 (not 403) to unauthenticated callers; secret values masked.

```bash
curl http://localhost:5001/api/settings/facility_name
```

### PUT /api/settings/&lt;key&gt;
**Auth:** JWT + permission `settings.edit`
**Params:** path: `key`; body: `value` (required; bool coerced to 'true'/'false'; secret mask `********` means leave unchanged)
**Purpose:** Update a setting (upserts a plugin-scoped string row if the key is new); audit-logged with secrets masked; invalidates the 5-min settings cache.

```bash
curl -X PUT http://localhost:5001/api/settings/facility_name -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"value":"West Jefferson"}'
```

### POST /api/settings/seed
**Auth:** JWT + permission `settings.edit`
**Params:** none
**Purpose:** Idempotently create any missing default settings (identifier toggles, search toggles, map, SMTP, SAML, etc.).

```bash
curl -X POST http://localhost:5001/api/settings/seed -H "Authorization: Bearer $TOK"
```

### POST /api/settings/test-email
**Auth:** JWT + permission `settings.edit`
**Params:** body: `to` (optional; falls back to `alert_recipients` setting)
**Purpose:** Send a test email to verify SMTP config; always 200 with a `sent` flag, SMTP errors returned with credentials scrubbed.

```bash
curl -X POST http://localhost:5001/api/settings/test-email -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"to":"me@example.com"}'
```

### POST /api/settings/map-blueprint
**Auth:** JWT + role `admin`
**Params:** multipart/form-data: `file` (png/jpg/jpeg/gif/webp/svg), `theme=light|dark`
**Purpose:** Upload a floor-map blueprint image to the instance maps dir and point `map_blueprint_<theme>` at its served URL.

```bash
curl -X POST http://localhost:5001/api/settings/map-blueprint -H "Authorization: Bearer $TOK" -F 'file=@floor.png' -F 'theme=light'
```

### GET /api/settings/map-blueprint/&lt;path:filename&gt;
**Auth:** none
**Params:** path: `filename`
**Purpose:** Serve an uploaded floor-map blueprint image (public so kiosk dashboards can load it).

```bash
curl -O http://localhost:5001/api/settings/map-blueprint/blueprint-light.png
```

### POST /api/settings/branding-logo
**Auth:** JWT + role `admin`
**Params:** multipart/form-data: `file` (map image types plus .ico), `kind=site|qr|badge|favicon`
**Purpose:** Upload a branding logo/favicon to the instance branding dir and set the matching branding setting (`site_logo`/`qr_logo`/`badge_logo`/`site_favicon`).

```bash
curl -X POST http://localhost:5001/api/settings/branding-logo -H "Authorization: Bearer $TOK" -F 'file=@logo.svg' -F 'kind=site'
```

### GET /api/settings/branding/&lt;path:filename&gt;
**Auth:** none
**Params:** path: `filename`
**Purpose:** Serve an uploaded branding logo (public - kiosks and print pages read it).

```bash
curl -O http://localhost:5001/api/settings/branding/logo-site.svg
```

## core-catalog

### GET /api/assets/types
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active=false` to include inactive
**Purpose:** List asset types (paginated, active-only by default).

```bash
curl http://localhost:5001/api/assets/types?active=false
```

### GET /api/assets/types/&lt;type_id&gt;
**Auth:** JWT optional
**Params:** path: `type_id`
**Purpose:** Get one asset type.

```bash
curl http://localhost:5001/api/assets/types/1
```

### POST /api/assets/types
**Auth:** permission `assets.create`
**Params:** body: `assettype` (req), `pluginname`, `tablename`, `description`, `icon`, `color`
**Purpose:** Create asset type (409 on duplicate name).

```bash
curl -X POST http://localhost:5001/api/assets/types -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"assettype":"Robot","icon":"mdi-robot"}'
```

### PUT /api/assets/types/&lt;type_id&gt;
**Auth:** permission `assets.edit`
**Params:** body: `description`, `icon`, `color`, `isactive`
**Purpose:** Update asset type display fields only (name/plugin/table are structural, not editable).

```bash
curl -X PUT http://localhost:5001/api/assets/types/1 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"color":"#ff0000"}'
```

### GET /api/assets/statuses
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active=false`
**Purpose:** List asset statuses (paginated, active-only by default).

```bash
curl http://localhost:5001/api/assets/statuses
```

### GET /api/assets/statuses/&lt;status_id&gt;
**Auth:** JWT optional
**Params:** path: `status_id`
**Purpose:** Get one asset status.

```bash
curl http://localhost:5001/api/assets/statuses/1
```

### POST /api/assets/statuses
**Auth:** permission `assets.create`
**Params:** body: `status` (req), `description`, `color`
**Purpose:** Create asset status (409 on duplicate).

```bash
curl -X POST http://localhost:5001/api/assets/statuses -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"status":"In Repair","color":"#f90"}'
```

### PUT /api/assets/statuses/&lt;status_id&gt;
**Auth:** permission `assets.edit`
**Params:** body: `status`, `description`, `color`, `isactive`
**Purpose:** Update asset status (rename conflict-checked).

```bash
curl -X PUT http://localhost:5001/api/assets/statuses/2 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"isactive":false}'
```

### DELETE /api/assets/statuses/&lt;status_id&gt;
**Auth:** permission `assets.delete`
**Params:** path: `status_id`
**Purpose:** Hard-delete asset status; 409 if any asset still uses it.

```bash
curl -X DELETE http://localhost:5001/api/assets/statuses/9 -H "Authorization: Bearer $TOK"
```

### GET /api/assets/relationshiptypes
**Auth:** JWT optional
**Params:** none
**Purpose:** List relationship types incl. read-only `propagatesthrough` rails.

```bash
curl http://localhost:5001/api/assets/relationshiptypes
```

### POST /api/assets/relationshiptypes
**Auth:** permission `assets.create`
**Params:** body: `relationshiptype` (req), `description`, `color`, `isdirectional` (default true)
**Purpose:** Create relationship type (409 on duplicate).

```bash
curl -X POST http://localhost:5001/api/assets/relationshiptypes -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"relationshiptype":"controls","isdirectional":true}'
```

### PUT /api/assets/relationshiptypes/&lt;type_id&gt;
**Auth:** permission `assets.edit`
**Params:** body: `relationshiptype`, `description`, `color`, `isdirectional`
**Purpose:** Update relationship type (rename conflict-checked).

```bash
curl -X PUT http://localhost:5001/api/assets/relationshiptypes/3 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"color":"#00f"}'
```

### DELETE /api/assets/relationshiptypes/&lt;type_id&gt;
**Auth:** permission `assets.delete`
**Params:** path: `type_id`
**Purpose:** Hard-delete relationship type; 409 while relationships use it.

```bash
curl -X DELETE http://localhost:5001/api/assets/relationshiptypes/3 -H "Authorization: Bearer $TOK"
```

### GET /api/assets
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `search` (assetnumber/name/serialnumber ilike), `type` (name), `typeid|type_id`, `statusid|status_id`, `locationid|location_id`, `businessunitid|businessunit_id`, `sort` (assetnumber|name|createddate|modifieddate), `dir` (asc|desc), `include_type_data`
**Purpose:** List assets with filtering, search, sorting, pagination.

```bash
curl 'http://localhost:5001/api/assets?type=machine&search=205&include_type_data=true'
```

### GET /api/assets/&lt;asset_id&gt;
**Auth:** JWT optional
**Params:** `include_type_data` (default true)
**Purpose:** Get one asset with full details.

```bash
curl http://localhost:5001/api/assets/42
```

### POST /api/assets
**Auth:** permission `assets.create`
**Params:** body: `assetnumber` (req), `assettypeid` (req), `name`, `serialnumber`, `statusid` (default 1), `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`
**Purpose:** Create asset (duplicate assetnumber 409, assettypeid validated); honors `X-Import-Mode` timestamps.

```bash
curl -X POST http://localhost:5001/api/assets -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"assetnumber":"0205","assettypeid":1,"name":"Grinder 5"}'
```

### PUT /api/assets/&lt;asset_id&gt;
**Auth:** permission `assets.edit`
**Params:** body: `assetnumber`, `name`, `serialnumber`, `assettypeid`, `statusid`, `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `isactive`
**Purpose:** Update asset (allowed fields incl. isactive); honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/assets/42 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"locationid":3}'
```

### DELETE /api/assets/&lt;asset_id&gt;
**Auth:** permission `assets.delete`
**Params:** path: `asset_id`
**Purpose:** Soft-delete asset (`isactive=false`).

```bash
curl -X DELETE http://localhost:5001/api/assets/42 -H "Authorization: Bearer $TOK"
```

### GET /api/assets/lookup/&lt;assetnumber&gt;
**Auth:** JWT optional
**Params:** path: `assetnumber` (string)
**Purpose:** Look up an active asset by asset number (returns full type data).

```bash
curl http://localhost:5001/api/assets/lookup/0205
```

### GET /api/assets/&lt;asset_id&gt;/relationships
**Auth:** JWT optional
**Params:** path: `asset_id`
**Purpose:** Get outgoing + incoming active relationships for an asset with partner asset dicts.

```bash
curl http://localhost:5001/api/assets/42/relationships
```

### POST /api/assets/relationships
**Auth:** permission `assets.create`
**Params:** body: `sourceassetid` (req), `targetassetid` (req), `relationshiptypeid` (req), `notes`; `X-Import-Mode` honored
**Purpose:** Create relationship, then fan out across symmetric propagation rails (Dualpath); response carries `propagated` + `propagatedcount`.

```bash
curl -X POST http://localhost:5001/api/assets/relationships -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"sourceassetid":10,"targetassetid":42,"relationshiptypeid":1}'
```

### DELETE /api/assets/relationships/&lt;rel_id&gt;
**Auth:** permission `assets.delete`
**Params:** path: `rel_id`
**Purpose:** Soft-delete one relationship row (no cascade to propagated partner rows).

```bash
curl -X DELETE http://localhost:5001/api/assets/relationships/7 -H "Authorization: Bearer $TOK"
```

### GET /api/assets/map
**Auth:** JWT optional
**Params:** `assettype` (name), `subtype` (id, per-type), `businessunitid`, `statusid`, `locationid`, `search`
**Purpose:** Unified floor-map payload: all mapped assets (with type data, primary IP, dualpath collapse) plus filter option lists.

```bash
curl 'http://localhost:5001/api/assets/map?assettype=machine&statusid=1'
```

### GET /api/assets/&lt;asset_id&gt;/communications
**Auth:** JWT optional
**Params:** path: `asset_id`
**Purpose:** List active communications (IPs etc.) for an asset with `comtype_name`.

```bash
curl http://localhost:5001/api/assets/42/communications
```

### GET /api/locations/types
**Auth:** JWT optional
**Params:** `active=false` includes inactive
**Purpose:** List location types.

```bash
curl http://localhost:5001/api/locations/types
```

### POST /api/locations/types
**Auth:** admin
**Params:** body: `locationtype` (req), `description`, `color`
**Purpose:** Create location type; reactivates a soft-deleted same-name type instead of 409.

```bash
curl -X POST http://localhost:5001/api/locations/types -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"locationtype":"Operation"}'
```

### PUT /api/locations/types/&lt;type_id&gt;
**Auth:** admin
**Params:** body: `locationtype`, `description`, `color`, `isactive`
**Purpose:** Update location type (rename conflict-checked).

```bash
curl -X PUT http://localhost:5001/api/locations/types/2 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"color":"#0a0"}'
```

### DELETE /api/locations/types/&lt;type_id&gt;
**Auth:** admin
**Params:** path: `type_id`
**Purpose:** Hard-delete location type; 409 while locations use it.

```bash
curl -X DELETE http://localhost:5001/api/locations/types/2 -H "Authorization: Bearer $TOK"
```

### GET /api/locations
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `locationname` (exact), `search` (name/building ilike)
**Purpose:** List locations (paginated); exact locationname lookup for idempotent import.

```bash
curl 'http://localhost:5001/api/locations?locationname=Building%201'
```

### GET /api/locations/&lt;location_id&gt;
**Auth:** JWT optional
**Params:** path: `location_id`
**Purpose:** Get one location.

```bash
curl http://localhost:5001/api/locations/3
```

### POST /api/locations
**Auth:** admin
**Params:** body: `locationname` (req), `building`, `floor`, `room`, `description`, `locationtypeid`, `parentlocationid`, `mapimage`, `mapwidth`, `mapheight`
**Purpose:** Create location (409 on duplicate name); honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/locations -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"locationname":"Cell 12","building":"B1"}'
```

### PUT /api/locations/&lt;location_id&gt;
**Auth:** admin
**Params:** body: `locationname`, `building`, `floor`, `room`, `description`, `locationtypeid`, `parentlocationid`, `mapimage`, `mapwidth`, `mapheight`, `isactive`
**Purpose:** Update location (rename conflict-checked); honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/locations/3 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"room":"104"}'
```

### DELETE /api/locations/&lt;location_id&gt;
**Auth:** admin
**Params:** path: `location_id`
**Purpose:** Soft-delete location.

```bash
curl -X DELETE http://localhost:5001/api/locations/3 -H "Authorization: Bearer $TOK"
```

### GET /api/vendors
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `vendor` (exact), `search` (ilike)
**Purpose:** List vendors (paginated); exact vendor lookup for idempotent import.

```bash
curl 'http://localhost:5001/api/vendors?search=fanuc'
```

### GET /api/vendors/&lt;vendor_id&gt;
**Auth:** JWT optional
**Params:** path: `vendor_id`
**Purpose:** Get one vendor.

```bash
curl http://localhost:5001/api/vendors/5
```

### POST /api/vendors
**Auth:** admin
**Params:** body: `vendor` (req), `description`, `website`, `supportphone`, `supportemail`, `notes`
**Purpose:** Create vendor (409 on duplicate); honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/vendors -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"vendor":"Fanuc"}'
```

### PUT /api/vendors/&lt;vendor_id&gt;
**Auth:** admin
**Params:** body: `vendor`, `description`, `website`, `supportphone`, `supportemail`, `notes`, `isactive`
**Purpose:** Update vendor (rename conflict-checked); honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/vendors/5 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"website":"https://fanuc.com"}'
```

### DELETE /api/vendors/&lt;vendor_id&gt;
**Auth:** admin
**Params:** path: `vendor_id`
**Purpose:** Soft-delete vendor.

```bash
curl -X DELETE http://localhost:5001/api/vendors/5 -H "Authorization: Bearer $TOK"
```

### GET /api/models
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `vendor` (id), `modeltype` (id), `modelnumber` (exact), `search` (ilike)
**Purpose:** List vendor catalog models (paginated) with flattened vendor/modeltype names; exact modelnumber+vendor lookup for import.

```bash
curl 'http://localhost:5001/api/models?vendor=5&search=30i'
```

### GET /api/models/&lt;model_id&gt;
**Auth:** JWT optional
**Params:** path: `model_id`
**Purpose:** Get one model with nested vendor + modeltype dicts.

```bash
curl http://localhost:5001/api/models/12
```

### POST /api/models
**Auth:** admin
**Params:** body: `modelnumber` (req), `vendorid`, `modeltypeid`, `description`, `imageurl`, `documentationurl`, `notes`
**Purpose:** Create model (409 on duplicate modelnumber+vendorid); honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/models -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"modelnumber":"R-30iB","vendorid":5}'
```

### PUT /api/models/&lt;model_id&gt;
**Auth:** admin
**Params:** body: `modelnumber`, `vendorid`, `modeltypeid`, `description`, `imageurl`, `documentationurl`, `notes`, `isactive`
**Purpose:** Update model; honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/models/12 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"notes":"EOL 2027"}'
```

### DELETE /api/models/&lt;model_id&gt;
**Auth:** admin
**Params:** path: `model_id`
**Purpose:** Soft-delete model.

```bash
curl -X DELETE http://localhost:5001/api/models/12 -H "Authorization: Bearer $TOK"
```

### POST /api/models/&lt;model_id&gt;/image
**Auth:** admin
**Params:** multipart/form-data: `file=<image>` (.png/.jpg/.jpeg/.gif/.webp/.svg)
**Purpose:** Upload/replace model photo (saved as `instance/modelimages/model-<id><ext>`, one per model); sets `imageurl`.

```bash
curl -X POST http://localhost:5001/api/models/12/image -H "Authorization: Bearer $TOK" -F file=@robot.jpg
```

### GET /api/models/image/&lt;filename&gt;
**Auth:** none
**Params:** path: `filename`
**Purpose:** Serve an uploaded model image (deliberately public - asset detail pages read it without auth).

```bash
curl http://localhost:5001/api/models/image/model-12.jpg
```

### DELETE /api/models/&lt;model_id&gt;/image
**Auth:** admin
**Params:** path: `model_id`
**Purpose:** Clear `imageurl` and delete the uploaded file only if it lives under `/api/models/image/` (external URLs untouched).

```bash
curl -X DELETE http://localhost:5001/api/models/12/image -H "Authorization: Bearer $TOK"
```

### GET /api/modeltypes
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `category`, `modeltype` (exact), `search` (ilike)
**Purpose:** List model types (types the vendor MODELS catalog, not machines); exact modeltype lookup for import.

```bash
curl 'http://localhost:5001/api/modeltypes?category=Equipment'
```

### GET /api/modeltypes/&lt;type_id&gt;
**Auth:** JWT optional
**Params:** path: `type_id`
**Purpose:** Get one model type.

```bash
curl http://localhost:5001/api/modeltypes/2
```

### POST /api/modeltypes
**Auth:** admin
**Params:** body: `modeltype` (req), `category`, `description`, `icon`
**Purpose:** Create model type (409 on duplicate); category defaults to Equipment; honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/modeltypes -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"modeltype":"Controller"}'
```

### PUT /api/modeltypes/&lt;type_id&gt;
**Auth:** admin
**Params:** body: `modeltype`, `category`, `description`, `icon`, `isactive`
**Purpose:** Update model type (rename conflict-checked); honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/modeltypes/2 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"icon":"mdi-chip"}'
```

### DELETE /api/modeltypes/&lt;type_id&gt;
**Auth:** admin
**Params:** path: `type_id`
**Purpose:** Soft-delete model type; 409 while models use it.

```bash
curl -X DELETE http://localhost:5001/api/modeltypes/2 -H "Authorization: Bearer $TOK"
```

### GET /api/businessunits
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `businessunit` (exact), `search` (name/code ilike)
**Purpose:** List business units (paginated); exact businessunit lookup for import.

```bash
curl http://localhost:5001/api/businessunits
```

### GET /api/businessunits/&lt;bu_id&gt;
**Auth:** JWT optional
**Params:** path: `bu_id`
**Purpose:** Get one business unit with parent + children.

```bash
curl http://localhost:5001/api/businessunits/1
```

### POST /api/businessunits
**Auth:** admin
**Params:** body: `businessunit` (req), `code`, `description`, `parentid`
**Purpose:** Create business unit (409 on duplicate); honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/businessunits -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"businessunit":"Blades","code":"BLD"}'
```

### PUT /api/businessunits/&lt;bu_id&gt;
**Auth:** admin
**Params:** body: `businessunit`, `code`, `description`, `parentid`, `isactive`
**Purpose:** Update business unit (rename conflict-checked); honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/businessunits/1 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"code":"BL"}'
```

### DELETE /api/businessunits/&lt;bu_id&gt;
**Auth:** admin
**Params:** path: `bu_id`
**Purpose:** Soft-delete business unit.

```bash
curl -X DELETE http://localhost:5001/api/businessunits/1 -H "Authorization: Bearer $TOK"
```

### GET /api/operatingsystems
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `osname` (exact), `osversion` (exact), `search` (osname ilike)
**Purpose:** List operating systems (paginated); exact osname/osversion lookup for import.

```bash
curl 'http://localhost:5001/api/operatingsystems?osname=Windows%2011'
```

### GET /api/operatingsystems/&lt;os_id&gt;
**Auth:** JWT optional
**Params:** path: `os_id`
**Purpose:** Get one operating system.

```bash
curl http://localhost:5001/api/operatingsystems/4
```

### POST /api/operatingsystems
**Auth:** admin
**Params:** body: `osname` (req), `osversion`, `architecture`, `endoflife`
**Purpose:** Create OS (409 on duplicate osname+osversion); honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/operatingsystems -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"osname":"Windows 11","osversion":"24H2"}'
```

### PUT /api/operatingsystems/&lt;os_id&gt;
**Auth:** admin
**Params:** body: `osname`, `osversion`, `architecture`, `endoflife`, `isactive`
**Purpose:** Update OS; honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/operatingsystems/4 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"endoflife":"2031-10-14"}'
```

### DELETE /api/operatingsystems/&lt;os_id&gt;
**Auth:** admin
**Params:** path: `os_id`
**Purpose:** Soft-delete OS.

```bash
curl -X DELETE http://localhost:5001/api/operatingsystems/4 -H "Authorization: Bearer $TOK"
```

### GET /api/customfields
**Auth:** JWT optional
**Params:** `assettypeid` (int), `active=false` includes inactive
**Purpose:** List custom-field definitions, ordered by sortorder.

```bash
curl 'http://localhost:5001/api/customfields?assettypeid=1'
```

### POST /api/customfields
**Auth:** admin
**Params:** body: `assettypeid` (req), `label` (req), `datatype` (one of CUSTOM_FIELD_DATATYPES, default text), `fieldkey`, `options` (list or newline/comma text), `showondetail`, `showonform`, `searchable`, `sortorder`
**Purpose:** Create field definition; fieldkey auto-slugged from label; reactivates a soft-deleted same-key field instead of 409.

```bash
curl -X POST http://localhost:5001/api/customfields -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"assettypeid":1,"label":"Coolant Type","datatype":"select","options":["Oil","Water"]}'
```

### PUT /api/customfields/&lt;fieldid&gt;
**Auth:** admin
**Params:** body: `label`, `datatype`, `options`, `showondetail`, `showonform`, `isactive`, `searchable`, `sortorder`
**Purpose:** Update field definition (label/datatype/options/flags/sortorder).

```bash
curl -X PUT http://localhost:5001/api/customfields/7 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"searchable":true}'
```

### DELETE /api/customfields/&lt;fieldid&gt;
**Auth:** admin
**Params:** path: `fieldid`
**Purpose:** Hard-delete field definition AND all stored values for it.

```bash
curl -X DELETE http://localhost:5001/api/customfields/7 -H "Authorization: Bearer $TOK"
```

### GET /api/customfields/asset/&lt;assetid&gt;
**Auth:** JWT optional
**Params:** path: `assetid`
**Purpose:** Active field defs for the asset's type merged with the asset's stored values.

```bash
curl http://localhost:5001/api/customfields/asset/42
```

### PUT /api/customfields/asset/&lt;assetid&gt;
**Auth:** admin
**Params:** body: `{values: {fieldid: value, ...}}`
**Purpose:** Upsert per-asset values; empty string clears a value; only fields of the asset's type accepted.

```bash
curl -X PUT http://localhost:5001/api/customfields/asset/42 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"values":{"7":"Oil"}}'
```

### GET /api/supportteams
**Auth:** JWT optional
**Params:** `active`, `teamname` (exact)
**Purpose:** List support teams with contacts; exact teamname lookup for import (not paginated).

```bash
curl http://localhost:5001/api/supportteams
```

### GET /api/supportteams/&lt;team_id&gt;
**Auth:** JWT optional
**Params:** path: `team_id`
**Purpose:** Get one support team with contacts.

```bash
curl http://localhost:5001/api/supportteams/2
```

### POST /api/supportteams
**Auth:** admin
**Params:** body: `teamname` (req), `teamurl`, `webhookurl`, `isactive`
**Purpose:** Create support team (409 on duplicate); audit-logged; honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/supportteams -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"teamname":"CNC Support"}'
```

### PUT /api/supportteams/&lt;team_id&gt;
**Auth:** admin
**Params:** body: `teamname`, `teamurl`, `webhookurl`, `isactive`
**Purpose:** Update support team (rename conflict-checked); audit-logged; honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/supportteams/2 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"webhookurl":"https://hooks/x"}'
```

### DELETE /api/supportteams/&lt;team_id&gt;
**Auth:** admin
**Params:** path: `team_id`
**Purpose:** Hard-delete team (cascade removes contacts); 409 while applications reference it; audit-logged.

```bash
curl -X DELETE http://localhost:5001/api/supportteams/2 -H "Authorization: Bearer $TOK"
```

### POST /api/supportteams/&lt;team_id&gt;/contacts
**Auth:** admin
**Params:** body: `name` (req), `sso`, `sortorder`, `isactive`
**Purpose:** Add contact to a team; audit-logged; honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/supportteams/2/contacts -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"name":"Jane Doe","sso":"212345678"}'
```

### PUT /api/supportteams/&lt;team_id&gt;/contacts/&lt;contact_id&gt;
**Auth:** admin
**Params:** body: `name`, `sso`, `sortorder`, `isactive`
**Purpose:** Update a team contact; audit-logged; honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/supportteams/2/contacts/9 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"sortorder":1}'
```

### DELETE /api/supportteams/&lt;team_id&gt;/contacts/&lt;contact_id&gt;
**Auth:** admin
**Params:** path: `team_id`, `contact_id`
**Purpose:** Hard-delete a team contact; audit-logged.

```bash
curl -X DELETE http://localhost:5001/api/supportteams/2/contacts/9 -H "Authorization: Bearer $TOK"
```

### GET /api/applications
**Auth:** JWT optional
**Params:** `page`, `per_page`, `active`, `showhidden`, `installable` (true/false), `appname` (exact), `search` (name/description ilike)
**Purpose:** List applications (paginated) with installedcount; hidden apps excluded unless `showhidden=true`; exact appname lookup for import.

```bash
curl 'http://localhost:5001/api/applications?installable=true'
```

### GET /api/applications/&lt;app_id&gt;
**Auth:** JWT optional
**Params:** path: `app_id`
**Purpose:** Get one application with active versions, installedcount, and linked KB articles.

```bash
curl http://localhost:5001/api/applications/15
```

### POST /api/applications
**Auth:** permission `applications.create`
**Params:** body: `appname` (req), `appdescription`, `supportteamid`, `isinstallable`, `applicationnotes`, `installpath`, `applicationlink`, `documentationpath`, `ishidden`, `isprinter`, `islicenced`, `isrequired`, `image`
**Purpose:** Create application (409 on duplicate name); audit-logged; honors `X-Import-Mode`.

```bash
curl -X POST http://localhost:5001/api/applications -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"appname":"PC-DMIS","isinstallable":true}'
```

### PUT /api/applications/&lt;app_id&gt;
**Auth:** permission `applications.edit`
**Params:** body: any of `appname`, `appdescription`, `supportteamid`, `isinstallable`, `applicationnotes`, `installpath`, `applicationlink`, `documentationpath`, `ishidden`, `isprinter`, `islicenced`, `isrequired`, `image`, `isactive`
**Purpose:** Update application (rename conflict-checked); field-level change diff audit-logged; honors `X-Import-Mode`.

```bash
curl -X PUT http://localhost:5001/api/applications/15 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"supportteamid":2}'
```

### DELETE /api/applications/&lt;app_id&gt;
**Auth:** permission `applications.delete`
**Params:** path: `app_id`
**Purpose:** Soft-delete application; audit-logged.

```bash
curl -X DELETE http://localhost:5001/api/applications/15 -H "Authorization: Bearer $TOK"
```

### GET /api/applications/&lt;app_id&gt;/versions
**Auth:** JWT optional
**Params:** path: `app_id`
**Purpose:** List active versions of an application (desc).

```bash
curl http://localhost:5001/api/applications/15/versions
```

### POST /api/applications/&lt;app_id&gt;/versions
**Auth:** permission `applications.create`
**Params:** body: `version` (req), `releasedate`, `notes`
**Purpose:** Create app version (409 on duplicate version per app); honors `X-Import-Mode` for legacy dates.

```bash
curl -X POST http://localhost:5001/api/applications/15/versions -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"version":"2024.2"}'
```

### GET /api/applications/&lt;app_id&gt;/installed
**Auth:** JWT optional
**Params:** path: `app_id`
**Purpose:** List computers with this app installed (503 if computers plugin absent).

```bash
curl http://localhost:5001/api/applications/15/installed
```

### GET /api/applications/machines/&lt;machine_id&gt;
**Auth:** JWT optional
**Params:** path: `machine_id`
**Purpose:** List apps installed on a computer (machine_id is a computerid; 503 without computers plugin).

```bash
curl http://localhost:5001/api/applications/machines/8
```

### POST /api/applications/machines/&lt;machine_id&gt;
**Auth:** permission `applications.create`
**Params:** body: `appid` (req), `appversionid`
**Purpose:** Install an app on a computer; reactivates a prior soft-deleted install; 409 if already installed.

```bash
curl -X POST http://localhost:5001/api/applications/machines/8 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"appid":15}'
```

### DELETE /api/applications/machines/&lt;machine_id&gt;/&lt;app_id&gt;
**Auth:** permission `applications.delete`
**Params:** path: `machine_id`, `app_id`
**Purpose:** Uninstall (soft-delete install row) an app from a computer.

```bash
curl -X DELETE http://localhost:5001/api/applications/machines/8/15 -H "Authorization: Bearer $TOK"
```

### PUT /api/applications/machines/&lt;machine_id&gt;/&lt;app_id&gt;
**Auth:** permission `applications.edit`
**Params:** body: `appversionid`
**Purpose:** Update an installed-app row (change appversionid).

```bash
curl -X PUT http://localhost:5001/api/applications/machines/8/15 -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"appversionid":3}'
```

## core-platform

### GET /api/reports
**Auth:** jwt-optional
**Params:** none
**Purpose:** List all available reports (6 core cards plus cards contributed by enabled plugins via the get_reports hook).

```bash
curl http://localhost:5001/api/reports
```

### GET /api/reports/machines-by-type
**Auth:** jwt-optional
**Params:** `businessunitid` (int filter), `format=json|csv`
**Purpose:** Machine count grouped by machine type (dualpath secondary bays collapsed when the site setting is on); 404 if machines plugin absent.

```bash
curl 'http://localhost:5001/api/reports/machines-by-type?businessunitid=2&format=csv'
```

### GET /api/reports/assets-by-status
**Auth:** jwt-optional
**Params:** `assettypeid`, `businessunitid`, `format=json|csv`
**Purpose:** Asset count grouped by status with status colors.

```bash
curl 'http://localhost:5001/api/reports/assets-by-status?assettypeid=1'
```

### GET /api/reports/kb-popularity
**Auth:** jwt-optional
**Params:** `limit` (default 20, max 100), `format=json|csv`
**Purpose:** Most-clicked knowledge base articles; 503 if knowledgebase plugin absent.

```bash
curl 'http://localhost:5001/api/reports/kb-popularity?limit=10'
```

### GET /api/reports/software-compliance
**Auth:** jwt-optional
**Params:** `appid` (filter to one app), `format=json|csv`
**Purpose:** Required applications vs installed per PC with compliance rate and up to 100 non-compliant PCs per app; 503 if computers plugin absent.

```bash
curl 'http://localhost:5001/api/reports/software-compliance?appid=5'
```

### GET /api/reports/asset-inventory
**Auth:** jwt-optional
**Params:** `businessunitid`, `locationid`, `format=json|csv`
**Purpose:** Complete asset inventory summary broken down by type, status, and location.

```bash
curl 'http://localhost:5001/api/reports/asset-inventory?locationid=3&format=csv'
```

### GET /api/reports/pc-relationships
**Auth:** jwt-optional
**Params:** `format=json|csv`
**Purpose:** PC-to-shop-floor-machine relationships (machine number, vendor, model, hostname, IP) matched in both edge directions via raw SQL UNION.

```bash
curl 'http://localhost:5001/api/reports/pc-relationships?format=csv'
```

### POST /api/reports/email
**Auth:** jwt + permission:reports.export
**Params:** body: `subject`, `columns` `[{key,label}]`, `rows` `[{..}]`, `intro` (optional), `to` (optional email)
**Purpose:** Email a report's rows as an HTML table on demand; recipient defaults to the site Alert Recipients setting; intended cron target via a PAT scoped to reports.export.

```bash
curl -X POST http://localhost:5001/api/reports/email -H 'Authorization: Bearer $PAT' -H 'Content-Type: application/json' -d '{"subject":"Warranty Report","columns":[{"key":"vendor","label":"Vendor"}],"rows":[{"vendor":"Haas"}],"to":"ops@example.com"}'
```

### GET /api/search
**Auth:** jwt-optional
**Params:** `q` (required, 2-200 chars)
**Purpose:** Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via `search_<type>_enabled` settings.

```bash
curl 'http://localhost:5001/api/search?q=tsgwp00525'
```

### GET /api/dashboard
**Auth:** jwt-optional
**Params:** none
**Purpose:** Dashboard summary: asset counts by type (machines/PCs/network/printers/measuring tools, dualpath-collapsed), counts by status, 10 most recent assets.

```bash
curl http://localhost:5001/api/dashboard
```

### GET /api/dashboard/summary
**Auth:** jwt-optional
**Params:** none
**Purpose:** Alias route for the same dashboard summary handler as GET /api/dashboard.

```bash
curl http://localhost:5001/api/dashboard/summary
```

### GET /api/dashboard/stats
**Auth:** jwt-optional
**Params:** none
**Purpose:** Asset counts grouped by every asset type with display category labels.

```bash
curl http://localhost:5001/api/dashboard/stats
```

### GET /api/dashboard/navigation
**Auth:** none
**Params:** none
**Purpose:** Sidebar navigation items: core entries merged with get_navigation_items from every enabled plugin, sorted by position.

```bash
curl http://localhost:5001/api/dashboard/navigation
```

### GET /api/dashboard/widgets
**Auth:** jwt-optional
**Params:** none
**Purpose:** Dashboard widget definitions aggregated from enabled plugins (get_dashboard_widgets hook), sorted by position.

```bash
curl http://localhost:5001/api/dashboard/widgets
```

### GET /api/dashboard/health
**Auth:** none
**Params:** none
**Purpose:** Health check: runs SELECT 1 against the DB, returns ok/degraded plus app version.

```bash
curl http://localhost:5001/api/dashboard/health
```

### GET /api/dashboarddefaults/visitor-location
**Auth:** none
**Params:** `fqdn` (optional), `ipaddress` (optional, defaults to client IP)
**Purpose:** Resolve the business unit for a kiosk/lobby display by FQDN first then IP (caller IP used when ipaddress omitted); null businessunitid when unmapped.

```bash
curl 'http://localhost:5001/api/dashboarddefaults/visitor-location?fqdn=display01.wjs.geaerospace.net'
```

### GET /api/dashboarddefaults/display-role
**Auth:** none
**Params:** `fqdn` (optional), `ipaddress` (optional, defaults to client IP)
**Purpose:** Resolve what a display PC should show at boot: role (dashboard/lobby/partskiosk), frontend path, and business unit; null role when unmapped.

```bash
curl 'http://localhost:5001/api/dashboarddefaults/display-role?ipaddress=10.1.2.3'
```

### GET /api/dashboarddefaults
**Auth:** jwt-optional
**Params:** none
**Purpose:** List all active display-to-business-unit mappings.

```bash
curl http://localhost:5001/api/dashboarddefaults
```

### POST /api/dashboarddefaults
**Auth:** jwt + role:admin
**Params:** body: `fqdn`, `ipaddress`, `displayrole` (dashboard|lobby|partskiosk, default dashboard), `businessunitid`, `description`
**Purpose:** Create a display mapping; requires fqdn or ipaddress, dashboard role requires businessunitid, 409 on duplicate fqdn/IP; audit-logged.

```bash
curl -X POST http://localhost:5001/api/dashboarddefaults -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"ipaddress":"10.1.2.3","displayrole":"dashboard","businessunitid":2}'
```

### PUT /api/dashboarddefaults/&lt;int:default_id&gt;
**Auth:** jwt + role:admin
**Params:** body: any of `fqdn`, `ipaddress`, `displayrole`, `businessunitid`, `description`
**Purpose:** Update a display mapping; non-dashboard roles get businessunitid nulled, dashboard role must keep one.

```bash
curl -X PUT http://localhost:5001/api/dashboarddefaults/7 -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"displayrole":"lobby"}'
```

### DELETE /api/dashboarddefaults/&lt;int:default_id&gt;
**Auth:** jwt + role:admin
**Params:** none
**Purpose:** Soft-delete (deactivate) a display mapping.

```bash
curl -X DELETE http://localhost:5001/api/dashboarddefaults/7 -H 'Authorization: Bearer $JWT'
```

### POST /api/collector/&lt;pluginname&gt;
**Auth:** api-key (X-API-Key: per-plugin `COLLECTOR_API_KEY_<NAME>` or shared `COLLECTOR_API_KEY`, or a collector.ingest-scoped managed PAT via Bearer/X-API-Key)
**Params:** body: JSON payload whose schema identityfield (e.g. hostname) is required; rest is plugin-defined
**Purpose:** Generic collector ingest (ADR-006): schema-validated identity field, idempotent upsert via the plugin's apply_collector_payload; audit-logged; 404 when no collector registered for the plugin.

```bash
curl -X POST http://localhost:5001/api/collector/computers -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{"hostname":"tsgwp00525","serialnumber":"ABC123"}'
```

### GET /api/collector/_schemas
**Auth:** jwt
**Params:** none
**Purpose:** List collector schemas for all enabled plugins that accept collector input.

```bash
curl http://localhost:5001/api/collector/_schemas -H 'Authorization: Bearer $JWT'
```

### POST /api/collector/pc
**Auth:** api-key
**Params:** body: `hostname` (required), `lastboottime` (ISO), `currentuser`, `serialnumber`
**Purpose:** Legacy computers-specific ingest: update one PC matched by hostname (or asset number) - lastreporteddate, lastboottime, loggedinuser, serialnumber.

```bash
curl -X POST http://localhost:5001/api/collector/pc -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{"hostname":"tsgwp00525","currentuser":"212345678"}'
```

### POST /api/collector/apps
**Auth:** api-key
**Params:** body: `hostname` (required), `apps` `[{appname, version}]` (required)
**Purpose:** Update installed applications for one PC; only apps already in the Application table are tracked, others skipped; returns created/updated/skipped counts.

```bash
curl -X POST http://localhost:5001/api/collector/apps -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{"hostname":"tsgwp00525","apps":[{"appname":"PC-DMIS","version":"2023.2"}]}'
```

### POST /api/collector/heartbeat
**Auth:** api-key
**Params:** body: `hostname` (string) or `hostnames` (array)
**Purpose:** Record PC online heartbeat (single hostname or batch); stamps lastreporteddate, returns updated count and notfound list.

```bash
curl -X POST http://localhost:5001/api/collector/heartbeat -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{"hostnames":["pc1","pc2"]}'
```

### POST /api/collector/bulk
**Auth:** api-key
**Params:** body: `pcs` `[{hostname (required), currentuser, lastboottime}]`
**Purpose:** Bulk update many PCs in one call (lastreporteddate, currentuser, lastboottime per entry); returns updated/notfound/errors.

```bash
curl -X POST http://localhost:5001/api/collector/bulk -H 'X-API-Key: $KEY' -H 'Content-Type: application/json' -d '{"pcs":[{"hostname":"pc1","currentuser":"212345678"}]}'
```

### GET /api/collector/status
**Auth:** api-key
**Params:** none
**Purpose:** Collector API liveness/credential check; returns timestamp and the collector endpoint list.

```bash
curl http://localhost:5001/api/collector/status -H 'X-API-Key: $KEY'
```

### GET /api/auditlogs
**Auth:** jwt + permission:audit.view
**Params:** `page` (default 1), `perpage` (default 50, max 200), `action` (created|updated|deleted), `entitytype`, `userid` (int), `search` (entityname/username ilike), `from_date`, `to_date` (ISO)
**Purpose:** List audit logs with filtering and pagination, newest first; rows enriched with best-effort SSO-to-full-name resolution.

```bash
curl 'http://localhost:5001/api/auditlogs?action=deleted&perpage=100' -H 'Authorization: Bearer $JWT'
```

### GET /api/auditlogs/entity/&lt;entitytype&gt;/&lt;int:entityid&gt;
**Auth:** jwt + permission:audit.view
**Params:** path only
**Purpose:** Full audit history for one entity, newest first.

```bash
curl http://localhost:5001/api/auditlogs/entity/Asset/42 -H 'Authorization: Bearer $JWT'
```

### GET /api/auditlogs/stats
**Auth:** jwt + permission:audit.view
**Params:** none
**Purpose:** Audit statistics: counts by action and entity type, last-7-days activity count, top 5 most active users.

```bash
curl http://localhost:5001/api/auditlogs/stats -H 'Authorization: Bearer $JWT'
```

### GET /api/plugins
**Auth:** jwt-optional
**Params:** none
**Purpose:** List all discovered plugins (enabled or not) with metadata plus the framework contract version.

```bash
curl http://localhost:5001/api/plugins
```

### GET /api/plugins/enabled
**Auth:** none (jwt-optional decorator, no claims used)
**Params:** none
**Purpose:** Flat sorted array of enabled plugin names (registry read, no DB); deliberately anonymous so kiosk routes can gate plugin-owned frontend routes.

```bash
curl http://localhost:5001/api/plugins/enabled
```

### PUT /api/plugins/&lt;name&gt;
**Auth:** jwt + role:admin
**Params:** body: `enabled` (bool, required)
**Purpose:** Enable or disable a plugin (route changes need an app restart); 409 when unknown or a dependency conflict refuses the change.

```bash
curl -X PUT http://localhost:5001/api/plugins/warranty -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"enabled":false}'
```

### GET /api/pluginui/settings-cards
**Auth:** jwt-optional
**Params:** none
**Purpose:** Merge enabled plugins' settings-catalog cards (get_settings_cards hook) sorted by position for the settings rail/overview.

```bash
curl http://localhost:5001/api/pluginui/settings-cards
```

### GET /api/pluginui/asset-panels
**Auth:** jwt-optional
**Params:** `assetid` (int, required); 400 without it, 404 if asset missing
**Purpose:** Asset-detail panels from enabled plugins (get_asset_panels hook) matching one asset's type ('*' wildcard supported), sorted by position.

```bash
curl 'http://localhost:5001/api/pluginui/asset-panels?assetid=42'
```

### GET /api/pluginui/map-overlays
**Auth:** jwt-optional
**Params:** none
**Purpose:** Merge enabled plugins' map overlay declarations (get_map_overlays hook), sorted by position.

```bash
curl http://localhost:5001/api/pluginui/map-overlays
```

### GET /api/pluginui/asset-presentation
**Auth:** jwt-optional
**Params:** none
**Purpose:** Merge enabled plugins' asset-type presentation entries (icon + detail route per type) used by search rows and cross-links.

```bash
curl http://localhost:5001/api/pluginui/asset-presentation
```

## plugin-computers

### GET /api/computers/types
**Auth:** jwt-optional
**Params:** `page`, `per_page`, `active` (default true; `'false'` includes inactive), `search` (ilike on computertype)
**Purpose:** List computer types, paginated, active-only by default.
```bash
curl -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/computers/types?search=kiosk&per_page=50'
```

### GET /api/computers/types/&lt;int:type_id&gt;
**Auth:** jwt-optional
**Params:** `type_id` in path
**Purpose:** Get a single computer type by ID.
```bash
curl 'http://localhost:5001/api/computers/types/3'
```

### POST /api/computers/types
**Auth:** permission `computers.create` (jwt_required)
**Params:** body: `computertype` (required), `description`, `icon`, `color`. Matching a deactivated type revives it instead of 409.
**Purpose:** Create (or reactivate) a computer type.
```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"computertype":"Shopfloor","color":"#0066cc"}' 'http://localhost:5001/api/computers/types'
```

### PUT /api/computers/types/&lt;int:type_id&gt;
**Auth:** permission `computers.edit` (jwt_required)
**Params:** body: `computertype`, `description`, `icon`, `color`, `isactive`. 409 on duplicate name.
**Purpose:** Update a computer type.
```bash
curl -X PUT -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"isactive":false}' 'http://localhost:5001/api/computers/types/3'
```

### DELETE /api/computers/types/&lt;int:type_id&gt;
**Auth:** permission `computers.delete` (jwt_required)
**Params:** `type_id` in path. 409 if any Computer still uses the type.
**Purpose:** Hard-delete a computer type when unused.
```bash
curl -X DELETE -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/computers/types/3'
```

### GET /api/computers/protocols
**Auth:** jwt-optional
**Params:** `active` (default true; `'false'` includes disabled)
**Purpose:** List remote-access protocols (VNC/WinRM/RDP catalog), unpaginated.
```bash
curl 'http://localhost:5001/api/computers/protocols?active=false'
```

### POST /api/computers/protocols
**Auth:** permission `computers.edit` (jwt_required)
**Params:** body: `name`, `scheme`, `linktemplate` (all required), `defaultport`, `isactive`. 409 on duplicate name.
**Purpose:** Create an access protocol.
```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"name":"VNC","scheme":"vnc","defaultport":5900,"linktemplate":"vnc://{host}:{port}"}' 'http://localhost:5001/api/computers/protocols'
```

### PUT|PATCH /api/computers/protocols/&lt;int:protocol_id&gt;
**Auth:** permission `computers.edit` (jwt_required)
**Params:** body: `name`, `scheme`, `linktemplate`, `defaultport`, `isactive` (all optional)
**Purpose:** Update an access protocol.
```bash
curl -X PATCH -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"defaultport":5901}' 'http://localhost:5001/api/computers/protocols/2'
```

### DELETE /api/computers/protocols/&lt;int:protocol_id&gt;
**Auth:** permission `computers.edit` (jwt_required)
**Params:** `protocol_id` in path. If referenced by any ComputerAccess it deactivates instead of deleting.
**Purpose:** Delete an access protocol (soft-deactivate when in use).
```bash
curl -X DELETE -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/computers/protocols/2'
```

### GET /api/computers/display-kiosks
**Auth:** jwt-optional
**Params:** none. Uses pctype mapping for `gea-shopfloor-display` (default "Kiosk") and the `display_fqdn_domain` setting.
**Purpose:** List display-kiosk computers with derived `F<serial>.<domain>` FQDN for the Dashboard Defaults picker.
```bash
curl -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/computers/display-kiosks'
```

### GET /api/computers
**Auth:** jwt-optional
**Params:** `page`, `per_page`, `active`, `search` (assetnumber/name/serialnumber/hostname ilike), `assetnumber` (exact, for idempotent import), `typeid`|`type_id`, `osid`|`os_id`, `locationid`|`location_id`, `businessunitid`|`businessunit_id`, `shopfloor` (true/false), `sort` (hostname|assetnumber|name|lastreporteddate), `dir` (asc|desc)
**Purpose:** List computers (asset + computer + resolved access links), filtered and paginated.
```bash
curl 'http://localhost:5001/api/computers?shopfloor=true&sort=lastreporteddate&dir=desc&per_page=25'
```

### GET /api/computers/&lt;int:computer_id&gt;
**Auth:** jwt-optional
**Params:** `computer_id` in path
**Purpose:** Get one computer with full detail: asset fields, computer extension, communications, resolved access links.
```bash
curl 'http://localhost:5001/api/computers/42'
```

### GET /api/computers/by-asset/&lt;int:asset_id&gt;
**Auth:** jwt-optional
**Params:** `asset_id` in path
**Purpose:** Get computer record by its core asset ID.
```bash
curl 'http://localhost:5001/api/computers/by-asset/1234'
```

### GET /api/computers/by-hostname/&lt;hostname&gt;
**Auth:** jwt-optional
**Params:** `hostname` in path (exact match)
**Purpose:** Get computer record by hostname.
```bash
curl 'http://localhost:5001/api/computers/by-hostname/tsgwp00525'
```

### POST /api/computers
**Auth:** permission `computers.create` (jwt_required)
**Params:** body: `assetnumber` (required); `name`, `serialnumber`, `gaugelabreference`, `maintenancereference`, `statusid`, `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `computertypeid`, `hostname`, `osid`, `vendorid`, `modelnumberid`, `loggedinuser`, `lastreporteddate`, `lastboottime`, `ipaddress` (creates primary IP communication), `accessmethods` `[{protocolid, portoverride?}]`. `X-Import-Mode` header preserves legacy timestamps. 409 on duplicate assetnumber or hostname.
**Purpose:** Create a computer (Asset + Computer records, optional primary IP and access methods), audit-logged.
```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"assetnumber":"PC-0042","hostname":"tsgwp00042","computertypeid":1,"osid":2,"ipaddress":"10.1.2.3","accessmethods":[{"protocolid":1}]}' 'http://localhost:5001/api/computers'
```

### PUT /api/computers/&lt;int:computer_id&gt;
**Auth:** permission `computers.edit` (jwt_required)
**Params:** body: any asset field (`assetnumber`, `name`, `serialnumber`, `gaugelabreference`, `maintenancereference`, `statusid`, `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `isactive`) or computer field (`computertypeid`, `hostname`, `osid`, `vendorid`, `modelnumberid`, `loggedinuser`, `lastreporteddate`, `lastboottime`). `ipaddress` upserts/clears the primary IP communication; `accessmethods` replaces the protocol list. 409 on assetnumber/hostname conflict. Changes audit-logged.
**Purpose:** Update a computer's asset and extension fields.
```bash
curl -X PUT -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"locationid":7,"ipaddress":"10.1.2.4"}' 'http://localhost:5001/api/computers/42'
```

### DELETE /api/computers/&lt;int:computer_id&gt;
**Auth:** permission `computers.delete` (jwt_required)
**Params:** `computer_id` in path
**Purpose:** Soft-delete a computer (sets asset `isactive=false`), audit-logged.
```bash
curl -X DELETE -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/computers/42'
```

### GET /api/computers/&lt;int:computer_id&gt;/apps
**Auth:** jwt-optional
**Params:** `computer_id` in path. Returns active installs only.
**Purpose:** List installed applications on a computer.
```bash
curl 'http://localhost:5001/api/computers/42/apps'
```

### POST /api/computers/&lt;int:computer_id&gt;/apps
**Auth:** permission `computers.create` (jwt_required)
**Params:** body: `appid` (required, must exist in Applications), `appversionid`. Reactivates a soft-deleted install; 409 if already installed.
**Purpose:** Record an application install on a computer.
```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"appid":17,"appversionid":3}' 'http://localhost:5001/api/computers/42/apps'
```

### DELETE /api/computers/&lt;int:computer_id&gt;/apps/&lt;int:app_id&gt;
**Auth:** permission `computers.delete` (jwt_required)
**Params:** `computer_id` and `app_id` in path
**Purpose:** Soft-remove an installed application (`isactive=false`).
```bash
curl -X DELETE -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/computers/42/apps/17'
```

### POST /api/computers/&lt;int:computer_id&gt;/report
**Auth:** permission `computers.create` (jwt_required)
**Params:** body (all optional): `loggedinuser`, `lastboottime`. Server sets `lastreporteddate` to now (UTC).
**Purpose:** Agent status check-in: refresh last-reported timestamp plus logged-in user and boot time.
```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"loggedinuser":"cproudlock","lastboottime":"2026-07-30T06:00:00"}' 'http://localhost:5001/api/computers/42/report'
```

### GET /api/computers/dashboard/summary
**Auth:** jwt-optional
**Params:** none
**Purpose:** Dashboard counts: total active computers, breakdown by type and OS, shopfloor vs non-shopfloor.
```bash
curl 'http://localhost:5001/api/computers/dashboard/summary'
```

## plugin-employees

### GET /api/employees/search

**Auth:** none
**Params:** `q` (query string, min 2 chars, required), `limit` (max results, default 10, capped 50)
**Purpose:** Search employees by first name, last name, or SSO substring (self-hosted table or external HR DB depending on `employee_directory_mode` setting).

```bash
curl 'http://localhost:5001/api/employees/search?q=smith&limit=5'
```

### GET /api/employees/lookup/&lt;sso&gt;

**Auth:** none
**Params:** `sso` (path, numeric)
**Purpose:** Look up a single employee by numeric SSO; returns directory fields plus resolved `photourl`.

```bash
curl 'http://localhost:5001/api/employees/lookup/210009518'
```

### GET /api/employees/lookup

**Auth:** none
**Params:** `sso` (query, comma-separated numeric SSOs, at least one required)
**Purpose:** Bulk lookup of multiple employees by SSO list; returns `employees` array plus a joined names string.

```bash
curl 'http://localhost:5001/api/employees/lookup?sso=210009518,210001234'
```

### GET /api/employees/directory

**Auth:** jwt-optional
**Params:** none
**Purpose:** List the full self-hosted directory for the management page; 400 when directory mode is external.

```bash
curl -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/employees/directory'
```

### POST /api/employees/directory

**Auth:** jwt + require_role admin
**Params:** JSON body: `sso` (numeric, required), `firstname`, `lastname` (required), `team`, `role`, `picture` (also accepts external-style keys `SSO`/`First_Name`/`Last_Name`/`Team`/`Role`/`Picture`)
**Purpose:** Create a self-hosted directory employee; 409 if SSO exists, 400 in external mode.

```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"sso":210009518,"firstname":"Jane","lastname":"Doe","team":"CNC","role":"Machinist"}' 'http://localhost:5001/api/employees/directory'
```

### PUT /api/employees/directory/&lt;int:sso&gt;

**Auth:** jwt + require_role admin
**Params:** `sso` (path); JSON body: `firstname`, `lastname`, `team`, `role`, `picture` (external-style keys also accepted; `team`/`role`/`picture` can be cleared)
**Purpose:** Update a self-hosted directory employee's name/team/role/picture; 404 if missing, 400 in external mode.

```bash
curl -X PUT -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"team":"Quality"}' 'http://localhost:5001/api/employees/directory/210009518'
```

### DELETE /api/employees/directory/&lt;int:sso&gt;

**Auth:** jwt + require_role admin
**Params:** `sso` (path)
**Purpose:** Delete a self-hosted directory employee; 404 if missing, 400 in external mode.

```bash
curl -X DELETE -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/employees/directory/210009518'
```

### POST /api/employees/directory/import

**Auth:** jwt + require_role admin
**Params:** multipart `file=<csv>` OR JSON body `{"csv": "..."}`; rows missing numeric sso or names are skipped
**Purpose:** Bulk upsert the self-hosted directory from CSV (headers `SSO,First_Name,Last_Name,Team,Role,Picture` case-insensitive; plain `firstname`/`lastname` also accepted); returns added/updated/skipped counts.

```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -F 'file=@employees.csv' 'http://localhost:5001/api/employees/directory/import'
```

### POST /api/employees/&lt;int:sso&gt;/photo

**Auth:** jwt + require_role admin
**Params:** `sso` (path); multipart/form-data `file=<image>`, extensions .png/.jpg/.jpeg/.gif/.webp only
**Purpose:** Upload or replace a self-hosted employee's photo (saved as `photo-<sso><ext>` in instance employeephotos dir; old file wiped even on extension change); 409 in external mode, 404 if employee missing.

```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -F 'file=@jane.jpg' 'http://localhost:5001/api/employees/210009518/photo'
```

### GET /api/employees/photo/&lt;path:filename&gt;

**Auth:** none
**Params:** `filename` (path, e.g. `photo-210009518.jpg`)
**Purpose:** Serve an uploaded employee photo file from the instance employeephotos dir (public so kiosk recognition cards can read it).

```bash
curl 'http://localhost:5001/api/employees/photo/photo-210009518.jpg'
```

### DELETE /api/employees/&lt;int:sso&gt;/photo

**Auth:** jwt + require_role admin
**Params:** `sso` (path)
**Purpose:** Clear a self-hosted employee's photo record and delete the uploaded file; 409 in external mode, 404 if employee missing.

```bash
curl -X DELETE -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/employees/210009518/photo'
```

## plugin-geenforce

### GET /api/geenforce/manifest
**Auth:** api-key (managed service token with `geenforce.fetch` scope via `X-API-Key` or Bearer PAT) OR source IP in `geenforce_allowed_cidrs`; resource-bound tokens restricted to their listed scopes (403 otherwise)
**Params:** query: `pctype` (required, = scopename), `phase` (default `runtime`); header: `If-None-Match` for 304
**Purpose:** Serve the current PUBLISHED manifest JSON snapshot for a scope (never the live draft) to the GE-Enforce client, with ETag/304 support and `X-Manifest-Version` header.

```bash
curl -H 'X-API-Key: $TOKEN' 'http://localhost:5001/api/geenforce/manifest?pctype=cmm&phase=runtime'
```

### GET /api/geenforce/payload/&lt;sha256&gt;
**Auth:** api-key (`geenforce.fetch` service token) OR IP allowlist; resource-bound tokens get 404 for blobs not referenced by their scopes
**Params:** path: `sha256` (64 lowercase hex chars, 400 otherwise); header: `If-None-Match` (ETag = the hash)
**Purpose:** Download a payload blob by content hash (blob store first, then inline DB payload) so share-less PCs can pull installers over HTTPS; per-IP rate limited (120/60s default) and size-capped (512MB default, 413 above).

```bash
curl -H 'X-API-Key: $TOKEN' -o installer.exe 'http://localhost:5001/api/geenforce/payload/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
```

### POST /api/geenforce/report
**Auth:** api-key (managed service token with `geenforce.report` scope) OR IP allowlist
**Params:** JSON body: `hostname` (required); remainder parsed by `service.record_enforcement_report` (`scopename`, `phase`, `appliedversion`, `enforcerversion`, `status`, per-entry results, counts); 400 on ValueError
**Purpose:** Record one PC's enforcement cycle: applied manifest version plus per-entry self-heal outcomes (installed/skipped/failed); returns `reportid` + `status`.

```bash
curl -X POST -H 'X-API-Key: $TOKEN' -H 'Content-Type: application/json' -d '{"hostname":"tsgwp00525","scopename":"cmm","appliedversion":4,"results":[{"entryname":"7zip","action":"installed"}]}' http://localhost:5001/api/geenforce/report
```

### GET /api/geenforce/scopes
**Auth:** jwt + permission `geenforce.manage`
**Params:** none
**Purpose:** List all imaging PC-type scopes with entry counts and current published version numbers, ordered by phase then scopename.

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes
```

### POST /api/geenforce/scopes
**Auth:** jwt + permission `geenforce.manage`
**Params:** JSON body: `scopename` (required), `phase` (default `runtime`, must be in PHASES), `manifestversion` (default `1.0`), `description`, `computertypeid`, `measuringtooltypeid`, `iscommon` (defaults true when scopename == `common`)
**Purpose:** Create a new manifest scope; 400 if scopename missing, phase invalid, or scope already exists for that scopename+phase; returns 201 with scope summary.

```bash
curl -X POST -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"scopename":"cmm","phase":"runtime","description":"CMM bays"}' http://localhost:5001/api/geenforce/scopes
```

### GET /api/geenforce/scopes/&lt;int:scopeid&gt;
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`
**Purpose:** Get one scope's summary plus its full draft entry list (each entry includes entryid, sortorder, curated appid/appname link, and inline-payload metadata).

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes/3
```

### PUT /api/geenforce/scopes/&lt;int:scopeid&gt;
**Auth:** jwt + permission `geenforce.manage`
**Params:** JSON body (all optional): `description`, `computertypeid`, `measuringtooltypeid`, `manifestversion` (stringified), `iscommon` (bool-coerced)
**Purpose:** Update scope metadata fields (only keys present in the body are changed); scopename and phase are immutable here.

```bash
curl -X PUT -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"description":"updated","manifestversion":"1.1"}' http://localhost:5001/api/geenforce/scopes/3
```

### DELETE /api/geenforce/scopes/&lt;int:scopeid&gt;
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`
**Purpose:** Delete a scope (and via cascade its entries); returns `{deleted: scopeid}`.

```bash
curl -X DELETE -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes/3
```

### GET /api/geenforce/scopes/&lt;int:scopeid&gt;/preview
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`
**Purpose:** Render the DRAFT manifest JSON that a publish would freeze, for admin review before shipping.

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes/3/preview
```

### GET /api/geenforce/applications
**Auth:** jwt + permission `geenforce.manage`
**Params:** none
**Purpose:** List the core active Applications catalog (appid + appname) for the curated entry-to-app link picker in the entry editor.

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/applications
```

### POST /api/geenforce/scopes/&lt;int:scopeid&gt;/entries
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`; JSON body: `Name` (required), `Type` (required, one of ENTRY_TYPES), optional `appid` (curated app link, unknown/non-numeric ids ignored), plus manifest fields consumed by `build_entry` (PCTypes, TargetHostnames, DetectionValue, etc.)
**Purpose:** Create a manifest entry in a scope at the next sortorder; validates Name (required) and Type (must be in ENTRY_TYPES); 400 on duplicate Name in scope; returns 201 with entry payload.

```bash
curl -X POST -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"Name":"7-Zip","Type":"App","appid":12}' http://localhost:5001/api/geenforce/scopes/3/entries
```

### PUT /api/geenforce/entries/&lt;int:entryid&gt;
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `entryid`; JSON body: `Name` (required), `Type` (required), optional `appid`, plus manifest fields
**Purpose:** Replace an entry's fields from the payload (re-populates via `populate_entry`, re-creating the one-to-one InUseCheck); same Name/Type validation and duplicate-Name 400 as create.

```bash
curl -X PUT -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"Name":"7-Zip","Type":"App","DetectionValue":"24.08"}' http://localhost:5001/api/geenforce/entries/17
```

### DELETE /api/geenforce/entries/&lt;int:entryid&gt;
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `entryid`
**Purpose:** Delete a manifest entry; returns `{deleted: entryid}`.

```bash
curl -X DELETE -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/entries/17
```

### PUT /api/geenforce/scopes/&lt;int:scopeid&gt;/entries/reorder
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`; JSON body: `order` = [entryid, ...] (must match the scope's entry ids exactly, 400 otherwise)
**Purpose:** Set entry ordering from an entryid list.

```bash
curl -X PUT -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"order":[17,15,16]}' http://localhost:5001/api/geenforce/scopes/3/entries/reorder
```

### GET /api/geenforce/scopes/&lt;int:scopeid&gt;/simulate
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`; query (all optional): `pctype` (defaults to scopename), `subtype`, `hostname`, `machinenumber`, `cmmversion`; phase comes from the scope
**Purpose:** Simulate which draft entries would apply to a given machine profile and why the rest are filtered out (PCTypes/TargetHostnames/TargetMachineNumbers/_CmmVersion), using the engine-mirror filters.

```bash
curl -H 'Authorization: Bearer $JWT' 'http://localhost:5001/api/geenforce/scopes/3/simulate?hostname=tsgwp00525&cmmversion=2023.2'
```

### GET /api/geenforce/scopes/&lt;int:scopeid&gt;/compliance
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`
**Purpose:** Fleet install coverage per app-linked entry (installed/version-match counts from the computers plugin's ComputerInstalledApp; null counts with `computersplugin:false` when that plugin is absent).

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes/3/compliance
```

### POST /api/geenforce/entries/&lt;int:entryid&gt;/payload
**Auth:** jwt + permission `geenforce.publish`
**Params:** path: `entryid`; multipart/form-data: `file` (required)
**Purpose:** Upload an inline payload file (max 1 MB, rejects empty) for an entry and point the entry at it (stores sha256, filename, mimetype); returns 201 with entry payload.

```bash
curl -X POST -H 'Authorization: Bearer $JWT' -F 'file=@fix.ps1' http://localhost:5001/api/geenforce/entries/17/payload
```

### GET /api/geenforce/entries/&lt;int:entryid&gt;/payload
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `entryid`
**Purpose:** Download the stored inline payload bytes for an entry as an attachment (404 if the entry has no payload).

```bash
curl -H 'Authorization: Bearer $JWT' -o fix.ps1 http://localhost:5001/api/geenforce/entries/17/payload
```

### POST /api/geenforce/scopes/&lt;int:scopeid&gt;/publish
**Auth:** jwt + permission `geenforce.publish`
**Params:** path: `scopeid`; JSON body (optional): `notes`
**Purpose:** Freeze the scope's draft into a new published version (`service.publish_scope`), recording the publishing user from the JWT identity and optional notes; returns 201 with the new versionnumber.

```bash
curl -X POST -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"notes":"add 7zip 24.08"}' http://localhost:5001/api/geenforce/scopes/3/publish
```

### GET /api/geenforce/scopes/&lt;int:scopeid&gt;/versions
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`
**Purpose:** List published versions for a scope, newest first (versionnumber, iscurrent, publishedat, publishedby, notes).

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes/3/versions
```

### GET /api/geenforce/scopes/&lt;int:scopeid&gt;/versions/&lt;int:versionnumber&gt;
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `scopeid`, `versionnumber`
**Purpose:** Fetch one published version's frozen manifest JSON (parsed and returned in the success envelope).

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes/3/versions/4
```

### POST /api/geenforce/scopes/&lt;int:scopeid&gt;/rollback
**Auth:** jwt + permission `geenforce.publish`
**Params:** path: `scopeid`; JSON body: `versionnumber` (required, int)
**Purpose:** Roll the scope's current published pointer back to an earlier versionnumber (`service.rollback_scope`); 400 with the error message if the version is invalid.

```bash
curl -X POST -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"versionnumber":3}' http://localhost:5001/api/geenforce/scopes/3/rollback
```

### POST /api/geenforce/scopes/&lt;int:scopeid&gt;/export-share
**Auth:** jwt + permission `geenforce.publish`
**Params:** path: `scopeid`; no body
**Purpose:** Write the scope's current published JSON to the configured share root (`geenforce_share_root` setting), backing up the old file to `_meta/history`; 400 if the share root is unconfigured or the export fails.

```bash
curl -X POST -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/scopes/3/export-share
```

### GET /api/geenforce/config
**Auth:** jwt + permission `geenforce.manage`
**Params:** none
**Purpose:** Read plugin config: the on-share export root (`geenforce_share_root`) and the client IP allowlist CIDRs (`geenforce_allowed_cidrs`).

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/config
```

### PUT /api/geenforce/config
**Auth:** jwt + permission `geenforce.publish`
**Params:** JSON body (both optional): `shareroot` (string path), `allowedcidrs` (CSV/newline CIDR list); allowedcidrs is validated/normalized (comma/newline-separated CIDRs or bare IPs, 400 listing any bad entries); only keys present in the body are written
**Purpose:** Update plugin config settings.

```bash
curl -X PUT -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"allowedcidrs":"10.20.30.0/24, 192.168.5.7"}' http://localhost:5001/api/geenforce/config
```

### GET /api/geenforce/reports
**Auth:** jwt + permission `geenforce.manage`
**Params:** query (optional): `hostname` (ILIKE match), `scopename` (exact)
**Purpose:** Latest enforcement report per PC (iscurrent rows) for the fleet compliance view: applied vs latest published version (`receivedlatest` flag), install/skip/fail/filtered counts, status, check-in times.

```bash
curl -H 'Authorization: Bearer $JWT' 'http://localhost:5001/api/geenforce/reports?scopename=cmm'
```

### GET /api/geenforce/reports/&lt;int:reportid&gt;
**Auth:** jwt + permission `geenforce.manage`
**Params:** path: `reportid`
**Purpose:** One enforcement report in detail with per-entry outcomes (entryname, action, selfhealed, exitcode, message) plus applied-vs-latest version comparison.

```bash
curl -H 'Authorization: Bearer $JWT' http://localhost:5001/api/geenforce/reports/42
```

## plugin-knowledgebase

### GET /api/knowledgebase

**Auth:** jwt-optional
**Params:** query: `page`, `per_page`, `search` (ILIKE on shortdescription/keywords/application name), `appid` (int filter), `linkurl` (exact natural-key match), `shortdescription` (exact match), `sort` (`clicks|topic|description|lastupdated`, default `clicks`), `order` (`asc|desc`, default `desc`)
**Purpose:** List active KB articles with search, topic filter, natural-key lookup, sorting, and pagination; each item includes its linked application (appid/appname) or null.

```bash
curl 'http://localhost:5001/api/knowledgebase?search=printer&sort=clicks&order=desc&page=1&per_page=20'
```

### GET /api/knowledgebase/stats

**Auth:** jwt-optional
**Params:** none
**Purpose:** Return aggregate stats for active articles: `totalclicks` (sum of clicks) and `totalarticles` (count).

```bash
curl 'http://localhost:5001/api/knowledgebase/stats'
```

### GET /api/knowledgebase/&lt;int:link_id&gt;

**Auth:** jwt-optional
**Params:** path: `link_id` (int)
**Purpose:** Fetch a single active article by id with its application (appid/appname) or null; 404 if missing or inactive.

```bash
curl 'http://localhost:5001/api/knowledgebase/42'
```

### POST /api/knowledgebase/&lt;int:link_id&gt;/click

**Auth:** jwt-optional
**Params:** path: `link_id` (int); no body
**Purpose:** Increment the article's click counter and return the target `linkurl` plus new click count (used for redirect tracking); 404 if missing or inactive.

```bash
curl -X POST 'http://localhost:5001/api/knowledgebase/42/click'
```

### POST /api/knowledgebase

**Auth:** permission `kb.create` (jwt required)
**Params:** body JSON: `shortdescription` (required), `linkurl` (required), `appid` (optional int, must exist), `keywords` (optional); import-mode may pass timestamp fields
**Purpose:** Create a new KB article; validates required fields and that `appid` (if given) exists; honors `X-Import-Mode` timestamp preservation via `apply_import_timestamps`; returns 201.

```bash
curl -X POST 'http://localhost:5001/api/knowledgebase' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"shortdescription":"VPN setup guide","linkurl":"https://wiki.example.com/vpn","appid":3,"keywords":"vpn,remote"}'
```

### PUT /api/knowledgebase/&lt;int:link_id&gt;

**Auth:** permission `kb.edit` (jwt required)
**Params:** path: `link_id` (int); body JSON: any of `shortdescription`, `linkurl`, `appid`, `keywords`, `isactive`
**Purpose:** Update an article's shortdescription, linkurl, appid, keywords, and/or isactive; validates `appid` if changed; honors import timestamps; 404 if article missing.

```bash
curl -X PUT 'http://localhost:5001/api/knowledgebase/42' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"keywords":"vpn,zscaler","isactive":true}'
```

### DELETE /api/knowledgebase/&lt;int:link_id&gt;

**Auth:** permission `kb.delete` (jwt required)
**Params:** path: `link_id` (int)
**Purpose:** Soft-delete an article by setting `isactive=false` (row is retained); 404 if article missing.

```bash
curl -X DELETE 'http://localhost:5001/api/knowledgebase/42' -H 'Authorization: Bearer $TOKEN'
```

## plugin-machines

### GET /api/machines/types
**Auth:** jwt-optional
**Params:** query: `page`, `per_page`, `active` (pass `false` to include inactive), `search` (ilike on `machinetype`)
**Purpose:** List machine types (active by default) with pagination and name search.
```bash
curl 'http://localhost:5001/api/machines/types?search=cnc&page=1&per_page=25'
```

### GET /api/machines/types/&lt;type_id&gt;
**Auth:** jwt-optional
**Params:** path: `type_id` (int)
**Purpose:** Get a single machine type by ID.
```bash
curl 'http://localhost:5001/api/machines/types/3'
```

### POST /api/machines/types
**Auth:** jwt + permission:machines.create
**Params:** body: `machinetype` (required), `description`, `icon`, `color`
**Purpose:** Create a machine type; reactivates a soft-deleted type of the same name instead of returning 409.
```bash
curl -X POST 'http://localhost:5001/api/machines/types' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"machinetype":"Lathe","description":"Turning machines","icon":"mdi-rotate-3d","color":"#1976d2"}'
```

### PUT /api/machines/types/&lt;type_id&gt;
**Auth:** jwt + permission:machines.edit
**Params:** path: `type_id`; body: any of `machinetype`, `description`, `icon`, `color`, `isactive`
**Purpose:** Update a machine type; rename guarded by 409 on duplicate name.
```bash
curl -X PUT 'http://localhost:5001/api/machines/types/3' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"description":"5-axis mills","isactive":true}'
```

### DELETE /api/machines/types/&lt;type_id&gt;
**Auth:** jwt + permission:machines.delete
**Params:** path: `type_id` (int)
**Purpose:** Hard-delete a machine type; refused with 409 if any machine still references it.
```bash
curl -X DELETE 'http://localhost:5001/api/machines/types/3' -H 'Authorization: Bearer $TOKEN'
```

### GET /api/machines
**Auth:** jwt-optional
**Params:** query: `page`, `per_page`, `active` (`false` includes inactive), `assetnumber` (exact-match for idempotent import), `search` (assetnumber/name/serialnumber ilike), `typeid`|`type_id`, `vendorid`|`vendor_id`, `locationid`|`location_id`, `businessunitid`|`businessunit_id`, `sort` (`assetnumber`|`name`), `dir` (`asc`|`desc`)
**Purpose:** List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row (annotated with `dualpathpartner`) when the site setting is enabled.
```bash
curl 'http://localhost:5001/api/machines?search=2007&typeid=2&sort=name&dir=desc&page=1&per_page=50'
```

### GET /api/machines/&lt;machine_id&gt;
**Auth:** jwt-optional
**Params:** path: `machine_id` (int)
**Purpose:** Get one machine with full asset details, nested machine dict, and `dualpathpartner` info.
```bash
curl 'http://localhost:5001/api/machines/42'
```

### GET /api/machines/by-asset/&lt;asset_id&gt;
**Auth:** jwt-optional
**Params:** path: `asset_id` (int)
**Purpose:** Get machine data looked up by core asset ID instead of machine ID.
```bash
curl 'http://localhost:5001/api/machines/by-asset/1001'
```

### POST /api/machines
**Auth:** jwt + permission:machines.create
**Params:** body: `assetnumber` (required, 409 on duplicate); asset fields: `name`, `gaugelabreference`, `maintenancereference`, `serialnumber`, `statusid` (default 1), `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`; machine fields: `machinetypeid`, `vendorid`, `modelnumberid`, `requiresmanualconfig`, `islocationonly`, `lastmaintenancedate`, `nextmaintenancedate`, `maintenanceintervaldays`, `controllervendorid`, `controllermodelid`
**Purpose:** Create a machine (creates both core Asset row and Machine extension row); audit-logged; honors `X-Import-Mode` legacy timestamps via `apply_import_timestamps`.
```bash
curl -X POST 'http://localhost:5001/api/machines' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"assetnumber":"2007","name":"Makino a51","machinetypeid":2,"vendorid":5,"locationid":3,"statusid":1}'
```

### PUT /api/machines/&lt;machine_id&gt;
**Auth:** jwt + permission:machines.edit
**Params:** path: `machine_id`; body: any of `assetnumber`, `name`, `gaugelabreference`, `maintenancereference`, `serialnumber`, `statusid`, `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `isactive`, `machinetypeid`, `vendorid`, `modelnumberid`, `requiresmanualconfig`, `islocationonly`, `lastmaintenancedate`, `nextmaintenancedate`, `maintenanceintervaldays`, `controllervendorid`, `controllermodelid`
**Purpose:** Update machine (asset + machine fields) with per-field change tracking to AuditLog; 409 on assetnumber conflict.
```bash
curl -X PUT 'http://localhost:5001/api/machines/42' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"locationid":7,"nextmaintenancedate":"2026-09-01"}'
```

### DELETE /api/machines/&lt;machine_id&gt;
**Auth:** jwt + permission:machines.delete
**Params:** path: `machine_id` (int)
**Purpose:** Soft-delete a machine (sets `asset.isactive=false`, keeps Machine row linked); audit-logged.
```bash
curl -X DELETE 'http://localhost:5001/api/machines/42' -H 'Authorization: Bearer $TOKEN'
```

### GET /api/machines/dashboard/summary
**Auth:** jwt-optional
**Params:** none
**Purpose:** Dashboard summary: total active machine count plus counts grouped by machine type and by asset status (total and by-type exclude Dualpath secondary bays when the collapse setting is enabled; by-status does not).
```bash
curl 'http://localhost:5001/api/machines/dashboard/summary'
```

## plugin-measuringtools

### GET /api/measuringtools/types
**Auth:** jwt-optional
**Params:** query: `active` (default true; `'false'` includes inactive), `search` (name ilike), `page`, `perpage`
**Purpose:** List measuring-tool types (active-only by default), paginated, name-sorted.

```bash
curl 'http://localhost:5001/api/measuringtools/types?search=caliper&page=1&perpage=25'
```

### GET /api/measuringtools/types/&lt;type_id&gt;
**Auth:** jwt-optional
**Params:** path: `type_id` (int)
**Purpose:** Get one measuring-tool type by id (404 if missing).

```bash
curl 'http://localhost:5001/api/measuringtools/types/3'
```

### POST /api/measuringtools/types
**Auth:** jwt + permission:measuringtools.create
**Params:** body JSON: `name` (required), `description`, `color`
**Purpose:** Create a measuring-tool type; reactivates a soft-deleted same-named one, 409 if an active one exists.

```bash
curl -X POST 'http://localhost:5001/api/measuringtools/types' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"name":"Thread Gage","description":"Go/no-go thread gages","color":"#4caf50"}'
```

### PUT /api/measuringtools/types/&lt;type_id&gt;
**Auth:** jwt + permission:measuringtools.edit
**Params:** path: `type_id`; body JSON: `name`, `description`, `color`, `isactive` (only keys present are applied)
**Purpose:** Update a measuring-tool type; 409 on rename collision with an existing name.

```bash
curl -X PUT 'http://localhost:5001/api/measuringtools/types/3' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"description":"Updated","isactive":true}'
```

### DELETE /api/measuringtools/types/&lt;type_id&gt;
**Auth:** jwt + permission:measuringtools.delete
**Params:** path: `type_id` (int)
**Purpose:** Hard-delete a measuring-tool type; refused with 409 if any tool still references it.

```bash
curl -X DELETE 'http://localhost:5001/api/measuringtools/types/3' -H 'Authorization: Bearer $TOKEN'
```

### GET /api/measuringtools
**Auth:** jwt-optional
**Params:** query: `active` (default true), `assetnumber` (exact match, for idempotent import), `search` (assetnumber/name/serialnumber ilike), `typeid`, `locationid`, `statusid`, `calibrationstatus` (overdue|duesoon|current|unknown), `page`, `perpage`
**Purpose:** List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied post-pagination.

```bash
curl 'http://localhost:5001/api/measuringtools?typeid=2&calibrationstatus=overdue&page=1&perpage=50'
```

### GET /api/measuringtools/&lt;tool_id&gt;
**Auth:** jwt-optional
**Params:** path: `tool_id` (int)
**Purpose:** Get one measuring tool by measuringtoolid, asset core dict with extension nested under `measuringtool`.

```bash
curl 'http://localhost:5001/api/measuringtools/17'
```

### GET /api/measuringtools/by-asset/&lt;asset_id&gt;
**Auth:** jwt-optional
**Params:** path: `asset_id` (int)
**Purpose:** Get a measuring tool by its core assetid (404 if the asset has no extension row).

```bash
curl 'http://localhost:5001/api/measuringtools/by-asset/1042'
```

### POST /api/measuringtools
**Auth:** jwt + permission:measuringtools.create
**Params:** body JSON: `assetnumber` (required, 409 on duplicate), `name`, `gaugelabreference`, `maintenancereference`, `serialnumber`, `statusid` (default 1), `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `measuringtooltypeid`, `calibrationintervaldays`, `lastcalibrationdate` (YYYY-MM-DD), `nextcalibrationdate` (YYYY-MM-DD), `calibrationprovider`; import timestamps honored via `X-Import-Mode`
**Purpose:** Create a measuring tool: one Asset core row (assettype `measuring_tool`) plus one measuringtools extension row in a single payload; audit-logged.

```bash
curl -X POST 'http://localhost:5001/api/measuringtools' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"assetnumber":"MT-0042","name":"6in Digital Caliper","measuringtooltypeid":2,"calibrationintervaldays":365,"lastcalibrationdate":"2026-01-15","nextcalibrationdate":"2027-01-15","calibrationprovider":"Gage Lab"}'
```

### PUT /api/measuringtools/&lt;tool_id&gt;
**Auth:** jwt + permission:measuringtools.edit
**Params:** path: `tool_id`; body JSON (only present keys applied): asset fields `assetnumber`, `name`, `gaugelabreference`, `maintenancereference`, `serialnumber`, `statusid`, `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `isactive`; extension fields `measuringtooltypeid`, `calibrationintervaldays`, `calibrationprovider`, `notes`, `lastcalibrationdate`, `nextcalibrationdate` (YYYY-MM-DD)
**Purpose:** Update asset core fields and extension fields in one payload; 409 on assetnumber collision; changes audit-logged.

```bash
curl -X PUT 'http://localhost:5001/api/measuringtools/17' -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"locationid":5,"nextcalibrationdate":"2026-12-01"}'
```

### DELETE /api/measuringtools/&lt;tool_id&gt;
**Auth:** jwt + permission:measuringtools.delete
**Params:** path: `tool_id` (int)
**Purpose:** Soft-delete a measuring tool by setting its asset isactive=false; audit-logged.

```bash
curl -X DELETE 'http://localhost:5001/api/measuringtools/17' -H 'Authorization: Bearer $TOKEN'
```

### GET /api/measuringtools/map-overlay
**Auth:** jwt-optional
**Params:** none
**Purpose:** ADR-010 map overlay: per-asset calibration badges `[{assetid, color, label}]` for active tools that are overdue or due soon only; no coordinates returned.

```bash
curl 'http://localhost:5001/api/measuringtools/map-overlay'
```

### GET /api/measuringtools/report/calibration
**Auth:** jwt-optional
**Params:** none
**Purpose:** Calibration report for the Reports hub: counts and full tool lists bucketed by derived status (overdue/duesoon/current/unknown) plus statuscolors map.

```bash
curl 'http://localhost:5001/api/measuringtools/report/calibration'
```

## plugin-network

### GET /api/network/types
**Auth:** jwt-optional
**Params:** `page`, `per_page`, `active` (default true; `false` includes inactive), `search` (ilike on networkdevicetype)
**Purpose:** List network device types, paginated.

```bash
curl 'http://localhost:5001/api/network/types?search=switch&per_page=50'
```

### GET /api/network/types/&lt;int:type_id&gt;
**Auth:** jwt-optional
**Params:** path: `type_id`
**Purpose:** Get one network device type by ID.

```bash
curl http://localhost:5001/api/network/types/3
```

### POST /api/network/types
**Auth:** jwt + permission `network.create`
**Params:** body: `networkdevicetype` (required), `description`, `icon`, `color`
**Purpose:** Create a network device type. Reactivates a soft-deleted duplicate instead of returning 409.

```bash
curl -X POST http://localhost:5001/api/network/types -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"networkdevicetype":"Switch","icon":"mdi-switch","color":"#2196f3"}'
```

### PUT /api/network/types/&lt;int:type_id&gt;
**Auth:** jwt + permission `network.edit`
**Params:** body: `networkdevicetype`, `description`, `icon`, `color`, `isactive`
**Purpose:** Update a network device type. 409 on name collision.

```bash
curl -X PUT http://localhost:5001/api/network/types/3 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"description":"Access switches"}'
```

### DELETE /api/network/types/&lt;int:type_id&gt;
**Auth:** jwt + permission `network.delete`
**Params:** path: `type_id`
**Purpose:** Hard-delete a device type. 409 if any device still references it.

```bash
curl -X DELETE http://localhost:5001/api/network/types/3 -H "Authorization: Bearer $TOKEN"
```

### GET /api/network
**Auth:** jwt-optional
**Params:** `page`, `per_page`, `active`, `assetnumber` (exact match for idempotent import), `search` (assetnumber/name/serialnumber/hostname), `typeid`|`type_id`, `vendorid`|`vendor_id`, `locationid`|`location_id`, `businessunitid`|`businessunit_id`, `poe` (true/false), `managed` (true/false), `sort` (hostname|assetnumber|name), `dir` (asc|desc)
**Purpose:** List network devices (Asset joined with NetworkDevice extension plus primary IP), filtered, sorted, paginated.

```bash
curl 'http://localhost:5001/api/network?typeid=2&poe=true&sort=hostname&dir=asc'
```

### GET /api/network/&lt;int:device_id&gt;
**Auth:** jwt-optional
**Params:** path: `device_id` (networkdeviceid)
**Purpose:** Get one network device (asset dict + `networkdevice` sub-object + primary `ipaddress`).

```bash
curl http://localhost:5001/api/network/17
```

### GET /api/network/by-asset/&lt;int:asset_id&gt;
**Auth:** jwt-optional
**Params:** path: `asset_id`
**Purpose:** Look up a network device by its core assetid.

```bash
curl http://localhost:5001/api/network/by-asset/1042
```

### GET /api/network/by-hostname/&lt;hostname&gt;
**Auth:** jwt-optional
**Params:** path: `hostname`
**Purpose:** Look up a network device by exact hostname.

```bash
curl http://localhost:5001/api/network/by-hostname/wjf-sw-idf3-01
```

### POST /api/network
**Auth:** jwt + permission `network.create`
**Params:** body: `assetnumber` (required); `name`, `serialnumber`, `gaugelabreference`, `maintenancereference`, `statusid` (default 1), `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `networkdevicetypeid`, `vendorid`, `hostname` (unique), `firmwareversion`, `portcount`, `ispoe`, `ismanaged`, `rackunit`, `ipaddress`
**Purpose:** Create a network device. Creates core Asset + NetworkDevice extension, upserts primary-IP Communication, audit-logged, honors `X-Import-Mode` legacy timestamps.

```bash
curl -X POST http://localhost:5001/api/network -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"assetnumber":"NET-0042","hostname":"wjf-sw-idf3-01","networkdevicetypeid":1,"portcount":48,"ispoe":true,"ipaddress":"10.1.3.10"}'
```

### PUT /api/network/&lt;int:device_id&gt;
**Auth:** jwt + permission `network.edit`
**Params:** body: any of `assetnumber`, `name`, `serialnumber`, `gaugelabreference`, `maintenancereference`, `statusid`, `locationid`, `businessunitid`, `mapx`, `mapy`, `notes`, `isactive`, `networkdevicetypeid`, `vendorid`, `hostname`, `firmwareversion`, `portcount`, `ispoe`, `ismanaged`, `rackunit`, `ipaddress`
**Purpose:** Update asset + network-device fields. 409 on assetnumber/hostname conflicts, change-diff audit log, upserts primary IP when `ipaddress` present.

```bash
curl -X PUT http://localhost:5001/api/network/17 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"firmwareversion":"16.12.4","ipaddress":"10.1.3.11"}'
```

### DELETE /api/network/&lt;int:device_id&gt;
**Auth:** jwt + permission `network.delete`
**Params:** path: `device_id`
**Purpose:** Soft-delete a network device (sets asset.isactive=false). Audit-logged.

```bash
curl -X DELETE http://localhost:5001/api/network/17 -H "Authorization: Bearer $TOKEN"
```

### GET /api/network/dashboard/summary
**Auth:** jwt-optional
**Params:** none
**Purpose:** Dashboard counts: total active devices, by type, by vendor, PoE vs non-PoE.

```bash
curl http://localhost:5001/api/network/dashboard/summary
```

### GET /api/network/vlans
**Auth:** jwt-optional
**Params:** `page`, `per_page`, `active` (default true), `search` (name/description/vlannumber), `type` (exact vlantype)
**Purpose:** List VLANs, paginated, ordered by vlannumber.

```bash
curl 'http://localhost:5001/api/network/vlans?search=voice'
```

### GET /api/network/vlans/&lt;int:vlan_id&gt;
**Auth:** jwt-optional
**Params:** path: `vlan_id`
**Purpose:** Get one VLAN including its active subnets.

```bash
curl http://localhost:5001/api/network/vlans/5
```

### POST /api/network/vlans
**Auth:** jwt + permission `network.create`
**Params:** body: `vlannumber` (required), `name` (required), `description`, `vlantype`
**Purpose:** Create a VLAN. 409 on duplicate vlannumber. Audit-logged.

```bash
curl -X POST http://localhost:5001/api/network/vlans -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"vlannumber":120,"name":"Shopfloor","vlantype":"production"}'
```

### PUT /api/network/vlans/&lt;int:vlan_id&gt;
**Auth:** jwt + permission `network.edit`
**Params:** body: `vlannumber`, `name`, `description`, `vlantype`, `isactive`
**Purpose:** Update a VLAN. 409 on vlannumber conflict. Change-diff audit log.

```bash
curl -X PUT http://localhost:5001/api/network/vlans/5 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"description":"CNC cell VLAN"}'
```

### DELETE /api/network/vlans/&lt;int:vlan_id&gt;
**Auth:** jwt + permission `network.delete`
**Params:** path: `vlan_id`
**Purpose:** Soft-delete a VLAN. 400 if it still has active subnets. Audit-logged.

```bash
curl -X DELETE http://localhost:5001/api/network/vlans/5 -H "Authorization: Bearer $TOKEN"
```

### GET /api/network/subnets
**Auth:** jwt-optional
**Params:** `page`, `per_page`, `active` (default true), `search` (cidr/name/description), `vlanid`, `locationid`, `type` (exact subnettype)
**Purpose:** List subnets, paginated, ordered by cidr.

```bash
curl 'http://localhost:5001/api/network/subnets?vlanid=5'
```

### GET /api/network/subnets/&lt;int:subnet_id&gt;
**Auth:** jwt-optional
**Params:** path: `subnet_id`
**Purpose:** Get one subnet plus `devices`: every asset of any type (PC/printer/network) whose primary-IP Communication falls inside the CIDR, with cross-plugin detail URLs.

```bash
curl http://localhost:5001/api/network/subnets/2
```

### POST /api/network/subnets
**Auth:** jwt + permission `network.create`
**Params:** body: `cidr` (required, must contain `/`), `name` (required), `description`, `gatewayip`, `subnetmask`, `networkaddress`, `broadcastaddress`, `vlanid`, `subnettype`, `locationid`, `dhcpenabled` (default true), `dhcprangestart`, `dhcprangeend`, `dns1`, `dns2`
**Purpose:** Create a subnet. Validates CIDR notation and vlanid existence. 409 on duplicate cidr. Audit-logged.

```bash
curl -X POST http://localhost:5001/api/network/subnets -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"cidr":"10.1.3.0/24","name":"IDF3 access","vlanid":5,"gatewayip":"10.1.3.1"}'
```

### PUT /api/network/subnets/&lt;int:subnet_id&gt;
**Auth:** jwt + permission `network.edit`
**Params:** body: any of `cidr`, `name`, `description`, `gatewayip`, `subnetmask`, `networkaddress`, `broadcastaddress`, `vlanid`, `subnettype`, `locationid`, `dhcpenabled`, `dhcprangestart`, `dhcprangeend`, `dns1`, `dns2`, `isactive`
**Purpose:** Update a subnet. 409 on cidr conflict. Change-diff audit log.

```bash
curl -X PUT http://localhost:5001/api/network/subnets/2 -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"dhcpenabled":false}'
```

### DELETE /api/network/subnets/&lt;int:subnet_id&gt;
**Auth:** jwt + permission `network.delete`
**Params:** path: `subnet_id`
**Purpose:** Soft-delete a subnet (isactive=false). Audit-logged.

```bash
curl -X DELETE http://localhost:5001/api/network/subnets/2 -H "Authorization: Bearer $TOKEN"
```

## plugin-notifications

### GET /api/notifications/types

**Auth:** none
**Params:** `page`, `per_page`; `active=false` to include inactive types
**Purpose:** List notification types, paginated, active-only by default.

```bash
curl 'http://localhost:5001/api/notifications/types?active=false&page=1&per_page=50'
```

### POST /api/notifications/types

**Auth:** JWT + permission `notifications.create`
**Params:** body: `typename` (required, unique), `typedescription`/`description`, `typecolor`/`color`, `expirymode` (`none`|`duration`|`dailytime`), `expirydays`, `expiryhour`, `expiryminute`, `splitperemployee`, `showemployeephoto`, `displaystyle` (`standard`|`carousel`|`grid`|`banner`)
**Purpose:** Create a notification type including expiry rule and shopfloor display config.

```bash
curl -X POST http://localhost:5001/api/notifications/types -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"typename":"Recognition","typecolor":"recognition","expirymode":"dailytime","expiryhour":8,"splitperemployee":true,"showemployeephoto":true}'
```

### PUT, PATCH /api/notifications/types/&lt;int:type_id&gt;

**Auth:** JWT + permission `notifications.create`
**Params:** body: any of `typename` (unique-checked), `typedescription`/`description`, `typecolor`/`color`, `isactive`, `expirymode`, `expirydays`, `expiryhour`, `expiryminute`, `splitperemployee`, `showemployeephoto`, `displaystyle`
**Purpose:** Update a notification type (name/desc/color/isactive plus expiry and display fields).

```bash
curl -X PATCH http://localhost:5001/api/notifications/types/3 -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"expirymode":"duration","expirydays":14}'
```

### GET /api/notifications

**Auth:** none
**Params:** `page`, `per_page`; `active=false` to include inactive; `typeid`/`type_id`; `ticketnumber` (exact match, for idempotent import); `current=true` (within start/end window now); `search` (ILIKE on text)
**Purpose:** List notifications with filters, newest-first, paginated.

```bash
curl 'http://localhost:5001/api/notifications?current=true&typeid=2&search=outage&page=1'
```

### GET /api/notifications/&lt;int:notification_id&gt;

**Auth:** none
**Params:** path: `notification_id`
**Purpose:** Get a single notification by ID.

```bash
curl http://localhost:5001/api/notifications/42
```

### POST /api/notifications

**Auth:** JWT + permission `notifications.create`
**Params:** body: `notification`/`message` (required), `notificationtypeid`, `businessunitid`, `appid`, `starttime`/`startdate` (ISO, default now), `endtime`/`enddate` (ISO), `ticketnumber`, `link`/`linkurl`, `isshopfloor` (default false), `employeesso` (comma-list allowed), `employeename`
**Purpose:** Create a notification; `endtime` auto-derived from type expiry rule when omitted.

```bash
curl -X POST http://localhost:5001/api/notifications -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"notification":"Line 2 press down","notificationtypeid":1,"isshopfloor":true,"ticketnumber":"INC0012345"}'
```

### PUT /api/notifications/&lt;int:notification_id&gt;

**Auth:** JWT + permission `notifications.edit`
**Params:** body: `notification`/`message`, `notificationtypeid`, `businessunitid`, `appid`, `ticketnumber`, `link`/`linkurl`, `isactive`, `isshopfloor`, `employeesso`, `employeename`, `starttime`/`startdate`, `endtime`/`enddate`
**Purpose:** Update any notification field; empty `starttime` resets to now, empty `endtime` clears it.

```bash
curl -X PUT http://localhost:5001/api/notifications/42 -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"endtime":"2026-08-01T12:00:00Z","isactive":true}'
```

### DELETE /api/notifications/&lt;int:notification_id&gt;

**Auth:** JWT + permission `notifications.delete`
**Params:** path: `notification_id`
**Purpose:** Soft-delete a notification (sets `isactive=false`, row kept).

```bash
curl -X DELETE http://localhost:5001/api/notifications/42 -H 'Authorization: Bearer $TOKEN'
```

### GET /api/notifications/active

**Auth:** none
**Params:** none
**Purpose:** Currently active notifications for display, including ones starting within a 10-day lookahead.

```bash
curl http://localhost:5001/api/notifications/active
```

### GET /api/notifications/calendar

**Auth:** none
**Params:** `start` (ISO), `end` (ISO); invalid dates silently ignored
**Purpose:** Active notifications as FullCalendar event objects for a date range.

```bash
curl 'http://localhost:5001/api/notifications/calendar?start=2026-07-01T00:00:00Z&end=2026-07-31T23:59:59Z'
```

### GET /api/notifications/dashboard/summary

**Auth:** none
**Params:** none
**Purpose:** Dashboard counts: total currently-active notifications plus active counts grouped by type/color.

```bash
curl http://localhost:5001/api/notifications/dashboard/summary
```

### GET /api/notifications/employee/&lt;sso&gt;

**Auth:** none
**Params:** path: `sso` (digits only, 400 otherwise)
**Purpose:** All active recognition-type notifications mentioning an employee SSO (exact or within comma-separated `employeesso` list).

```bash
curl http://localhost:5001/api/notifications/employee/212345678
```

### GET /api/notifications/shopfloor

**Auth:** none
**Params:** `businessunit` (numeric BU id: returns that BU's plus null-BU notifications; omitted: null-BU only)
**Purpose:** Shopfloor TV feed: current cards (active now, or ended less than 30 min ago flagged resolved) plus upcoming (starts within 5 days); splits multi-employee cards per type config, resolves employee names/photos via employees plugin, returns `configversion` hash so kiosks reload on layout changes.

```bash
curl 'http://localhost:5001/api/notifications/shopfloor?businessunit=3'
```

## plugin-printedparts

### GET /api/printedparts/items
**Auth:** jwt + permission:printedparts.view
**Params:** query: page, per_page, search (matches itemcode/gagelabtag/itemname/itemdescription/binlocation), active (default true; 'false' includes retired), lowstock=true
**Purpose:** List printed items, paginated, with search and low-stock filter.

```bash
curl -H "Authorization: Bearer $TOK" 'http://localhost:5001/api/printedparts/items?search=bracket&lowstock=true&page=1&per_page=25'
```

### GET /api/printedparts/items/<int:item_id>
**Auth:** jwt + permission:printedparts.view
**Params:** path: item_id
**Purpose:** Get one printed item plus its 25 most recent ledger transactions.

```bash
curl -H "Authorization: Bearer $TOK" http://localhost:5001/api/printedparts/items/42
```

### POST /api/printedparts/items
**Auth:** jwt + permission:printedparts.create
**Params:** json body: itemname (required), gagelabtag, itemdescription, lowstockthreshold (default from printedparts_default_threshold setting, fallback 5), binlocation, printnotes; quantityonhand starts at 0
**Purpose:** Create a printed item; itemcode auto-minted from printedparts_code_prefix setting + row id; optional gagelabtag unique-checked (409 on clash).

```bash
curl -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"itemname":"Fixture clip","gagelabtag":"WJRP0042","binlocation":"B3","lowstockthreshold":10}' http://localhost:5001/api/printedparts/items
```

### PUT /api/printedparts/items/<int:item_id>
**Auth:** jwt + permission:printedparts.edit
**Params:** path: item_id; json body: any of the editable fields; gagelabtag uppercased, empty string clears it
**Purpose:** Update catalog fields (itemname, itemdescription, lowstockthreshold, binlocation, printnotes, gagelabtag); rejects quantityonhand (ledger-managed) and duplicate gagelabtag (409).

```bash
curl -X PUT -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"binlocation":"C1","lowstockthreshold":8}' http://localhost:5001/api/printedparts/items/42
```

### DELETE /api/printedparts/items/<int:item_id>
**Auth:** jwt + permission:printedparts.delete
**Params:** path: item_id
**Purpose:** Soft-retire an item (isactive=false); ledger history preserved.

```bash
curl -X DELETE -H "Authorization: Bearer $TOK" http://localhost:5001/api/printedparts/items/42
```

### POST /api/printedparts/items/<int:item_id>/restore
**Auth:** jwt + permission:printedparts.delete
**Params:** path: item_id
**Purpose:** Un-retire a soft-deleted item (isactive=true); code, photo, history intact.

```bash
curl -X POST -H "Authorization: Bearer $TOK" http://localhost:5001/api/printedparts/items/42/restore
```

### POST /api/printedparts/items/<int:item_id>/image
**Auth:** jwt + permission:printedparts.edit
**Params:** path: item_id; multipart/form-data: file=<image>
**Purpose:** Upload or replace the item's photo (png/jpg/jpeg/gif/webp); old image files for the item are deleted first, imageurl updated.

```bash
curl -X POST -H "Authorization: Bearer $TOK" -F 'file=@clip.jpg' http://localhost:5001/api/printedparts/items/42/image
```

### GET /api/printedparts/image/<path:filename>
**Auth:** none
**Params:** path: filename (e.g. printeditem-42.jpg)
**Purpose:** Serve an uploaded item image from instance/printedpartsimages (public: fetched by `<img>` tags on kiosk and lists).

```bash
curl http://localhost:5001/api/printedparts/image/printeditem-42.jpg -o clip.jpg
```

### DELETE /api/printedparts/items/<int:item_id>/image
**Auth:** jwt + permission:printedparts.delete
**Params:** path: item_id
**Purpose:** Clear the item's imageurl; deletes the file on disk only if the URL is plugin-owned (starts with /api/printedparts/image/).

```bash
curl -X DELETE -H "Authorization: Bearer $TOK" http://localhost:5001/api/printedparts/items/42/image
```

### POST /api/printedparts/items/<int:item_id>/restock
**Auth:** jwt + permission:printedparts.restock
**Params:** path: item_id; json body: quantity (positive int, required), badge (required, 422 BadgeError if unresolvable)
**Purpose:** Add freshly printed stock via a 'restock' ledger write (single-commit ledger + cached quantity); badge resolved server-side to sso/name; 404 if item inactive.

```bash
curl -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"quantity":20,"badge":"123456789"}' http://localhost:5001/api/printedparts/items/42/restock
```

### POST /api/printedparts/items/<int:item_id>/adjust
**Auth:** jwt + permission:printedparts.restock
**Params:** path: item_id; json body: quantitychange (non-zero int, required), reason (required), badge (required)
**Purpose:** Correct the count (damage, recount) via an 'adjust' ledger write; reason mandatory; rejects driving stock below zero; fires low-stock alert on downward threshold crossing.

```bash
curl -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"quantitychange":-3,"reason":"damaged in bin","badge":"123456789"}' http://localhost:5001/api/printedparts/items/42/adjust
```

### GET /api/printedparts/kiosk/item/<itemcode>
**Auth:** none (deliberately open per decision record; kiosk cannot carry JWT)
**Params:** path: itemcode (e.g. WJRP0042, 3DP0042, 42, or WJRP0042|3)
**Purpose:** Item summary for a scanned bin barcode; resolves full code (itemcode or gagelabtag), 'TAG|rev' QR payloads (rev stripped), or bare keypad digits when the numeric tail uniquely matches one active item.

```bash
curl http://localhost:5001/api/printedparts/kiosk/item/WJRP0042
```

### POST /api/printedparts/kiosk/take
**Auth:** none (deliberately open per decision record)
**Params:** json body: itemcode (required, same resolution as kiosk/item), badge (required, resolved server-side, 422 on BadgeError), quantity (positive int <= on hand), revision (optional int)
**Purpose:** Kiosk checkout: decrement-only 'take' ledger write, badge-attributed, bounded by quantityonhand; records optional print-file revision (explicit field or 'TAG|rev' tail); triggers low-stock alert on threshold crossing; the product's only open write.

```bash
curl -X POST -H 'Content-Type: application/json' -d '{"itemcode":"WJRP0042|3","badge":"123456789","quantity":2}' http://localhost:5001/api/printedparts/kiosk/take
```

### GET /api/printedparts/reports/stock
**Auth:** jwt-optional (like all product reports)
**Params:** query: format=csv for CSV download (printedparts-stock.csv), else JSON {columns, rows}
**Purpose:** Stock-level report for active items with low-stock flags and ledgerdelta (cache-vs-ledger reconcile; nonzero means a write bypassed the single-commit rule).

```bash
curl 'http://localhost:5001/api/printedparts/reports/stock?format=csv' -o stock.csv
```

### GET /api/printedparts/reports/consumption
**Auth:** jwt-optional
**Params:** query: days (default 30; 0 or negative = all time), format=csv (printedparts-consumption.csv)
**Purpose:** Take-transactions aggregated per item (takes count + quantitytaken), sorted by quantity taken descending.

```bash
curl 'http://localhost:5001/api/printedparts/reports/consumption?days=90'
```

### GET /api/printedparts/reports/by-person
**Auth:** jwt-optional
**Params:** query: days (default 30; 0 or negative = all time), format=csv (printedparts-by-person.csv)
**Purpose:** Take-transactions grouped by employee SSO (takes count + quantitytaken), sorted by quantity taken descending.

```bash
curl 'http://localhost:5001/api/printedparts/reports/by-person?days=30&format=csv' -o by-person.csv
```

### GET /api/printedparts/items/<int:item_id>/files
**Auth:** jwt + permission:printedparts.view
**Params:** path: item_id
**Purpose:** List the item's print-file revision history, newest revision first.

```bash
curl -H "Authorization: Bearer $TOK" http://localhost:5001/api/printedparts/items/42/files
```

### POST /api/printedparts/items/<int:item_id>/files
**Auth:** jwt + permission:printedparts.edit
**Params:** path: item_id; multipart/form-data: file=<print file> (required), note=<what changed> (optional)
**Purpose:** Upload the next print-file revision (append-only, auto-numbered max+1); allowed ext: stl/3mf/gcode/gco/bgcode/step/stp/obj/amf; 100 MB cap; uploader recorded from JWT identity.

```bash
curl -X POST -H "Authorization: Bearer $TOK" -F 'file=@clip-v2.stl' -F 'note=thicker wall' http://localhost:5001/api/printedparts/items/42/files
```

### GET /api/printedparts/files/<int:file_id>/download
**Auth:** jwt-optional
**Params:** path: file_id
**Purpose:** Download a print-file revision as an attachment under its original filename (jwt-optional so plain anchor downloads work).

```bash
curl -OJ http://localhost:5001/api/printedparts/files/7/download
```

### DELETE /api/printedparts/files/<int:file_id>
**Auth:** jwt + permission:printedparts.delete
**Params:** path: file_id
**Purpose:** Delete a bad print-file revision (wrong file uploaded): removes the stored file and the DB record.

```bash
curl -X DELETE -H "Authorization: Bearer $TOK" http://localhost:5001/api/printedparts/files/7
```

## plugin-printers

### GET /api/printers/types
**Auth:** jwt-optional
**Params:** page, per_page, active (default true; 'false' includes inactive), search
**Purpose:** List printer types, paginated, with active filter and name search.

```bash
curl -H "Authorization: Bearer $TOKEN" 'http://localhost:5001/api/printers/types?search=laser&active=false'
```

### GET /api/printers/types/&lt;type_id&gt;
**Auth:** jwt-optional
**Params:** path: type_id (int)
**Purpose:** Get a single printer type by ID.

```bash
curl http://localhost:5001/api/printers/types/3
```

### POST /api/printers/types
**Auth:** permission:printers.create
**Params:** body: printertype (required), description, icon, color; reactivates a matching inactive type instead of 409
**Purpose:** Create a new printer type (or reactivate an inactive duplicate).

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"printertype":"Label","color":"#00f"}' http://localhost:5001/api/printers/types
```

### PUT /api/printers/types/&lt;type_id&gt;
**Auth:** permission:printers.edit
**Params:** body: printertype, description, icon, color, isactive (any subset); 409 on name clash
**Purpose:** Update a printer type.

```bash
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"isactive":false}' http://localhost:5001/api/printers/types/3
```

### DELETE /api/printers/types/&lt;type_id&gt;
**Auth:** permission:printers.delete
**Params:** path: type_id; 409 if any printer still references the type
**Purpose:** Hard-delete a printer type when unused.

```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/printers/types/3
```

### GET /api/printers/drivers
**Auth:** jwt-optional
**Params:** active (default true; 'false' includes inactive). Unpaginated.
**Purpose:** List printer driver packages (named SMB/HTTP links).

```bash
curl 'http://localhost:5001/api/printers/drivers?active=false'
```

### POST /api/printers/drivers
**Auth:** permission:printers.create
**Params:** body: name (required), location (required), description, modelnumberid, isactive
**Purpose:** Create a driver entry.

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"name":"HP UPD","location":"\\\\fileserver\\drivers\\hpupd"}' http://localhost:5001/api/printers/drivers
```

### PUT /api/printers/drivers/&lt;driver_id&gt;
**Auth:** permission:printers.edit
**Params:** body: name, location, description, isactive, modelnumberid (any subset)
**Purpose:** Update a driver entry.

```bash
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"isactive":false}' http://localhost:5001/api/printers/drivers/5
```

### DELETE /api/printers/drivers/&lt;driver_id&gt;
**Auth:** permission:printers.delete
**Params:** path: driver_id
**Purpose:** Hard-delete a driver entry.

```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/printers/drivers/5
```

### GET /api/printers
**Auth:** jwt-optional
**Params:** page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)
**Purpose:** List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.

```bash
curl 'http://localhost:5001/api/printers?search=csf&typeid=2&sort=assetnumber&dir=desc'
```

### GET /api/printers/install-list
**Auth:** jwt-optional
**Params:** format=text for pipe-delimited installer variant (printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy); default JSON
**Purpose:** Flat unpaginated list of active network printers (with map positions) for the signed printer-installer EXE; replaces classic apiprinters.asp.

```bash
curl 'http://localhost:5001/api/printers/install-list?format=text'
```

### GET /api/printers/install-batch
**Auth:** jwt-optional
**Params:** printerids (required, comma-separated printer IDs)
**Purpose:** Generate and download a Windows .bat that installs selected printers (HP/Xerox via universal PrinterInstaller.exe, per-printer .exe /SILENT, or flags manual).

```bash
curl -OJ 'http://localhost:5001/api/printers/install-batch?printerids=1,2,3'
```

### GET /api/printers/pc-default
**Auth:** jwt-optional
**Params:** machine (PC asset number), format=text for pipe-delimited variant; returns {} / empty body if no default set
**Purpose:** Look up a PC's default printer via the defaultprinter asset relationship (parity with classic apipcdefaultprinter.asp); used by installer to preselect map hotspot.

```bash
curl 'http://localhost:5001/api/printers/pc-default?machine=0421&format=text'
```

### GET /api/printers/&lt;printer_id&gt;
**Auth:** jwt-optional
**Params:** path: printer_id (int)
**Purpose:** Get one printer with full asset details, communications, and active drivers matching its model.

```bash
curl http://localhost:5001/api/printers/17
```

### GET /api/printers/by-asset/&lt;asset_id&gt;
**Auth:** jwt-optional
**Params:** path: asset_id (int)
**Purpose:** Get printer data keyed by core asset ID.

```bash
curl http://localhost:5001/api/printers/by-asset/204
```

### POST /api/printers
**Auth:** permission:printers.create
**Params:** body: assetnumber (required); name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, printertypeid, vendorid, modelnumberid, hostname, windowsname, sharename, iscsf, installpath, pin, iscolor, isduplex, isnetwork, mapx, mapy, notes, ipaddress (creates primary IP comm); X-Import-Mode header preserves legacy timestamps
**Purpose:** Create a printer (Asset + Printer extension + optional primary IP communication).

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"assetnumber":"PR-0042","hostname":"wjprn042","ipaddress":"10.1.2.42","printertypeid":1}' http://localhost:5001/api/printers
```

### PUT /api/printers/&lt;printer_id&gt;
**Auth:** permission:printers.edit
**Params:** body: any subset of asset fields (assetnumber, name, serialnumber, gaugelabreference, maintenancereference, statusid, locationid, businessunitid, mapx, mapy, notes, isactive) + printer fields (printertypeid, vendorid, modelnumberid, hostname, windowsname, sharename, iscsf, installpath, pin, iscolor, isduplex, isnetwork) + ipaddress (upserts/clears primary IP comm); 409 on assetnumber clash
**Purpose:** Update printer: asset, extension, and primary IP in one call.

```bash
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"ipaddress":"10.1.2.99","iscolor":true}' http://localhost:5001/api/printers/17
```

### DELETE /api/printers/&lt;printer_id&gt;
**Auth:** permission:printers.delete
**Params:** path: printer_id
**Purpose:** Soft-delete a printer (sets the underlying asset isactive=false).

```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/printers/17
```

### GET /api/printers/&lt;printer_id&gt;/supplies
**Auth:** jwt-optional
**Params:** path: printer_id; needs an IP communication on the printer
**Purpose:** Real-time supply levels from Zabbix for one printer, annotated with status/color/part numbers; fails soft (empty supplies, pingstatus -1) when Zabbix is off/unreachable.

```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/printers/17/supplies
```

### GET /api/printers/lowsupplies
**Auth:** jwt-optional
**Params:** none (data cached 5 min)
**Purpose:** Fleet-wide report of printers with low/critical supply levels from Zabbix, with summary counts.

```bash
curl http://localhost:5001/api/printers/lowsupplies
```

### GET /api/printers/lookup
**Auth:** jwt-optional
**Params:** ip or fqdn (one required; value matched against communication ipaddress)
**Purpose:** Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).

```bash
curl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'
```

### POST /api/printers/supplies/refresh
**Auth:** permission:printers.create
**Params:** none
**Purpose:** Clear the cached Zabbix supply data so next reads pull fresh values (toner-report Refresh button).

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/printers/supplies/refresh
```

### GET /api/printers/dashboard/summary
**Auth:** jwt-optional
**Params:** none
**Purpose:** Dashboard summary: total active printers, counts by type and vendor, low/critical supply counts (Zabbix, if reachable).

```bash
curl http://localhost:5001/api/printers/dashboard/summary
```

### GET /api/printers/supplies/meta
**Auth:** jwt-optional
**Params:** none
**Purpose:** Allowed enum values for supplytype, color, and capacitytier (for the UI forms).

```bash
curl http://localhost:5001/api/printers/supplies/meta
```

### GET /api/printers/models
**Auth:** jwt-optional
**Params:** page, per_page, search (modelnumber), vendorid|vendor_id, withsupplies=true (only models that already have supplies)
**Purpose:** List printer models with supply counts for the supply-management picker (restricted to models attached to printers or already carrying supplies).

```bash
curl 'http://localhost:5001/api/printers/models?withsupplies=true&search=M404'
```

### GET /api/printers/models/&lt;modelnumberid&gt;/supplies
**Auth:** jwt-optional
**Params:** path: modelnumberid (int)
**Purpose:** List all active supplies (toner/drum/waste part numbers) mapped to a model.

```bash
curl http://localhost:5001/api/printers/models/12/supplies
```

### POST /api/printers/models/&lt;modelnumberid&gt;/supplies
**Auth:** permission:printers.create
**Params:** body: partnumber (required), supplytype (default toner), color (default none), capacitytier (default standard), marketingname, pageyield, notes; 409 if part already mapped to model
**Purpose:** Add a supply part-number mapping to a model.

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"partnumber":"CF259A","supplytype":"toner","color":"black"}' http://localhost:5001/api/printers/models/12/supplies
```

### PUT /api/printers/supplies/&lt;modelsupplyid&gt;
**Auth:** permission:printers.edit
**Params:** body: any subset of supplytype, color, capacitytier, partnumber, marketingname, pageyield, notes; enum-validated, 409 on partnumber clash within model
**Purpose:** Update a model supply mapping.

```bash
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"capacitytier":"high"}' http://localhost:5001/api/printers/supplies/44
```

### DELETE /api/printers/supplies/&lt;modelsupplyid&gt;
**Auth:** permission:printers.delete
**Params:** path: modelsupplyid
**Purpose:** Hard-delete a model supply mapping.

```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/printers/supplies/44
```

## plugin-slides

### GET /api/slides/feed
**Auth:** none
**Params:** query: `surface=lobby|shopfloor` (default `lobby`; invalid values fall back to `lobby`). Returns `{success, surface, basepath, interval, slides:[{filename, seconds}]}`; only slides whose file exists on disk are listed.
**Purpose:** Public flat playlist for a surface (lobby/shopfloor). Raw `jsonify`, not the `success_response` envelope, so the screensaver parser works unchanged.

```bash
curl 'http://localhost:5001/api/slides/feed?surface=shopfloor'
```

### GET /api/slides/img/&lt;surface&gt;/&lt;path:filename&gt;
**Auth:** none
**Params:** path: `surface` (`lobby|shopfloor`), `filename` (must equal its basename).
**Purpose:** Public serving of a single slide image from `instance/slides/<surface>/`, with basename path-traversal guard. 404 on unknown surface, traversal attempt, or missing file.

```bash
curl 'http://localhost:5001/api/slides/img/lobby/Slide1.png' -o Slide1.png
```

### GET /api/slides/&lt;surface&gt;
**Auth:** jwt + permission:slides.manage
**Params:** path: `surface` (`lobby|shopfloor`; else `VALIDATION_ERROR`).
**Purpose:** Admin list of a surface's slides (TvSlide rows ordered by `sortorder, slideid`, filtered to files present on disk), each with a `url` field for the img route.

```bash
curl -H 'Authorization: Bearer $TOKEN' 'http://localhost:5001/api/slides/lobby'
```

### POST /api/slides/&lt;surface&gt;/upload
**Auth:** jwt + permission:slides.manage
**Params:** path: `surface`. multipart/form-data body: `files` (repeatable) or single `file`. Allowed extensions: `.jpg .jpeg .png .gif .bmp .webp`. Returns `{added:[names]}`.
**Purpose:** Upload one or more slide images. Non-image extensions skipped, names `secure_filename`'d and unique-renamed (`stem_N.ext`) on collision, appended after current max sortorder in natural filename order, `seconds=0` (surface default).

```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -F 'files=@Slide1.png' -F 'files=@Slide2.png' 'http://localhost:5001/api/slides/lobby/upload'
```

### POST /api/slides/&lt;surface&gt;/order
**Auth:** jwt + permission:slides.manage
**Params:** path: `surface`. JSON body: `{order: [filename, ...]}`.
**Purpose:** Save play order: each filename in the `order` array gets `sortorder` set to its index; unknown filenames silently ignored.

```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"order":["Slide2.png","Slide1.png"]}' 'http://localhost:5001/api/slides/lobby/order'
```

### POST /api/slides/&lt;surface&gt;/delete
**Auth:** jwt + permission:slides.manage
**Params:** path: `surface`. JSON body: `{files: [filename, ...]}`.
**Purpose:** Delete named slides: removes file from disk (OSError swallowed) and the TvSlide row. Filenames reduced to basename first; returns count of DB rows removed.

```bash
curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"files":["Slide1.png"]}' 'http://localhost:5001/api/slides/shopfloor/delete'
```

### PATCH /api/slides/&lt;surface&gt;/&lt;int:slideid&gt;
**Auth:** jwt + permission:slides.manage
**Params:** path: `surface`, `slideid` (int). JSON body: `{seconds: int}` (non-int -> `VALIDATION_ERROR`).
**Purpose:** Update a single slide's per-slide duration. `seconds` clamped to `>=0` (`0` means use the 10s surface default); 404 if slide missing or belongs to a different surface.

```bash
curl -X PATCH -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"seconds":15}' 'http://localhost:5001/api/slides/lobby/12'
```

## plugin-usb

USB device inventory with check-in/check-out tracking. Dual-mode backend: self-hosted tables or external `cmmc_usb` DB, selected by the `usb_directory_mode` setting.

### GET /api/usb

**Auth:** JWT optional
**Params:** query: `page`, `per_page`, `status` (`available` | `checkedout` | `retired`), `search` (matches `device_id` or `device_desc`)
**Purpose:** List USB devices with checkout status (paginated).

```bash
curl 'http://localhost:5001/api/usb?status=available&search=kingston&page=1&per_page=25' -H 'Authorization: Bearer $JWT'
```

### GET /api/usb/&lt;device_id&gt;

**Auth:** JWT optional
**Params:** path: `device_id`
**Purpose:** Get one device plus its last 20 check-in/out log rows. 404 if unknown.

```bash
curl 'http://localhost:5001/api/usb/USB-0042' -H 'Authorization: Bearer $JWT'
```

### POST /api/usb

**Auth:** JWT + permission `usb.create`
**Params:** body JSON: `device_id` (required), `device_desc`, `device_owner` (badge), `locker_location`
**Purpose:** Create a device; starts in checked-in status. 409 on duplicate `device_id`.

```bash
curl -X POST 'http://localhost:5001/api/usb' -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"device_id":"USB-0042","device_desc":"Kingston 32GB","device_owner":"212345678","locker_location":"A3"}'
```

### PUT /api/usb/&lt;device_id&gt;

**Auth:** JWT + permission `usb.edit`
**Params:** path: `device_id`; body JSON: any of `device_desc`, `device_owner`, `locker_location`, `status`
**Purpose:** Edit device fields. 404 if unknown.

```bash
curl -X PUT 'http://localhost:5001/api/usb/USB-0042' -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"device_desc":"Kingston 64GB","locker_location":"B1"}'
```

### POST /api/usb/&lt;device_id&gt;/retire

**Auth:** JWT + permission `usb.edit`
**Params:** path: `device_id`; no body
**Purpose:** Retire a device (sets status to `retired`). 404 if unknown.

```bash
curl -X POST 'http://localhost:5001/api/usb/USB-0042/retire' -H 'Authorization: Bearer $JWT'
```

### POST /api/usb/&lt;device_id&gt;/checkout

**Auth:** JWT + permission `usb.create`
**Params:** path: `device_id`; body JSON: `badge` (required), `locker_location` (optional, also updates the device)
**Purpose:** Check a device out to a badge. Writes a check-out log row, sets status to checked-out, auto-creates the user from the HR directory. 409 if already checked out.

```bash
curl -X POST 'http://localhost:5001/api/usb/USB-0042/checkout' -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"badge":"212345678","locker_location":"A3"}'
```

### POST /api/usb/&lt;device_id&gt;/checkin

**Auth:** JWT + permission `usb.create`
**Params:** path: `device_id`; body JSON: `badge` (required), `locker_location`, `sanitized` (bool/1/0), `scanned_viruses` (bool/1/0)
**Purpose:** Check a device back in. Writes a check-in log row with sanitized/virus-scan flags, sets status to checked-in. 400 if not currently checked out.

```bash
curl -X POST 'http://localhost:5001/api/usb/USB-0042/checkin' -H 'Authorization: Bearer $JWT' -H 'Content-Type: application/json' -d '{"badge":"212345678","sanitized":true,"scanned_viruses":true}'
```

### GET /api/usb/&lt;device_id&gt;/history

**Auth:** JWT optional
**Params:** path: `device_id`; query: `page`, `per_page`
**Purpose:** Paginated check-in/out log for one device, newest first.

```bash
curl 'http://localhost:5001/api/usb/USB-0042/history?page=1&per_page=50' -H 'Authorization: Bearer $JWT'
```

### GET /api/usb/checkouts

**Auth:** JWT optional
**Params:** query: `page`, `per_page`, `active=true` (only rows whose device is still checked out), `badge` (filter by `badge_number`)
**Purpose:** List check-out log rows (paginated), each with the device's current status joined in.

```bash
curl 'http://localhost:5001/api/usb/checkouts?active=true&badge=212345678' -H 'Authorization: Bearer $JWT'
```

### GET /api/usb/checkouts/active

**Auth:** JWT optional
**Params:** query: `badge` (filter by `badge_number`)
**Purpose:** Latest check-out log row for every currently checked-out device (non-paginated list).

```bash
curl 'http://localhost:5001/api/usb/checkouts/active?badge=212345678' -H 'Authorization: Bearer $JWT'
```

## plugin-warranty

### GET /api/warranty

**Auth:** jwt-optional
**Params:** query: `active` (default true; 'false' includes inactive), `servicetag` (exact match, for idempotent import), `vendor` (exact match), `assetid` (int, filter to warranties covering that asset), `status` (post-filter on derived status: `expired|expiring|active|unknown`)
**Purpose:** List warranties with linked-asset summaries and derived status (batch asset fetch, ordered by enddate with null last).

```bash
curl -H "Authorization: Bearer $TOKEN" 'http://localhost:5001/api/warranty?status=expiring&assetid=42'
```

### GET /api/warranty/asset/&lt;int:assetid&gt;

**Auth:** jwt-optional
**Params:** path: `assetid`
**Purpose:** Active warranties covering one asset, for the asset-detail panel.

```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/warranty/asset/42
```

### GET /api/warranty/&lt;int:warrantyid&gt;

**Auth:** jwt-optional
**Params:** path: `warrantyid`
**Purpose:** Fetch a single warranty by id with asset summaries; 404 if missing.

```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/warranty/7
```

### POST /api/warranty

**Auth:** jwt + permission: `warranty.create`
**Params:** JSON body: `vendor` (required), `servicetag`, `provider` (default 'manual', lowercased), `servicelevel`, `startdate` (YYYY-MM-DD), `enddate` (YYYY-MM-DD), `notes`, `assetids` (list of asset ids to link)
**Purpose:** Create a warranty (vendor required) and optionally link it to assets; returns 201.

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"vendor":"Dell","servicetag":"ABC1234","enddate":"2027-06-30","assetids":[42]}' http://localhost:5001/api/warranty
```

### PUT /api/warranty/&lt;int:warrantyid&gt;

**Auth:** jwt + permission: `warranty.edit`
**Params:** path: `warrantyid`; JSON body (all optional): `vendor`, `servicetag`, `provider`, `servicelevel`, `startdate`, `enddate`, `notes`, `isactive` (bool), `assetids` (full replacement list)
**Purpose:** Partial update of any warranty field (only keys present in body change), including isactive and replacing asset links via assetids.

```bash
curl -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"enddate":"2028-01-15","assetids":[42,43]}' http://localhost:5001/api/warranty/7
```

### DELETE /api/warranty/&lt;int:warrantyid&gt;

**Auth:** jwt + permission: `warranty.delete`
**Params:** path: `warrantyid`
**Purpose:** Hard-delete a warranty (and its asset links); 404 if missing.

```bash
curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/warranty/7
```

### POST /api/warranty/&lt;int:warrantyid&gt;/refresh

**Auth:** jwt + permission: `warranty.edit`
**Params:** path: `warrantyid`; no body
**Purpose:** Re-query the warranty's provider (dell etc.) by service tag and update servicelevel/startdate/enddate + lastcheckeddate; 400 if provider is manual or not configured.

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/warranty/7/refresh
```

### POST /api/warranty/sync/dell

**Auth:** jwt + permission: `warranty.edit`
**Params:** query: `all` (default false; 'true' re-checks assets that already have a dated warranty); no body
**Purpose:** Bulk Dell sync: look up serials of active assets lacking a dated warranty via Dell bulk_lookup, creating or updating (canonicalizing to provider dell) warranties; returns candidates/tags/matched/created/updated counts.

```bash
curl -X POST -H "Authorization: Bearer $TOKEN" 'http://localhost:5001/api/warranty/sync/dell?all=true'
```

### GET /api/warranty/report

**Auth:** jwt-optional
**Params:** none
**Purpose:** Report for the Reports hub: active warranties bucketed by derived status (expired/expiring/active/unknown) with per-bucket counts and full lists.

```bash
curl -H "Authorization: Bearer $TOKEN" http://localhost:5001/api/warranty/report
```
