{"openapi":"3.0.3","info":{"title":"Yendia API","version":"1.3.0","description":"The Yendia platform's HTTP surface, in five groups.\n\n**MRV sync** (`POST /api/mrv/*`) is what the `yendia_mrv` Flutter field app talks to. It is the group a third party is most likely to integrate with. Writes are idempotent on a UUID the handset mints offline, monitoring visits and replantings are append-only, and a photograph is only confirmed once its bytes have been re-hashed server-side.\n\n**MRV pull** (`GET /api/mrv/catalog`, `GET /api/mrv/time`) is the other direction: the reference data and register a replacement handset needs in order to work, and the server clock a device measures its own offset against.\n\n**Reference data** (`/api/admin/sites`, `/api/admin/plots`, `/api/admin/officers/*`) maintains what the handset reads and cannot change. The app's own sites list is read-only and tells an officer to contact their coordinator, so these endpoints — or the console forms that share their validation — are the only way sites, plots and the officer roster are ever created or corrected.\n\n**Commerce** (`/api/gift/*`) drives the Paystack purchase flow.\n\n**Console** (`/api/admin/export`, `/api/admin/monitoring-due`, `/api/admin/notifications`, `/api/photos/*`, `/api/my-photos/*`, `/api/avatars/*`, `/api/dashboard/*`) backs the two web dashboards. Every non-MRV group is session-authenticated by cookie, not key-authenticated.\n\n**Evidence photographs are served by two routes, not one.** `/api/photos/*` is admin-only and reaches any record. `/api/my-photos/*` serves a donor only the pictures of trees allocated to their own paid gifts, and answers 404 — never 403 — for anything else, so probing ids cannot confirm that a record exists.\n\n**Optional fields must still be present, as `null`.** Every property in the MRV bodies below is listed as required, and that is accurate rather than an artefact: the validators accept `null` for an optional value but reject an omitted key. This matches the Flutter models, whose `toMap()` always emits every column. Sending `{}` without `remarks` returns 400, not a default.\n\n> Note: the field app is not yet wired to this API — it still runs against `SimulatedMrvApi`. The endpoints below are implemented and tested; the client is the missing piece.","contact":{"name":"Yendia","email":"hello@yendia.org"}},"servers":[{"url":"https://localhost:3000","description":"This deployment"}],"tags":[{"name":"MRV sync","description":"Device → server. Bearer key per handset, issued in the admin console."},{"name":"MRV pull","description":"Server → device. Reference data and the tree register, so a replacement handset can work."},{"name":"Reference data","description":"Sites, plots and the officer roster — what the handset pulls and cannot edit. The app's own sites list is read-only and tells an officer to contact their coordinator, so these endpoints are the only way this data is maintained."},{"name":"Commerce","description":"Paystack gift purchase."},{"name":"Console","description":"Session-authenticated dashboard endpoints."}],"components":{"schemas":{"ContactEnquiry":{"type":"object","properties":{"name":{"type":"string","minLength":2},"email":{"type":"string","format":"email","pattern":"^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"organisation":{"anyOf":[{"type":"string","maxLength":120},{"type":"string","enum":[""]}]},"topic":{"type":"string","enum":["gift","partnership","verification","field","other"]},"message":{"type":"string","minLength":10,"maxLength":4000},"website":{"anyOf":[{"type":"string","maxLength":0},{"type":"string","enum":[""]}]}},"required":["name","email","topic","message"],"description":"Contact enquiry fields. **Not reachable over HTTP** — the site's contact form submits these to a server action. Listed for reference only."}},"securitySchemes":{"officerToken":{"type":"http","scheme":"bearer","description":"The session token returned by `POST /api/mrv/officers/login`. Signing in on the handset is what links it — there are no issued device keys. Send the two installation headers (`X-Installation-Id`, `X-Device-Name`) alongside it so records are attributed to the right physical handset; a device row is registered automatically on first sight and can be revoked in the console to block a lost phone."},"sessionCookie":{"type":"apiKey","in":"cookie","name":"yendia_session","description":"An opaque server-side session token. Session facts live in the `sessions` table, never in the cookie."}}},"paths":{"/api/mrv/trees":{"post":{"tags":["MRV sync"],"summary":"Upload a registered tree","description":"Idempotent on `Tree.id`. Identity, planting facts and the registration fix are frozen after the first write; a divergent re-send is accepted with 200 but recorded in `mrv_rejections` rather than applied. `current_status`, `current_health`, `monitoring_count` and friends are **ignored** if sent — they are derived from the visits, which are the system of record.\n\nThe nursery measurements — `planting_age_months`, `planting_height_cm`, `planting_diameter_mm` — are the exception to frozen: a still-null one is filled in by a later delivery, but a stored value is never overwritten. They are nullable on the wire even though the field app now requires the age and height of a *new* registration, because handsets still hold trees captured before it asked and nobody can re-measure a seedling already in the ground. A null means not measured, and must never be read as zero.","security":[{"officerToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":36},"tree_code":{"type":"string","minLength":1,"maxLength":32},"site_id":{"type":"string","minLength":1,"maxLength":64},"plot_id":{"type":"string","minLength":1,"maxLength":64},"species_common_name":{"type":"string","minLength":1,"maxLength":191},"species_scientific_name":{"type":"string","maxLength":191,"nullable":true},"category":{"type":"string","enum":["indigenous","agroforestry"]},"tree_gift_id":{"type":"string","maxLength":64,"nullable":true},"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180},"accuracy_metres":{"type":"number","minimum":0},"elevation_metres":{"type":"number","nullable":true},"fix_captured_at":{"type":"string"},"gps_sample_count":{"type":"integer","minimum":0,"maximum":9007199254740991,"nullable":true},"gps_spread_metres":{"type":"number","minimum":0,"nullable":true},"planting_date":{"type":"string"},"planted_by":{"type":"string","minLength":1,"maxLength":191},"planting_team":{"type":"string","maxLength":191,"nullable":true},"planting_age_months":{"type":"integer","minimum":0,"maximum":600,"nullable":true},"planting_height_cm":{"type":"number","minimum":0,"nullable":true},"planting_diameter_mm":{"type":"number","minimum":0,"nullable":true},"remarks":{"type":"string","maxLength":4000,"nullable":true},"photo_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$","nullable":true},"registered_by":{"type":"string","maxLength":64,"nullable":true},"created_at":{"type":"string"},"updated_at":{"type":"string"},"device_id":{"type":"string","maxLength":64,"nullable":true},"device_name":{"type":"string","maxLength":191,"nullable":true},"app_version":{"type":"string","maxLength":64,"nullable":true},"os_version":{"type":"string","maxLength":191,"nullable":true},"created_by":{"type":"string","maxLength":64,"nullable":true},"synced_at":{"anyOf":[{"type":"null"},{"type":"string","enum":[""]},{"type":"string"}]},"clock_offset_seconds":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"nullable":true}},"required":["id","tree_code","site_id","plot_id","species_common_name","species_scientific_name","category","tree_gift_id","latitude","longitude","accuracy_metres","elevation_metres","fix_captured_at","gps_sample_count","gps_spread_metres","planting_date","planted_by","planting_team","planting_age_months","planting_height_cm","planting_diameter_mm","remarks","photo_sha256","registered_by","created_at","updated_at","device_id","device_name","app_version","os_version","created_by","synced_at","clock_offset_seconds"],"description":"The Flutter `Tree.toMap()` output. Unknown keys are stripped rather than rejected, so a newer app build still syncs against an older server."}}}},"responses":{"200":{"description":"Accepted, or already held.","content":{"application/json":{"schema":{"type":"object","required":["ok","id","outcome"],"properties":{"ok":{"type":"boolean","enum":[true]},"id":{"type":"string","description":"The device-minted UUID, echoed back."},"outcome":{"type":"string","enum":["created","updated","duplicate"],"description":"`duplicate` means this record was already held. It is a success: a retry after an ambiguous failure must land here or the handset's queue never drains."}}}}}},"400":{"description":"Malformed JSON, or the payload failed validation. The rejected payload is stored in `mrv_rejections` so it is visible to an administrator rather than lost.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"Missing, malformed, unknown or revoked device key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"409":{"description":"The UUID — or, for a tree, the human-readable code — belongs to a record captured on a different device installation.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"429":{"description":"Over 600 requests per minute for this device. `Retry-After` is set.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure. Worth retrying.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"get":{"tags":["MRV pull"],"summary":"Pull the tree register for this device","description":"Paged in arrival order on the server-assigned `seq`. Metadata only — no photograph bytes. **Store these locally as already-synced**: they carry the original capturing installation's `device_id`, so offering them back would hit the ownership pin and be refused 409 forever.","security":[{"officerToken":[]}],"parameters":[{"name":"since","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Feed back the previous page's `cursor`."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":500,"default":200}}],"responses":{"200":{"description":"A page of trees.","content":{"application/json":{"schema":{"type":"object","properties":{"trees":{"type":"array","items":{"type":"object"}},"cursor":{"type":"integer","nullable":true,"description":"Pass as `since` to continue."},"hasMore":{"type":"boolean"},"siteIds":{"type":"array","items":{"type":"string"},"description":"The sites this device is entitled to: the key's officer's explicit assignments, plus their default site, plus any site they already have records at."}}}}}},"401":{"description":"Missing or revoked device key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/visits":{"post":{"tags":["MRV sync"],"summary":"Upload a monitoring visit","description":"Append-only. A re-delivery returns 200 so the handset stops queueing it, but may only fill in a still-null link or delivery metadata — `status`, `health`, `height_cm`, `diameter_mm`, `visit_date` and the GPS fix can never be rewritten.\n\nA visit for a tree this server has not seen is still accepted: uploads are per-record, so a tree can fail while its visit succeeds, and rejecting it would discard real fieldwork over delivery order.","security":[{"officerToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":36},"tree_id":{"type":"string","minLength":1,"maxLength":36},"visit_date":{"type":"string"},"status":{"type":"string","enum":["alive","dead","missing","replanted"]},"health":{"type":"string","enum":["excellent","good","fair","poor"]},"height_cm":{"type":"number","minimum":0,"nullable":true},"diameter_mm":{"type":"number","minimum":0,"nullable":true},"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180},"accuracy_metres":{"type":"number","minimum":0},"elevation_metres":{"type":"number","nullable":true},"fix_captured_at":{"type":"string"},"gps_sample_count":{"type":"integer","minimum":0,"maximum":9007199254740991,"nullable":true},"gps_spread_metres":{"type":"number","minimum":0,"nullable":true},"remarks":{"type":"string","maxLength":4000,"nullable":true},"officer_name":{"type":"string","maxLength":191,"nullable":true},"replant_event_id":{"type":"string","maxLength":36,"nullable":true},"photo_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$","nullable":true},"created_at":{"type":"string"},"updated_at":{"type":"string"},"device_id":{"type":"string","maxLength":64,"nullable":true},"device_name":{"type":"string","maxLength":191,"nullable":true},"app_version":{"type":"string","maxLength":64,"nullable":true},"os_version":{"type":"string","maxLength":191,"nullable":true},"created_by":{"type":"string","maxLength":64,"nullable":true},"synced_at":{"anyOf":[{"type":"null"},{"type":"string","enum":[""]},{"type":"string"}]},"clock_offset_seconds":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"nullable":true}},"required":["id","tree_id","visit_date","status","health","height_cm","diameter_mm","latitude","longitude","accuracy_metres","elevation_metres","fix_captured_at","gps_sample_count","gps_spread_metres","remarks","officer_name","replant_event_id","photo_sha256","created_at","updated_at","device_id","device_name","app_version","os_version","created_by","synced_at","clock_offset_seconds"],"description":"`MonitoringVisit.toMap()`."}}}},"responses":{"200":{"description":"Accepted, or already held.","content":{"application/json":{"schema":{"type":"object","required":["ok","id","outcome"],"properties":{"ok":{"type":"boolean","enum":[true]},"id":{"type":"string","description":"The device-minted UUID, echoed back."},"outcome":{"type":"string","enum":["created","updated","duplicate"],"description":"`duplicate` means this record was already held. It is a success: a retry after an ambiguous failure must land here or the handset's queue never drains."}}}}}},"400":{"description":"Malformed JSON, or the payload failed validation. The rejected payload is stored in `mrv_rejections` so it is visible to an administrator rather than lost.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"Missing, malformed, unknown or revoked device key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"409":{"description":"The UUID — or, for a tree, the human-readable code — belongs to a record captured on a different device installation.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"429":{"description":"Over 600 requests per minute for this device. `Retry-After` is set.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure. Worth retrying.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"get":{"tags":["MRV pull"],"summary":"Pull the monitoring visits for this device","description":"Paged in arrival order on the server-assigned `seq`, scoped by the tree's site exactly like the tree pull. The tree row carries only the derived state (status, last visit, counts); this is the history behind it. **Store these locally as already-synced**: they are the server's records, and offering them back would hit the ownership pin.","security":[{"officerToken":[]}],"parameters":[{"name":"since","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Feed back the previous page's `cursor`."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":500,"default":200}}],"responses":{"200":{"description":"A page of visits.","content":{"application/json":{"schema":{"type":"object","properties":{"visits":{"type":"array","items":{"type":"object"}},"cursor":{"type":"integer","nullable":true,"description":"Pass as `since` to continue."},"hasMore":{"type":"boolean"}}}}}},"401":{"description":"Missing or revoked session.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/replants":{"post":{"tags":["MRV sync"],"summary":"Upload a replanting","description":"Append-only, on the same terms as a visit. Delivered on its own account rather than riding along with the visit that recorded the loss, because the visit carries none of the replanting's facts — its date, its planter, its photograph.","security":[{"officerToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":36},"tree_id":{"type":"string","minLength":1,"maxLength":36},"visit_id":{"type":"string","maxLength":36,"nullable":true},"planting_date":{"type":"string"},"planted_by":{"type":"string","minLength":1,"maxLength":191},"planting_team":{"type":"string","maxLength":191,"nullable":true},"latitude":{"type":"number","minimum":-90,"maximum":90},"longitude":{"type":"number","minimum":-180,"maximum":180},"accuracy_metres":{"type":"number","minimum":0},"elevation_metres":{"type":"number","nullable":true},"fix_captured_at":{"type":"string"},"gps_sample_count":{"type":"integer","minimum":0,"maximum":9007199254740991,"nullable":true},"gps_spread_metres":{"type":"number","minimum":0,"nullable":true},"remarks":{"type":"string","maxLength":4000,"nullable":true},"photo_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$","nullable":true},"created_at":{"type":"string"},"updated_at":{"type":"string"},"device_id":{"type":"string","maxLength":64,"nullable":true},"device_name":{"type":"string","maxLength":191,"nullable":true},"app_version":{"type":"string","maxLength":64,"nullable":true},"os_version":{"type":"string","maxLength":191,"nullable":true},"created_by":{"type":"string","maxLength":64,"nullable":true},"synced_at":{"anyOf":[{"type":"null"},{"type":"string","enum":[""]},{"type":"string"}]},"clock_offset_seconds":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"nullable":true}},"required":["id","tree_id","visit_id","planting_date","planted_by","planting_team","latitude","longitude","accuracy_metres","elevation_metres","fix_captured_at","gps_sample_count","gps_spread_metres","remarks","photo_sha256","created_at","updated_at","device_id","device_name","app_version","os_version","created_by","synced_at","clock_offset_seconds"],"description":"`ReplantEvent.toMap()`."}}}},"responses":{"200":{"description":"Accepted, or already held.","content":{"application/json":{"schema":{"type":"object","required":["ok","id","outcome"],"properties":{"ok":{"type":"boolean","enum":[true]},"id":{"type":"string","description":"The device-minted UUID, echoed back."},"outcome":{"type":"string","enum":["created","updated","duplicate"],"description":"`duplicate` means this record was already held. It is a success: a retry after an ambiguous failure must land here or the handset's queue never drains."}}}}}},"400":{"description":"Malformed JSON, or the payload failed validation. The rejected payload is stored in `mrv_rejections` so it is visible to an administrator rather than lost.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"Missing, malformed, unknown or revoked device key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"409":{"description":"The UUID — or, for a tree, the human-readable code — belongs to a record captured on a different device installation.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"429":{"description":"Over 600 requests per minute for this device. `Retry-After` is set.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure. Worth retrying.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"get":{"tags":["MRV pull"],"summary":"Pull the replantings for this device","description":"Paged in arrival order on the server-assigned `seq`, scoped by the tree's site exactly like the tree pull. Each replanting is its own planting event beside the original, delivered on its own account. **Store these locally as already-synced**: they are the server's records, and offering them back would hit the ownership pin.","security":[{"officerToken":[]}],"parameters":[{"name":"since","in":"query","schema":{"type":"integer","minimum":0,"default":0},"description":"Feed back the previous page's `cursor`."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":500,"default":200}}],"responses":{"200":{"description":"A page of replants.","content":{"application/json":{"schema":{"type":"object","properties":{"replants":{"type":"array","items":{"type":"object"}},"cursor":{"type":"integer","nullable":true,"description":"Pass as `since` to continue."},"hasMore":{"type":"boolean"}}}}}},"401":{"description":"Missing or revoked session.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/boundaries":{"post":{"tags":["MRV sync"],"summary":"Upload a walked plot boundary","description":"Writes to `plot_boundaries`, never to `plots`. The plot is programme reference data: `name`, `code`, `hectares` and `site_id` in the payload are ignored, and an unknown plot is a 404 rather than an invitation to create one.\n\nA re-walk with a newer `boundary_updated_at` becomes a new revision and the previous ring is kept in `plot_boundary_history`. An older or equal timestamp is a replay and a no-op.","security":[{"officerToken":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64},"boundary":{},"boundary_created_at":{"anyOf":[{"type":"null"},{"type":"string","enum":[""]},{"type":"string"}]},"boundary_updated_at":{"anyOf":[{"type":"null"},{"type":"string","enum":[""]},{"type":"string"}]},"boundary_device_id":{"type":"string","maxLength":64,"nullable":true},"boundary_device_name":{"type":"string","maxLength":191,"nullable":true},"boundary_app_version":{"type":"string","maxLength":64,"nullable":true},"boundary_os_version":{"type":"string","maxLength":191,"nullable":true},"boundary_created_by":{"type":"string","maxLength":64,"nullable":true},"boundary_synced_at":{"anyOf":[{"type":"null"},{"type":"string","enum":[""]},{"type":"string"}]},"boundary_clock_offset_seconds":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"nullable":true}},"required":["id","boundary","boundary_created_at","boundary_updated_at","boundary_device_id","boundary_device_name","boundary_app_version","boundary_os_version","boundary_created_by","boundary_synced_at","boundary_clock_offset_seconds"],"description":"`RestorationPlot.toMap()`. Note `boundary` arrives double-encoded — `GeoPoint.encodeAll` produces a JSON *string* of `[[lat, lng], …]`, and the vertex order is latitude-first, the reverse of GeoJSON."}}}},"responses":{"200":{"description":"Accepted, or already held.","content":{"application/json":{"schema":{"type":"object","required":["ok","id","outcome"],"properties":{"ok":{"type":"boolean","enum":[true]},"id":{"type":"string","description":"The device-minted UUID, echoed back."},"outcome":{"type":"string","enum":["created","updated","duplicate"],"description":"`duplicate` means this record was already held. It is a success: a retry after an ambiguous failure must land here or the handset's queue never drains."}}}}}},"400":{"description":"Malformed JSON, or the payload failed validation. The rejected payload is stored in `mrv_rejections` so it is visible to an administrator rather than lost.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"Missing, malformed, unknown or revoked device key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such plot. Plots and sites are created in the admin console.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"409":{"description":"The UUID — or, for a tree, the human-readable code — belongs to a record captured on a different device installation.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"429":{"description":"Over 600 requests per minute for this device. `Retry-After` is set.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure. Worth retrying.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/photos":{"post":{"tags":["MRV sync"],"summary":"Upload evidence photograph bytes","description":"**This response authorises deletion.** The handset reclaims its full-size original on the strength of a 200, so a 200 is a promise that the bytes are durably stored *and* that they re-hash to the claimed SHA-256. Anything less certain must be a non-2xx.","security":[{"officerToken":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["recordId","sha256","photo"],"properties":{"recordId":{"type":"string","description":"UUID of the tree, visit or replanting."},"sha256":{"type":"string","pattern":"^[0-9a-f]{64}$","description":"Computed over the bytes at capture."},"photo":{"type":"string","format":"binary","description":"Max 15 MB; JPEG or PNG."},"recordType":{"type":"string","enum":["tree","visit","replant"],"description":"Optional. Inferred from the id when absent, at the cost of a lookup."}}}}}},"responses":{"200":{"description":"Bytes verified and stored.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"id":{"type":"string"},"recordType":{"type":"string"},"sha256":{"type":"string"}}}}}},"400":{"description":"Missing fields, or `sha256` is not 64 hex characters.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"Missing or revoked device key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No record with that id yet — **retryable**, the row is probably still in the handset's queue.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"413":{"description":"Larger than 15 MB.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"415":{"description":"Not JPEG or PNG.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"422":{"description":"The bytes do not match the claimed hash. Returns `claimed` and `actual`; the original stays on the handset.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Storage or database write failed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/catalog":{"get":{"tags":["MRV pull"],"summary":"Reference data and the officer roster","description":"Sites, plots, species, the programme cadence, and the officers this device may sign in.\n\nThe roster carries `passcode_hash`, `passcode_salt` and `passcode_iterations` — the PBKDF2-HMAC-SHA256 output `services/passcode.dart` produces, **never a passcode**. Shipping the derived key is what makes offline sign-in possible, which also makes this response a credential store in transit: it is scoped to the device's sites, marked `no-store`, and must only be served over TLS.","security":[{"officerToken":[]}],"responses":{"200":{"description":"The catalogue.","content":{"application/json":{"schema":{"type":"object","properties":{"config":{"type":"object","properties":{"monitoring_cadence_days":{"type":"integer","description":"The programme cadence. The server owns this; a handset keeps its own value only as the fallback for an installation that has never synced."}}},"sites":{"type":"array","items":{"type":"object"}},"plots":{"type":"array","items":{"type":"object"}},"species":{"type":"array","items":{"type":"object"}},"officers":{"type":"array","items":{"type":"object"}},"site_ids":{"type":"array","items":{"type":"string"}}}}}}},"401":{"description":"Missing or revoked device key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/photos/missing":{"get":{"tags":["MRV pull"],"summary":"Photographs the server has lost","description":"Record ids in this officer's sites whose rows carry a photo fingerprint the server can no longer back with bytes — no stored photo, or a stored photo whose file is gone. Handsets keep every full-size original after upload and call this once per pull; whatever they still hold the original for goes back on their upload queue and is re-sent through `POST /api/mrv/photos`, which overwrites in place. Scoped exactly like the tree pull.","security":[{"officerToken":[]}],"responses":{"200":{"description":"Ids by record type. Empty arrays when nothing is missing.","content":{"application/json":{"schema":{"type":"object","required":["tree","visit","replant"],"properties":{"tree":{"type":"array","items":{"type":"string"}},"visit":{"type":"array","items":{"type":"string"}},"replant":{"type":"array","items":{"type":"string"}}}}}}},"401":{"description":"Missing or revoked session.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/photos/{recordType}/{recordId}":{"get":{"tags":["MRV pull"],"summary":"Download an evidence photo","description":"The bytes behind a pulled record's photo fingerprint, so a handset can show the real photograph for trees registered on other devices. Scoped exactly like the tree pull: records outside the officer's sites answer 404, indistinguishably from records that do not exist. The `X-Photo-Sha256` response header carries the fingerprint to verify the download against.","security":[{"officerToken":[]}],"parameters":[{"name":"recordType","in":"path","required":true,"schema":{"type":"string","enum":["tree","visit","replant"]}},{"name":"recordId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The image bytes.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"No such record in this officer's sites, or no photo stored for it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/avatars/{id}":{"get":{"tags":["MRV pull"],"summary":"Download an officer's profile photograph","description":"The bytes behind the catalogue's `has_avatar` flag, so the roster and profile screens show real faces instead of initials. Serves FIELD OFFICERS only — the roster never names anyone else, so neither does this; any other user answers 404, indistinguishably from a user with no photograph.","security":[{"officerToken":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The officer's user id, from the catalogue roster."}],"responses":{"200":{"description":"The image bytes.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"No such officer, or no photograph stored.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"post":{"tags":["MRV pull"],"summary":"Upload the officer's own profile photograph","description":"Multipart field `photo` (JPEG, PNG or WebP, up to 2 MB). Accepted only for the officer the session belongs to: any other `id` answers 404 — the same as an unknown officer, and deliberately not 403, which a handset treats as a revoked session. Replaces whatever the console or an earlier upload stored, so the catalogue's `has_avatar` reflects it on every other handset's next pull.","security":[{"officerToken":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The signed-in officer's own user id."}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["photo"],"properties":{"photo":{"type":"string","format":"binary"}}}}}},"responses":{"200":{"description":"Stored.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"storageKey":{"type":"string"}}}}}},"400":{"description":"Not a multipart form, or no `photo` field.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"Not this session's officer.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"422":{"description":"Not an image the server accepts.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/officers/login":{"post":{"tags":["MRV sync"],"summary":"Sign an officer in, and link the handset","description":"The front door. Signing in here is what links a handset to the server: the `officer_token` this returns is the credential every other MRV endpoint expects, minted with a long TTL so a phone out of signal for weeks does not come back to a dead session. Offline verification against the pulled roster remains how a shift starts with no signal.\n\nUnauthenticated by necessity — there is nothing to present before the first sign-in — so the rate limit is load-bearing: 10 attempts per 15 minutes per caller *and* identifier.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"identifier":{"type":"string","minLength":1,"maxLength":255},"passcode":{"type":"string","minLength":1,"maxLength":128}},"required":["identifier","passcode"],"description":"`identifier` is an email or a phone number; spaces and brackets in the number are ignored."}}}},"responses":{"200":{"description":"Verified. `officer_token` is an ordinary session row.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"officer":{"type":"object"},"officer_token":{"type":"string"}}}}}},"400":{"description":"Malformed body.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"An unknown officer and a wrong passcode give the same answer, so the endpoint cannot be used to discover which identifiers exist.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"429":{"description":"Too many attempts.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/mrv/time":{"get":{"tags":["MRV sync"],"summary":"Server clock","description":"**No authentication** — it reveals only the time. The app's `HttpServerClock` does not strictly need this endpoint: it reads the standard `Date` response header from any URL, including a 404, which is why it could ship while the upload API was still unspecified.","responses":{"200":{"description":"The server's clock.","content":{"application/json":{"schema":{"type":"object","properties":{"now":{"type":"string","format":"date-time"}}}}}}}}},"/api/gift/init":{"post":{"tags":["Commerce"],"summary":"Start a tree gift","description":"Records a pending gift and returns what the Paystack popup needs. Payment is confirmed by `/api/gift/verify` or the webhook, whichever arrives first.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"fullName":{"type":"string","minLength":2},"email":{"type":"string","format":"email","pattern":"^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"},"phone":{"type":"string","pattern":"^\\+?[\\d\\s().-]{7,20}$"},"package":{"type":"string","enum":["seed","growth","community","legacy"]}},"required":["fullName","email","phone","package"],"description":"Donor details and chosen package."}}}},"responses":{"200":{"description":"Pending gift created.","content":{"application/json":{"schema":{"type":"object","properties":{"reference":{"type":"string"},"amountKobo":{"type":"integer"},"amountGHS":{"type":"number"},"email":{"type":"string"},"publicKey":{"type":"string"}}}}}},"400":{"description":"Invalid JSON, or validation failed.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/gift/verify":{"post":{"tags":["Commerce"],"summary":"Confirm a payment by reference","description":"Verifies with Paystack, checks the amount and currency match what was recorded, then fulfils — marking the gift paid, auto-provisioning a donor account and emailing them. Idempotent with the webhook; a gift already `success` is returned unchanged.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["reference"],"properties":{"reference":{"type":"string","minLength":4}}}}}},"responses":{"200":{"description":"Fulfilment outcome.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["success","failed","pending","not_found"]},"reference":{"type":"string"},"reason":{"type":"string","description":"e.g. `amount_or_currency_mismatch`."}}}}}},"400":{"description":"Invalid JSON, or missing reference.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No gift with that reference.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/gift/webhook":{"post":{"tags":["Commerce"],"summary":"Paystack webhook","description":"Verified by HMAC SHA-512 over the raw body using the Paystack secret key, compared in constant time. Only `charge.success` fulfils; unrelated references are ignored. Idempotent with `/verify`.","parameters":[{"name":"x-paystack-signature","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"event":{"type":"string","example":"charge.success"},"data":{"type":"object","properties":{"reference":{"type":"string"}}}}}}}},"responses":{"200":{"description":"Received. Always 200 once the signature is valid, so Paystack does not retry.","content":{"application/json":{"schema":{"type":"object","properties":{"received":{"type":"boolean"}}}}}},"400":{"description":"Invalid JSON.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"Signature missing or wrong.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"503":{"description":"`PAYSTACK_SECRET_KEY` is not configured.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/monitoring-due":{"get":{"tags":["Console"],"summary":"Trees owed a monitoring visit","description":"The planning view behind `/admin/monitoring`. Applies the field app's own rule — a tree never visited counts from its planting date; a dead or missing position is excluded, because what it is owed is a replacement rather than a look.\n\nStaff-only: admins **and** field officers, since officers plan rounds too. Read-only; it reports what is owed and schedules nothing.","security":[{"sessionCookie":[]}],"parameters":[{"name":"siteId","in":"query","schema":{"type":"string"}},{"name":"region","in":"query","schema":{"type":"string"}},{"name":"district","in":"query","schema":{"type":"string"}},{"name":"plotId","in":"query","schema":{"type":"string"}},{"name":"species","in":"query","schema":{"type":"string"}},{"name":"cadenceDays","in":"query","schema":{"type":"integer","minimum":1,"maximum":3650},"description":"Override the programme cadence to plan a what-if. Clamped."},{"name":"limit","in":"query","schema":{"type":"integer","maximum":500}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0}},{"name":"sort","in":"query","schema":{"type":"string","enum":["overdue","plot","code"]},"description":"Default `overdue` — most overdue first."},{"name":"group","in":"query","schema":{"type":"string","enum":["plots"]},"description":"Return the per-plot round instead of individual trees."}],"responses":{"200":{"description":"Summary plus trees, or plots when `group=plots`.","content":{"application/json":{"schema":{"type":"object"}}}},"403":{"description":"Not signed in as an admin or field officer.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"500":{"description":"Server-side failure.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/export":{"get":{"tags":["Console"],"summary":"Export a CSV","description":"Trees, visits and replantings mirror the three sheets the field app's own Excel export produces, provenance included, so a coordinator reconciling a handset against the server compares like with like.","security":[{"sessionCookie":[]}],"parameters":[{"name":"type","in":"query","schema":{"type":"string","enum":["gifts","trees","visits","replants","boundaries"],"default":"gifts"}}],"responses":{"200":{"description":"CSV attachment.","content":{"text/csv":{"schema":{"type":"string"}}}},"400":{"description":"Unknown `type`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/photos/{recordType}/{recordId}":{"get":{"tags":["Console"],"summary":"Serve a stored evidence photograph","description":"Admin-only: evidence photographs are not public.","security":[{"sessionCookie":[]}],"parameters":[{"name":"recordType","in":"path","required":true,"schema":{"type":"string","enum":["tree","visit","replant"]}},{"name":"recordId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The image bytes.","content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"Unknown record type.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No photograph for that record, or the file is gone.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/dashboard/logout":{"post":{"tags":["Console"],"summary":"End the current session","description":"Deletes the `sessions` row and clears the cookie, so the session is revoked server-side rather than merely forgotten by the browser. **Deliberately not gated on a session** — signing out when already signed out is not an error, it is the state you asked for, so this is idempotent and answers the same way either way. Requiring a valid session here would turn an expired tab into a page that cannot be dismissed.","responses":{"303":{"description":"Redirects to `/dashboard/login`. Returned whether or not a session existed."}}}},"/api/admin/events":{"get":{"tags":["Console"],"summary":"Live notification and sync-run stream","security":[{"sessionCookie":[]}],"description":"A `text/event-stream` carrying the same data as `/api/admin/notifications`, plus the sync runs currently in flight, pushed as they change. Each `update` event is a JSON body of `{ unread, items, runs }`.\n\n**What this does and does not make faster.** A record being refused, and a handset beginning to deliver, are instantaneous and arrive here at once. A run *finishing* is not an event at all — it is judged by silence, after `SYNC_RUN_IDLE_MINUTES` — so `sync_succeeded` and `sync_failed` are no sooner here than anywhere else.\n\nThe server closes the stream every few minutes on purpose, so that a proxy's own idle limit never gets to; `EventSource` reconnects on its own. A client that cannot hold the stream open should fall back to polling the JSON route — the console does.","responses":{"200":{"description":"An event stream. Ends when the client disconnects or the server recycles the connection.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"403":{"description":"Not an admin session.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/notifications":{"get":{"tags":["Console"],"summary":"The console notification feed","description":"Notifications for the signed-in admin, newest first, with that admin's own read state joined on, plus their unread count for the header badge.\n\n**This GET has a side effect, deliberately.** Before reading, it sweeps sync runs that have gone quiet — closing them, judging them succeeded or failed, and sending the corresponding notification and email. That is not gratuitous: a sync run is inferred rather than declared (`/api/mrv/*` has no batch envelope), so a run *ends* in silence, and silence produces no request to close it in. With no scheduler on this deployment, a console reading its own notifications is the most reliable moment to notice that a handset stopped talking twenty minutes ago — and it is exactly the moment somebody wants to know.\n\nThe sweep is idempotent and safe to run concurrently: closing is a conditional UPDATE that exactly one caller wins, and a unique key on `(sync_run_id, kind)` means a race cannot produce a second notification or a second email. **A cron may call this endpoint purely for the sweep** — that is the supported way to make finished syncs announce themselves when no console happens to be open.","security":[{"sessionCookie":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":30},"description":"Clamped to 1–200."},{"name":"unreadOnly","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"Pass `1` to return only what this admin has not read."}],"responses":{"200":{"description":"The feed and the caller's unread count.","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["sync_started","sync_succeeded","sync_failed"],"description":"Not a closed set in the database — `notification_prefs` is a table, so a new kind is an INSERT rather than a migration. These are the three that exist today."},"severity":{"type":"string","enum":["info","success","warning","danger"]},"title":{"type":"string"},"body":{"type":"string","nullable":true},"href":{"type":"string","nullable":true,"description":"Where the console should send someone who clicks it."},"sync_run_id":{"type":"string","format":"uuid","nullable":true},"device_id":{"type":"string","format":"uuid","nullable":true},"email_to":{"type":"string","nullable":true,"description":"Null when no email was attempted — either the kind has email switched off, or OPS_EMAIL is unset."},"emailed_at":{"type":"string","format":"date-time","nullable":true,"description":"Set only once a send actually returned. Recorded rather than assumed, so a notification whose email bounced is distinguishable from one that was delivered."},"email_error":{"type":"string","nullable":true,"description":"Why the send failed, when it did."},"created_at":{"type":"string","format":"date-time"},"read_at":{"type":"string","format":"date-time","nullable":true,"description":"Per **user**, not per notification: the console is shared, and one coordinator clearing the badge must not clear it for everyone. Null means unread by the caller."}},"required":["id","kind","severity","title","body","href","sync_run_id","device_id","email_to","emailed_at","email_error","created_at","read_at"]}},"unread":{"type":"integer","description":"Unread by THIS admin — the number the bell shows."}},"required":["items","unread"]}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"post":{"tags":["Console"],"summary":"Mark notifications read","description":"Marks one notification read, or everything currently unread. Read state is per user, so this never affects what another coordinator sees. Idempotent — marking an already-read notification is a no-op rather than an error.\n\n`all` marks what is unread **at the moment of the query**, bounded by a SELECT rather than by a timestamp: recording “read up to now” as a time would silently swallow anything written during the round trip.","security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Mark just this one."},"all":{"type":"boolean","description":"Mark everything this admin has not yet read. Takes precedence over `id`."}}}}}},"responses":{"200":{"description":"How many rows were newly marked.","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"marked":{"type":"integer","description":"Zero when everything named was already read."}},"required":["ok","marked"]}}}},"400":{"description":"Body was not JSON, or named neither `id` nor `all`.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/sites":{"get":{"tags":["Reference data"],"summary":"List restoration sites","description":"Each site carries the counts that decide whether it can be deleted.","security":[{"sessionCookie":[]}],"responses":{"200":{"description":"Every site.","content":{"application/json":{"schema":{"type":"object","properties":{"sites":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"},"district":{"type":"string"},"community":{"type":"string"},"plot_count":{"type":"integer"},"tree_count":{"type":"integer","description":"Trees registered here. Non-zero blocks deletion."}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"post":{"tags":["Reference data"],"summary":"Create a restoration site","description":"Region, district and community are required: they are non-null in the app's `RestorationSite`, which builds a `\"community, district\"` locator out of them, so a blank renders as a stray comma on every officer's screen. Omit `id` and a readable one is derived from the name, matching the `site-nsawam` style the app seeds.","security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","region","district","community"],"properties":{"id":{"type":"string","description":"Optional; derived from the name otherwise."},"name":{"type":"string"},"region":{"type":"string"},"district":{"type":"string"},"community":{"type":"string"}}}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"type":"object","properties":{"site":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"},"district":{"type":"string"},"community":{"type":"string"},"plot_count":{"type":"integer"},"tree_count":{"type":"integer","description":"Trees registered here. Non-zero blocks deletion."}}}}}}}},"400":{"description":"A field is blank, or that id is taken. `issues` names the field.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/sites/{id}":{"get":{"tags":["Reference data"],"summary":"Read one site","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The site id."}],"responses":{"200":{"description":"The site.","content":{"application/json":{"schema":{"type":"object","properties":{"site":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"},"district":{"type":"string"},"community":{"type":"string"},"plot_count":{"type":"integer"},"tree_count":{"type":"integer","description":"Trees registered here. Non-zero blocks deletion."}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"patch":{"tags":["Reference data"],"summary":"Edit a site","description":"Omitted fields keep their current value. The **id cannot change**: `trees.site_id` is a plain column with no foreign key, so a rename would silently detach every record carrying it.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The site id."}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"region":{"type":"string"},"district":{"type":"string"},"community":{"type":"string"}}}}}},"responses":{"200":{"description":"The updated site.","content":{"application/json":{"schema":{"type":"object","properties":{"site":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string"},"district":{"type":"string"},"community":{"type":"string"},"plot_count":{"type":"integer"},"tree_count":{"type":"integer","description":"Trees registered here. Non-zero blocks deletion."}}}}}}}},"400":{"description":"A field was blanked. `issues` names it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"delete":{"tags":["Reference data"],"summary":"Delete a site","description":"Refused while any tree or plot references it. Trees carry no foreign key to a site — the database would carry the delete out and strand them — so this check is the only thing preventing it.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The site id."}],"responses":{"204":{"description":"Deleted."},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such site.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"409":{"description":"Trees or plots still reference it. `issues.delete` says which.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/plots":{"get":{"tags":["Reference data"],"summary":"List restoration blocks / plots","security":[{"sessionCookie":[]}],"parameters":[{"name":"site","in":"query","required":false,"schema":{"type":"string"},"description":"Limit to one site."}],"responses":{"200":{"description":"Plots, with planned and surveyed area side by side.","content":{"application/json":{"schema":{"type":"object","properties":{"plots":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"site_id":{"type":"string"},"site_name":{"type":"string","nullable":true},"name":{"type":"string"},"code":{"type":"string"},"hectares":{"type":"number","nullable":true,"description":"Planned area."},"surveyed_hectares":{"type":"number","nullable":true,"description":"What a walked boundary enclosed. Never overwrites the plan."},"vertex_count":{"type":"integer","nullable":true},"revision":{"type":"integer","nullable":true},"walked_at":{"type":"string","nullable":true},"tree_count":{"type":"integer"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"post":{"tags":["Reference data"],"summary":"Create a plot","description":"A plot must exist before a handset can deliver a boundary for it — `POST /api/mrv/boundaries` answers 404 otherwise, because walking a shape does not create reference data. `hectares` is the PLANNED area; a walked boundary reports its own surveyed area alongside and never overwrites it.","security":[{"sessionCookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["site_id","name","code"],"properties":{"id":{"type":"string"},"site_id":{"type":"string"},"name":{"type":"string"},"code":{"type":"string"},"hectares":{"type":"number","nullable":true,"description":"Planned area. Positive, or null."}}}}}},"responses":{"201":{"description":"Created.","content":{"application/json":{"schema":{"type":"object","properties":{"plot":{"type":"object","properties":{"id":{"type":"string"},"site_id":{"type":"string"},"site_name":{"type":"string","nullable":true},"name":{"type":"string"},"code":{"type":"string"},"hectares":{"type":"number","nullable":true,"description":"Planned area."},"surveyed_hectares":{"type":"number","nullable":true,"description":"What a walked boundary enclosed. Never overwrites the plan."},"vertex_count":{"type":"integer","nullable":true},"revision":{"type":"integer","nullable":true},"walked_at":{"type":"string","nullable":true},"tree_count":{"type":"integer"}}}}}}}},"400":{"description":"Blank field, unknown `site_id`, or that id is taken.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/plots/{id}":{"get":{"tags":["Reference data"],"summary":"Read one plot","description":"With `detail=1`, adds the walked ring, its area recomputed from the vertices, whether the path crosses itself, the boundary's revision history, and which trees fall outside it — using the same ray-casting rule as `RestorationPlot.contains` in the app, so the answer matches the handset's.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The plot id."},{"name":"detail","in":"query","required":false,"schema":{"type":"string","enum":["1"]},"description":"Include geometry, history and per-tree containment."}],"responses":{"200":{"description":"The plot, or the full detail when `detail=1`.","content":{"application/json":{"schema":{"type":"object","properties":{"plot":{"type":"object","properties":{"id":{"type":"string"},"site_id":{"type":"string"},"site_name":{"type":"string","nullable":true},"name":{"type":"string"},"code":{"type":"string"},"hectares":{"type":"number","nullable":true,"description":"Planned area."},"surveyed_hectares":{"type":"number","nullable":true,"description":"What a walked boundary enclosed. Never overwrites the plan."},"vertex_count":{"type":"integer","nullable":true},"revision":{"type":"integer","nullable":true},"walked_at":{"type":"string","nullable":true},"tree_count":{"type":"integer"}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such plot.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"patch":{"tags":["Reference data"],"summary":"Edit a plot","description":"Name, code and planned area only. A plot cannot be moved to another site: its trees record `site_id` and `plot_id` independently, so moving it would leave them holding one of each.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The plot id."}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"code":{"type":"string"},"hectares":{"type":"number","nullable":true}}}}}},"responses":{"200":{"description":"The updated plot.","content":{"application/json":{"schema":{"type":"object","properties":{"plot":{"type":"object","properties":{"id":{"type":"string"},"site_id":{"type":"string"},"site_name":{"type":"string","nullable":true},"name":{"type":"string"},"code":{"type":"string"},"hectares":{"type":"number","nullable":true,"description":"Planned area."},"surveyed_hectares":{"type":"number","nullable":true,"description":"What a walked boundary enclosed. Never overwrites the plan."},"vertex_count":{"type":"integer","nullable":true},"revision":{"type":"integer","nullable":true},"walked_at":{"type":"string","nullable":true},"tree_count":{"type":"integer"}}}}}}}},"400":{"description":"A field is invalid. `issues` names it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such plot.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"delete":{"tags":["Reference data"],"summary":"Delete a plot","description":"Refused while any tree references it. The walked boundary cascades; its revision history is removed explicitly, since it carries no foreign key.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The plot id."}],"responses":{"204":{"description":"Deleted."},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such plot.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"409":{"description":"Trees still reference it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/officers/{id}":{"get":{"tags":["Reference data"],"summary":"Read a field officer's profile","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The officer's user id."}],"responses":{"200":{"description":"The officer.","content":{"application/json":{"schema":{"type":"object","properties":{"officer":{"type":"object","properties":{"user_id":{"type":"string"},"full_name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string","nullable":true},"default_site_id":{"type":"string","nullable":true},"status":{"type":"string","enum":["active","disabled"]},"role":{"type":"string"},"photo_url":{"type":"string","nullable":true},"passcode_set_at":{"type":"string","nullable":true},"created_at":{"type":"string"}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such officer.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"patch":{"tags":["Reference data"],"summary":"Edit a field officer's profile","description":"The console's side of the app's edit-profile screen, validated by the same rules the app uses — a loose email pattern that rejects only what plainly is not an address, and a phone of at least seven digits. Both are sign-in credentials, so both are required. Omitted fields keep their current value.\n\n`status` is settable here and nowhere else: the app shows role and status read-only, on the grounds that an app letting a field officer set their own would be recording a claim rather than a fact. Disabling is only half of cutting off access — revoke their device keys too, or a handset keeps working offline until it next pulls the catalogue.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The officer's user id."}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"full_name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string","nullable":true},"default_site_id":{"type":"string","nullable":true},"status":{"type":"string","enum":["active","disabled"]}}}}}},"responses":{"200":{"description":"The updated officer.","content":{"application/json":{"schema":{"type":"object","properties":{"officer":{"type":"object","properties":{"user_id":{"type":"string"},"full_name":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string","nullable":true},"default_site_id":{"type":"string","nullable":true},"status":{"type":"string","enum":["active","disabled"]},"role":{"type":"string"},"photo_url":{"type":"string","nullable":true},"passcode_set_at":{"type":"string","nullable":true},"created_at":{"type":"string"}}}}}}}},"400":{"description":"Invalid field, or the email belongs to another account. `issues` names it.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such officer.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/admin/officers/{id}/sites":{"get":{"tags":["Reference data"],"summary":"Where an officer is assigned to work","description":"Returns every site with why it is, or is not, in scope. Scope is the union of three sources and **only `assigned` is editable**: a site is also in scope if it is the officer's default, or if they already have records at it — an administrative omission should not lock someone out of a site they demonstrably worked.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The officer's user id."}],"responses":{"200":{"description":"Sites, assignments and effective scope.","content":{"application/json":{"schema":{"type":"object","properties":{"sites":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string","nullable":true},"district":{"type":"string","nullable":true},"assigned":{"type":"boolean","description":"Ticked here. The only editable source."},"is_default":{"type":"boolean","description":"Their default site — in scope regardless."},"has_records":{"type":"boolean","description":"They already have records here — in scope regardless."},"in_scope":{"type":"boolean"}}}},"assigned":{"type":"array","items":{"type":"string"}},"in_scope":{"type":"array","items":{"type":"string"}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such officer.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"put":{"tags":["Reference data"],"summary":"Set an officer's site assignments","description":"PUT, not PATCH: the body carries the complete set. A partial update would leave an officer scoped to somewhere nobody chose.\n\nThis scopes the tree register their handset pulls (`pullScopeForDevice`) and which officers' passcode hashes the catalogue will hand that device. It does **not** hide sites from the app's picker — sites, plots and species stay unscoped on purpose, so an officer reassigned mid-shift does not find the picker empty. A handset picks the change up at its next catalogue pull, not before.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The officer's user id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["site_ids"],"properties":{"site_ids":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"The new scope.","content":{"application/json":{"schema":{"type":"object","properties":{"sites":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"region":{"type":"string","nullable":true},"district":{"type":"string","nullable":true},"assigned":{"type":"boolean","description":"Ticked here. The only editable source."},"is_default":{"type":"boolean","description":"Their default site — in scope regardless."},"has_records":{"type":"boolean","description":"They already have records here — in scope regardless."},"in_scope":{"type":"boolean"}}}},"assigned":{"type":"array","items":{"type":"string"}},"in_scope":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"`site_ids` is not an array, or names a site that does not exist.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"403":{"description":"Not signed in as an admin.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such officer.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/my-photos/{recordType}/{recordId}":{"get":{"tags":["Console"],"summary":"Serve an evidence photograph to the donor who paid for it","description":"The donor-facing counterpart to `/api/photos/*`, which is admin-only and stays that way.\n\nOwnership is resolved before the bytes are located: the record must belong to a tree allocated to one of *this* signed-in donor's successful gifts. A record that exists but belongs to somebody else returns **404**, identical to one that does not exist, so an id cannot be probed for existence.","security":[{"sessionCookie":[]}],"parameters":[{"name":"recordType","in":"path","required":true,"schema":{"type":"string","enum":["tree","visit","replant"]}},{"name":"recordId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The image bytes. `Cache-Control: private`.","content":{"image/png":{"schema":{"type":"string","format":"binary"}},"image/jpeg":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"Unknown record type.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"No such photograph, or it is not this donor's.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/avatars/{id}":{"get":{"tags":["Console"],"summary":"Serve a user's profile photograph","description":"Signed-in only, and deliberately not restricted further: a donor's own avatar renders on their dashboard and a coordinator's renders in the console header, so any authenticated user may read any avatar. It is not public, because a photograph of a named person should not be available to anybody who guesses a UUID.\n\nUploads go through the console's own form, not this route — the bytes are validated by sniffing their magic numbers rather than trusting the `Content-Type` a client attaches.","security":[{"sessionCookie":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The user id."}],"responses":{"200":{"description":"The image bytes. `Cache-Control: private`.","content":{"image/png":{"schema":{"type":"string","format":"binary"}},"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/webp":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"Not signed in.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}},"404":{"description":"That account has no photograph.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}},"/api/openapi.json":{"get":{"tags":["Console"],"summary":"This document","description":"The machine-readable OpenAPI description of everything above, generated from the same zod schemas the routes validate against so it cannot drift from the implementation. Public and unauthenticated: it describes the contract, it carries no data.","responses":{"200":{"description":"An OpenAPI 3.0.3 document.","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/{unmatched}":{"get":{"tags":["Console"],"summary":"Any unknown API path","description":"A catch-all so that a mistyped or retired endpoint answers in JSON rather than falling through to the site's HTML 404 page. A client that asked for JSON and received `<!DOCTYPE html>` fails with `SyntaxError: Unexpected token '<'`, which says nothing about the real mistake.\n\n**`retryable` is `false`, and that is the point.** `SyncRepository` on the handset treats a non-2xx as retryable and keeps the record; a path that does not exist will not begin to exist, so a device must be told to stop rather than re-post into a void on every sync forever.\n\nEvery method answers the same way. Static and dynamic segments both outrank a catch-all in Next's matcher, so this can never shadow a real endpoint.","parameters":[{"name":"unmatched","in":"path","required":true,"schema":{"type":"string"},"description":"Whatever path was requested."}],"responses":{"404":{"description":"No endpoint at that path. Never retryable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code, e.g. `invalid_payload`."},"retryable":{"type":"boolean","description":"Whether offering the identical request again could plausibly succeed. The current field client ignores this and retries everything; it is here so a future one need not guess."},"detail":{"type":"string"},"issues":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"message":{"type":"string"}}}}}}}}}}}}}}