# Genesis backend — API reference

Base URL: `https://api.genesiscoworkingspace.com.my` (dev: `http://localhost:4000`).
No version prefix. All request/response bodies are JSON unless noted.

- **Public** endpoints — consumed by the marketing landing page. Frozen by
  `docs/API-CONTRACT.md`; the exact response shapes there are authoritative.
- **Admin** endpoints (`/admin/*`) — consumed by the admin portal SPA. Documented
  here; the request shapes are the zod schemas in
  `src/modules/*/‌*.schema.ts` (this doc mirrors them — if they disagree, the code
  wins, please file it).

> Machine-readable OpenAPI is not generated yet. The shapes below are complete and
> current; a `zod-to-openapi` pass is the intended follow-up.

---

## Auth model

|               | Access token                                               | Refresh token                                                                    |
| ------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
| form          | short-lived JWT (`ACCESS_TOKEN_TTL`, default 15m)          | opaque, 30d                                                                      |
| transport     | `Authorization: Bearer <token>` header, held in SPA memory | `httpOnly; Secure; SameSite=None` cookie `gcs_admin_refresh`, `Path=/admin/auth` |
| obtained from | `POST /admin/auth/login`, `POST /admin/auth/refresh`       | same                                                                             |

The refresh cookie is only sent to `/admin/auth/*`. Call `POST /admin/auth/refresh`
(with credentials) when an access token 401s, then retry. Rotation is enforced:
replaying a already-rotated refresh token revokes the entire session family.

CORS: `ADMIN_ORIGINS` allowlist, `Access-Control-Allow-Credentials: true`. Send
`credentials: 'include'` on every `/admin/*` request.

### Error envelopes

Most errors: `{ "error": string, "requestId": string, "fields"?: Record<string,string> }`
with the matching HTTP status.

`POST /enquiries` only, on validation failure (`422`):
`{ "ok": false, "error": string, "fields": Record<string,string> }` (discriminated
union with the success body).

| status | meaning                                                                                          |
| ------ | ------------------------------------------------------------------------------------------------ |
| 400    | malformed request (bad JSON, failed schema) — `fields` maps offending keys                       |
| 401    | missing / invalid / expired access token                                                         |
| 403    | authenticated but not allowed (e.g. non-superadmin hitting user management)                      |
| 404    | no such resource                                                                                 |
| 413    | upload over 25 MB                                                                                |
| 422    | semantic validation failure (duplicate phone/email, honeypot-adjacent)                           |
| 429    | rate limited                                                                                     |
| 5xx    | unexpected — `error` is always the generic string, detail is in the server log under `requestId` |

---

## Public

### `GET /health`

`200` → `{ status, db: { status, latencyMs }, sha, uptimeSec, node }`. `503` if the DB ping fails.

### `GET /spaces/:slug/gallery`

`slug` ∈ `genesis | hive`. Optional `?category=<slug>`. Unauthenticated.
`200` → `SpaceGallery` (see `docs/API-CONTRACT.md`). Unknown slug → `404`.
`Cache-Control: public, max-age=300, stale-while-revalidate=86400`.

### `POST /enquiries`

Body: `{ name, phone, email, message, source?, website?, token? }` (see
`docs/API-CONTRACT.md`). `200` → `{ ok: true, id }`. Validation failure → `422`
`{ ok: false, error, fields }`. Over the rate limit → `429`. A tripped honeypot
still returns `200 { ok: true, id }`.

---

## Admin — auth (`/admin/auth`)

| Method & path                      | Body                  | Success                                                                                                                                                                                                         |
| ---------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /admin/auth/login`           | `{ phone, password }` | `200` `{ accessToken, admin }` + `Set-Cookie` refresh. Bad credentials → `401` (generic). Rate limited per IP + per phone → `429`. 10 failures → 15-min lockout.                                                |
| `POST /admin/auth/refresh`         | — (refresh cookie)    | `200` `{ accessToken, admin }` + rotated cookie. Reused/expired/absent → `401`.                                                                                                                                 |
| `POST /admin/auth/logout`          | — (refresh cookie)    | `200` `{ ok: true }`, clears the cookie.                                                                                                                                                                        |
| `POST /admin/auth/forgot-password` | `{ email }`           | **always `200` `{ ok: true }`** (no account-enumeration). Emails a 60-min reset link if an account with that email exists and is active. Looked up by email, not phone — the link is delivered by email anyway. |
| `POST /admin/auth/reset-password`  | `{ token, password }` | `200` `{ ok: true }`. Consumes the token (works for both password-reset and admin-invite tokens), sets the password, revokes all sessions, clears lockout. Invalid/expired token → `400`.                       |
| `GET /admin/auth/me`               | — (Bearer)            | `200` `{ admin }`.                                                                                                                                                                                              |

`admin` object: `{ id, name, phone, email, role: 'superadmin'|'admin', status: 'active'|'inactive' }`.

---

## Admin — media (`/admin/media`) — any authenticated admin

| Method & path                       | Request                                                                                       | Success                                                                                                                     |
| ----------------------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `GET /admin/media/categories`       | —                                                                                             | `200` `{ categories: [{ id, slug, label }] }`                                                                               |
| `GET /admin/media`                  | query: `page` (**required**, `genesis\|hive`), `category?` (slug), `visible?` (`0\|1`)        | `200` `{ images: MediaImage[] }` — page order, **hidden included**                                                          |
| `POST /admin/media`                 | `multipart/form-data`: `file` (image, ≤25 MB) + `page`, `alt`, `category?` (slug), `caption?` | `201` `{ image: MediaImage }` with real dimensions + generated variants. Non-image / bad category → `422`; oversize → `413` |
| `PATCH /admin/media/:id`            | `{ alt?, caption?, category? }` (`category`: slug or `null`)                                  | `200` `{ image }`                                                                                                           |
| `PATCH /admin/media/:id/visibility` | `{ isVisible: boolean }`                                                                      | `200` `{ image }`                                                                                                           |
| `PATCH /admin/media/order`          | `{ page, ids: string[] }` — the **complete** ordered id list for that page                    | `200` `{ ok: true }`. Id set must match exactly → else `422`                                                                |
| `DELETE /admin/media/:id`           | —                                                                                             | `204`. Soft delete; the image leaves the public gallery and the admin list, files are retained 30 days                      |

`MediaImage`: `{ id, page, category, alt, caption, order, isVisible, status,
width, height, src, variants: { avif: [{url,width}], webp: [{url,width}] },
createdAt, updatedAt }`. `src` and every `variants[].url` are absolute
(`ASSET_BASE_URL`-prefixed).

---

## Admin — enquiries (`/admin/enquiries`) — any authenticated admin

| Method & path                       | Request                                                                                                                                                                            | Success                                                                                                                                                                                                                                                |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /admin/enquiries`              | query: `status?`, `source?`, `q?` (substring: name/email/phone/message), `from?`/`to?` (`YYYY-MM-DD` or ISO, UTC), `page?` (1), `perPage?` (1–100, 20), `sort?` (`newest\|oldest`) | `200` `{ data: EnquiryListItem[], meta: { page, perPage, total } }`                                                                                                                                                                                    |
| `GET /admin/enquiries/:id`          | —                                                                                                                                                                                  | `200` `{ enquiry: EnquiryDetail }`. **Side effect: `new` → `read`.** `404` if unknown                                                                                                                                                                  |
| `POST /admin/enquiries/:id/replies` | `{ body: string }` (1–5000)                                                                                                                                                        | `201` `{ enquiry: EnquiryDetail }` (refreshed, includes the new reply). Persists the reply, sets the enquiry to `replied`, emails the enquirer. **The email outcome is recorded on the reply (`deliveryStatus`); a bounce does not fail the request.** |
| `PATCH /admin/enquiries/:id`        | `{ status }` ∈ `read \| replied \| spam \| archived` (`new` is not settable → `400`)                                                                                               | `200` `{ enquiry: EnquiryDetail }`                                                                                                                                                                                                                     |

`EnquiryListItem`: `{ id, name, email, phone, message, source, status,
spamScore, spamReason, replyCount, notifiedAt, createdAt, updatedAt }`.

`EnquiryDetail` = `EnquiryListItem` + `{ userAgent, referer, ipHash, replies: [{
id, body, author: { id, name }, deliveryStatus: 'pending'|'sent'|'failed',
deliveryError, sentAt, createdAt }] }`.

---

## Admin — users (`/admin/users`) — **superadmin only**, except `/password`

| Method & path                    | Access                              | Request                                                                                                                                                                                   | Success                                                                                                                                                                                          |
| -------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /admin/users`               | superadmin                          | query: `q?`, `status?`, `role?`, `page?`, `perPage?`, `sort?`                                                                                                                             | `200` `{ data: User[], meta }`                                                                                                                                                                   |
| `GET /admin/users/:id`           | superadmin                          | —                                                                                                                                                                                         | `200` `{ user: User }`                                                                                                                                                                           |
| `POST /admin/users`              | superadmin                          | `{ name, phone, email, role?, password }` — the superadmin sets the initial password directly                                                                                             | `201` `{ user }`, account usable immediately (no invite email/token). Duplicate phone/email → `422`                                                                                              |
| `PATCH /admin/users/:id`         | superadmin                          | `{ name?, email?, phone?, role?, status? }`                                                                                                                                               | `200` `{ user }`. A role/status change ends that user's sessions. Guard rails → `400`: can't change your own role, can't deactivate yourself, can't demote/deactivate the last active superadmin |
| `POST /admin/users/:id/password` | **self** (own id) **or** superadmin | self: `{ currentPassword, newPassword }` (verifies current, rotates, signs out other sessions). superadmin on another id: `{}` — emails a reset link. Regular admin on another id → `403` | `200` `{ ok: true }`                                                                                                                                                                             |

`User`: `{ id, name, phone, email, role, status, hasPassword, lastLoginAt,
createdBy, createdAt, updatedAt }`. `hasPassword: false` ⇒ the invite hasn't been
accepted and the account can't log in yet.
