# Chavivim Lost & Found API — integration guide

For developers wiring an admin tool (the mobile dispatch app, the central portal) or the **phone system** into the Chavivim Lost & Found system. Pair this with the interactive spec on this site (`openapi.yaml`, the Swagger page) — this guide is the prose + copy-paste; Swagger is for trying calls live.

> Just need the phone-line intake? Jump to [Voice submissions (VoIP / phone)](#voice-submissions-voip--phone).

---

## TL;DR

- **Base URL:** `https://kjchavivim.org/api/v1` — any division host works (`https://chavivim.org/api/v1` too); the API is identical on every host.
- **Auth:** `Authorization: Bearer cvk_…` on **every** request. Keep the token server-side.
- **Cross-tenant by design.** Lost & Found is **one shared board** across every division — items are **not** tenant-scoped. A `location_key` (a division slug) is just a geographic tag for filtering/labelling, not an ownership boundary.
- **Format:** JSON in / JSON out. Image and audio uploads are `multipart/form-data`.
- **Moderation:** public/phone submissions land as `pending`; an admin approves before they show on the public board.

---

## Scopes

`lost_found:read`, `lost_found:write`, `lost_found:delete` (or `lost_found:*`). A 403 with `code: "insufficient_scope"` means the key lacks the scope.

| action | scope |
|---|---|
| any `GET` (list/get/locations/poster/claims) | `lost_found:read` |
| create, edit, status, image upload, claims, **voice submissions** | `lost_found:write` |
| delete an item | `lost_found:delete` |

---

## The item model

```jsonc
{
  "id": "lf_…",
  "kind": "lost" | "found",
  "title": "…",                       // max 120
  "description": "…",                 // max 1500
  "location": "near the main shul",   // free-text "where" (max 120, required)
  "location_key": "kj" | null,        // division slug from /locations, or null
  "image_url": "/media/shared/…" | null,
  "date_event": "2026-06-09" | null,  // ISO date the item was lost/found
  "submitter_name": "…",              // admin-only
  "submitter_phone": "…",             // admin-only
  "status": "pending" | "approved" | "rejected" | "resolved",
  "submitted_at": 1780000000000,      // unix ms
  "reviewed_at":  1780000000000 | null,
  "reviewed_by":  "api:<key name>" | "<admin email>" | null,
  "submitted_from_tenant": "kj",      // audit only
  "source": "web" | "voip",           // 'voip' = phoned-in voice message
  "audio_url": "/media/shared/voip-….wav" | null,  // admin-only
  "transcript": "…" | null            // admin-only; best-effort STT of a voice clip
}
```

**Privacy:** `submitter_name`, `submitter_phone`, `audio_url`, and `transcript` are returned by this authenticated API but are **stripped from the public board** (`GET /api/lost-found/list`) — the public routes contact through division dispatch, never a member's number, and a raw voicemail can contain the caller's spoken phone number.

---

## Endpoints & flows (copy-paste)

`$T` = your token. Set `Authorization: Bearer $T` on every call (omitted below for brevity). `$B` = `https://kjchavivim.org/api/v1`.

### Locations (the division taxonomy)
```
GET /lost-found/locations
→ { "divisions": [ { "key":"kj", "label":"Orange County (Kiryas Joel / Monroe)" }, …, { "key":"other", "label":"Other" } ] }
```
Use a `key` as `location_key`. The `other` sentinel (or any unknown key) stores `null` — i.e. "no division".

### List / search
```
GET /lost-found/items?status=pending&kind=found&location_key=kj&source=voip&q=keys&limit=100
→ { "items": [ …full rows… ], "count": N }
```
- `status`: `pending|approved|rejected|resolved|all` (omit/`all` = every status).
- `kind`: `lost|found`. `location_key`: a division slug. `source`: `web|voip`.
- `source=voip&status=pending` is the **voice review queue**.
- `q`: free-text across title/description/location (scans the first `limit` rows). `limit`: 1–500, default 200.

### Create
```
POST /lost-found/items
{ "kind":"found", "title":"Set of keys", "description":"…",
  "location":"near the main shul", "location_key":"kj",
  "submitter_name":"…", "submitter_phone":"…",
  "date_event":"2026-06-09", "image_url":"/media/shared/…", "status":"approved" }
→ 201 { "item": {…} }
```
`kind`, `title`, `description`, `submitter_name`, `submitter_phone`, and a free-text `location` are required. `status` defaults to `approved` for API callers (trusted moderator) — pass `"pending"` to route it through review.

### Get one (with its claims)
```
GET /lost-found/items/{id}   → { "item": {…}, "claims": [ … ] }
```

### Edit and/or change status (one call)
```
PATCH /lost-found/items/{id}
{ "kind":"lost", "title":"…", "description":"…", "location":"…",
  "location_key":"kj", "date_event":"2026-06-09", "status":"approved" }
→ { "item": {…} }
```
Send any subset; `status` may be folded in. At least one editable field or `status` is required. Empty-string / `other` `location_key` clears the division.

### Status only
```
POST /lost-found/items/{id}/status   { "status":"approved" }   // or rejected | resolved | pending
→ { "item_id":"…", "status":"approved" }
```

### Delete  (scope `lost_found:delete`)
```
DELETE /lost-found/items/{id}   → { "item_id":"…", "deleted": true }
```
Hard delete; cascades to the item's claims.

### Image upload
```
POST /lost-found/items/{id}/image      (multipart/form-data, part "image")
→ 201 { "item": {…}, "image_url":"/media/shared/lf-…" }
```
Max 15 MB; jpeg/png/webp/gif/heic/heif. Stored in R2 and set as the item's `image_url`.

### Social poster (render spec)
```
GET /lost-found/items/{id}/poster?tenant=kj
→ { item_id, variant:"lost|found|reunited", tenant:{…}, … }   // a 1080×1920 render spec
```

### Claims (someone recognises an item)
```
GET  /lost-found/items/{id}/claims     → { "item_id":"…", "claims":[ … ] }
POST /lost-found/items/{id}/claims     { "claimant_name":"…", "claimant_phone":"…", "message":"…" }
→ 201 { "claim": {…} }
POST /lost-found/claims/{id}/status    { "status":"connected" }   // pending | connected | dismissed
```
A claim is a contact-relay record — the admin connects the claimant with the original submitter; civilian numbers are never auto-shared.

---

## Voice submissions (VoIP / phone)

When a caller dials the lost & found line and presses "2 to leave a message", the phone system (FreePBX) POSTs the recording here. It lands as a **pending stub** item with the clip attached; an admin reviews it like any other submission. **No new review endpoints** — the app reuses the list / PATCH / status endpoints above.

```
POST /lost-found/voice-submissions        (scope lost_found:write)
Content-Type: multipart/form-data
```

| field | type | req | notes |
|---|---|---|---|
| `audio` | file | ✅ | WAV 8 kHz mono PCM typical, 5–120 s, ≤ 10 MB. mp3/ogg/m4a/webm accepted; a bare `application/octet-stream` is accepted and treated as WAV. |
| `caller_phone` | string | – | May be empty/withheld. Stored as the item's `submitter_phone`. |
| `did` | string | – | The inbound number dialed (recorded on the R2 object metadata). |
| `received_at` | integer | – | Unix **ms** the message was recorded; defaults to server time → the item's `submitted_at`. |
| `duration_sec` | integer | – | Clip length, if known (noted in the description). |
| `source` | string | – | Always `voip` (defaulted server-side regardless). |

What the PBX uploader sends:
```sh
curl -X POST "$B/lost-found/voice-submissions" -H "Authorization: Bearer $T" \
  -F "audio=@/var/spool/asterisk/lostfound/20260609-171500.wav;type=audio/x-wav" \
  -F "caller_phone=8451234567" -F "did=3155492200" \
  -F "received_at=1780000000000" -F "duration_sec=18" -F "source=voip"
→ 201 { "item": { "id":"lf_…", "status":"pending", "source":"voip",
                  "audio_url":"/media/shared/voip-….wav", "transcript":"…"|null, … } }
```

**How it behaves:**
- **Pending + unmapped.** The caller types nothing, so `kind` is a placeholder (`found`), `location_key` is `null`, and `title`/`location` are placeholders. The admin sets the real values on review, then approves. Nothing is public until approved.
- **Transcription is best-effort + non-blocking.** The clip is transcribed with Workers AI Whisper. If it's unavailable / over quota / errors, the item is still created with the audio and `transcript: null`. Treat the transcript as a hint — the admin always plays the clip.
- **Language: auto-detect, tuned for this line.** It handles Yiddish, English, and code-switched/mixed speech. Because Whisper frequently mislabels Yiddish as German (they're close, and Yiddish is low-resource), a detected **German / Dutch / Afrikaans** result is automatically **re-transcribed forcing Yiddish** (Hebrew script) — so you get Yiddish, not a German romanisation. English is detected correctly and left as-is. Known non-speech hallucinations ("you", "thank you", ".") are dropped so a silent clip keeps the clean "Voice submission" title.

**The review side (no new endpoints):**
```
GET   /lost-found/items?source=voip&status=pending     # the voice queue  (lost_found:read)
        → play item.audio_url
PATCH /lost-found/items/{id}  { kind, title, description, location, location_key, date_event }
POST  /lost-found/items/{id}/status  { "status":"approved" }   # or "rejected"
```

---

## Errors

Uniform envelope:
```
{ "error": { "code": "insufficient_scope", "message": "…" } }
```
Common codes: `unauthorized` (401), `insufficient_scope` (403), `not_found` (404), `invalid_field` / `invalid_json` / `invalid_content_type` (400), `image_too_large` / `audio_too_large` / `unsupported_type` (400), `empty_patch` (400), `method_not_allowed` (405), `no_database` / `no_media` (503). Transcription failures do **not** error — they just null the transcript.

---

## Gotchas

- **Cross-tenant board** — there's no per-division data isolation; `location_key` only filters/labels. Don't expect a `tenant=` scoping like the blog has.
- **Get is by id only** — to find by something else, list and match client-side.
- **Hard delete**, no restore (claims cascade).
- Keep the token **server-side**; it's stored only as a hash and shown once at mint.
- `audio_url` / `transcript` / submitter contact are admin-only — never surface them on a public page.
