# FRONTEND_GUIDE.md — build your own frontend

The Update platform is **API-first**: the bundled admin portal is just one client with no special
privileges. Your organization can build any frontend you like against the same API — a public
live-updates page for your website, a fully custom moderation console, or a native mobile app.

This guide covers the two integration modes, the auth for each, and the endpoints you'll use. Full
endpoint detail is in [API.md](API.md).

- **Base URL:** `https://YOUR-HOST/update/api`
- **Two modes:** [A) Public reader](#mode-a--public-reader-frontend) · [B) Custom console](#mode-b--custom-management-console)

---

## Which mode do I need?

| You want to… | Mode | Auth |
|---|---|---|
| Show approved updates on your website / app | **A** | API key (read-only, org-scoped) |
| Let readers like / follow | **A** | API key |
| Build your own moderation / event-management UI | **B** | Org-user JWT (+ refresh) |
| Post updates from a custom app / on-ground tool | **B** | Org-user JWT |

You can use both at once (a public page **and** a private console).

---

## Mode A — public reader frontend

Everything readers see. Authenticated with an **API key** created in the portal
(*API keys & integration*). The key is read-only and scoped to your org, so it's safe to ship in
client-side code. Send it as `X-API-Key` (or `?key=` for `<script>` tags).

### Endpoints
| Endpoint | Gives you |
|---|---|
| `GET /public/events` | Your org's live + recently-closed events (for a sidebar list). |
| `GET /public/events/{code}` | Event metadata + stats `{ updates, voices, likes }` (cover header). |
| `GET /public/events/{code}/updates?limit=&before=` | The feed (approved only, keyset-paginated, `ETag`-cacheable). |
| `GET /public/events/{code}/voices?limit=` | Top voices (most active contributors). |
| `GET /public/events/{code}/top?limit=` | Top moments (most-liked updates). |
| `POST` / `DELETE /public/updates/{id}/like` | Like / unlike (anonymous, `voter_token`). |
| `POST /public/events/{code}/follow` | Follow/subscribe (`email` or `device_token`). |

This is enough to rebuild the classic CSCAfrica live page: cover header (title, `#hashtag`,
description, `updates · voices · likes`), a middle feed that polls for new items, and right-hand
"top voices" / "top moments" columns.

### Auto-refresh ("↑ N new updates")
Poll `GET /public/events/{code}/updates?limit=N` on an interval. To detect new items, remember the
highest `id` you've shown; anything with a larger `id` is new. Use `ETag` + `If-None-Match` to get
cheap `304`s when nothing changed. (A server push channel — SSE/websockets — isn't available yet.)

### The turnkey option
If you don't want to build the feed rendering yourself, drop the **widget** instead — one
`<script>` tag renders text, images, video, location links, and like buttons. See
[EMBED_GUIDE.md](EMBED_GUIDE.md). Mode A is for when you want full control of the markup.

### Minimal example
```js
const API = "https://YOUR-HOST/update/api";
const KEY = "upd_your_public_key";
const h = { "X-API-Key": KEY };

const meta = await (await fetch(`${API}/public/events/NGR2027`, { headers: h })).json();
// meta.event.title, meta.stats.{updates,voices,likes}

const feed = await (await fetch(`${API}/public/events/NGR2027/updates?limit=20`, { headers: h })).json();
feed.updates.forEach(renderUpdate);   // {id,text,type,media_type,media_url,location,likes,author,submitted_at}

// like an update (dedupe with a token you persist per visitor)
await fetch(`${API}/public/updates/${id}/like`, {
  method: "POST", headers: { ...h, "Content-Type": "application/json" },
  body: JSON.stringify({ voter_token: myVisitorToken })
});
```

A complete, styled sample page is at [`examples/reader-demo.html`](examples/reader-demo.html) —
paste your key and open it.

---

## Mode B — custom management console

Replace the admin portal with your own UI: login, dashboard, event CRUD, moderation, contributors,
API keys, direct submission. Authenticated with an **org-user JWT**.

### Auth flow (with refresh)
```js
// 1. Login -> access token (8h) + refresh token (30d)
const r = await (await fetch(`${API}/auth/login`, {
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password })
})).json();
let access = r.data.token, refresh = r.data.refresh_token;   // r.data.expires_in = seconds

// 2. Call the API with the access token
const events = await (await fetch(`${API}/events`, {
  headers: { Authorization: `Bearer ${access}` }
})).json();

// 3. On a 401, refresh once and retry (rotating refresh token)
async function refreshAccess() {
  const j = await (await fetch(`${API}/auth/refresh`, {
    method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh_token: refresh })
  })).json();
  if (!j.ok) throw new Error("session expired");
  access = j.data.token; refresh = j.data.refresh_token;     // store the rotated pair
}

// 4. Logout revokes the refresh token
await fetch(`${API}/auth/logout`, {
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ refresh_token: refresh })
});
```
Store the refresh token where it survives reloads (e.g. `localStorage`). Access tokens are
stateless JWTs; refresh tokens are DB-backed and **rotate on every use** (an old refresh token is
revoked once used — reusing it returns `401`).

### Endpoints you'll use
Events `GET/POST/PUT/DELETE /events…`, moderation `GET /events/{id}/updates`,
`POST /updates/{id}/approve|reject`, `PUT /updates/{id}`, `POST /updates/bulk`, submission
`POST /updates`, uploads `POST /uploads`, contributors `GET /contributors`,
`POST /contributors/{id}/flag`, keys `GET/POST /apikeys`. See [API.md](API.md) for full detail —
your console calls exactly what the bundled portal calls.

### ⚠️ CORS — register your console's domain
If your console runs in a **browser on a different origin** than the API (e.g.
`https://console.yourorg.org` calling `https://updates-host/api`), you must register that origin,
or the browser will block the authenticated calls.

In the portal → **API keys & integration → Allowed origins**, add your console's origin(s), e.g.:
```
https://console.yourorg.org
http://localhost:5173
```
(or `PUT /org/settings { "allowed_origins": [...] }`). Format is `scheme://host[:port]` with no
path. Once registered, the API returns the right CORS headers (including `Authorization`) for those
origins. **You do not need this if** your console is same-origin with the API, or if your frontend
is server-rendered / calls the API from your own backend.

> Auth uses Bearer tokens, not cookies, so there's no `Allow-Credentials` and no CSRF surface — the
> token is the security boundary; the origin list just controls which browser origins may read
> responses.

### Posting updates from a custom app
`POST /updates` takes an explicit `event_id`/`event_code` + `content` (and optional `media_url`
from `POST /uploads`, `media_type`, `latitude`/`longitude`). This is the same generic write path
the future "Line" mobile app will use — build a mobile client the same way you'd build the console.

---

## Rate limits, caching, errors
- **Reads** aren't rate-limited; **writes/ingestion** are (per contributor+event and per IP — 429
  `rate_limited`). See [API.md §4](API.md#4-rate-limiting).
- Public feed responses carry `Cache-Control` + `ETag`; use `If-None-Match`.
- Errors are always `{ ok:false, error:{ code, message } }` with a matching HTTP status.

## Checklist
- [ ] Public page → create an **API key**, call `/public/*`.
- [ ] Custom console → use `/auth/login` + refresh; if cross-origin in a browser, **register your
      origin** under Allowed origins.
- [ ] Media → upload via `/uploads`, pass the returned URL as `media_url`.
- [ ] Never put a JWT or admin credentials in public client code — only the read-only API key is
      meant to ship to browsers.
