# vt-api → Flutter alignment spec

**Audience:** the Claude agent working on the Viață la Țară Flutter app (Android / iOS / Web).
**Source of truth:** the vt-api backend on branch `fix-plan` (the commit that adds this file),
verified against the code and by calling the running API. Where older docs (`FLUTTER_API_INTEGRATION.md`,
`docs/TASKS.md`, `VT_API_PROMPT.md`) disagree with this file, **this file wins**.

## How to use this document

1. Read §1–§3 first — they apply to every request.
2. For each feature in the app, compare its models (§4) and calls (§5–§7) with what is written here:
   paths, methods, body keys, query keys, status codes, response shapes, error codes and messages.
3. §9 lists what **changed recently** on the server — those are the most likely mismatches.
4. §10 lists the few **remaining server quirks** the app must tolerate. Handle them as described.
5. Finish with the checklist in §11.

Conventions in this file: `?` after a key = optional; **absent** = the key is not in the JSON at all;
`null` = the key is present with value `null`. Treat both as "no value" when parsing.

---

## 1. Global conventions

### 1.1 Base URL and transport

- REST base: `https://<host>/api` (dev default `http://localhost:3838/api`). Health: `GET /health` → `{ "data": { "status": "ok" }, "error": null }`.
- WebSocket (chat realtime): `wss://<host>/ws` (dev `ws://localhost:3838/ws`). See §7.
- Uploaded images are served publicly at `https://<host>/uploads/<uuid>.webp` (no auth, cache-forever).
- JSON only (`Content-Type: application/json`), except `POST /api/uploads` (multipart).
- **JSON body limit: 100 KB** → larger bodies get `413 PAYLOAD_TOO_LARGE`. Malformed JSON gets
  `400 BAD_REQUEST`.
- Flutter **Web**: the web origin must be in the server's `CORS_ORIGINS` allowlist, otherwise requests get
  `403 FORBIDDEN` `Originea cererii nu este permisă.`. Mobile apps send no `Origin` and are unaffected.

### 1.2 Envelope — every response, success and error

```jsonc
// success
{ "data": <payload>, "error": null }
// failure
{ "data": null, "error": { "code": "VALIDATION_ERROR", "message": "…", "details": [ { "field": "body.phone", "message": "…" } ] } }
```

Exceptions: **`204 No Content` has no body at all** (`POST /auth/logout`, `/auth/forgot-password`,
`/auth/reset-password`). Never JSON-decode a 204.

### 1.3 Data shapes

| Shape | JSON | Endpoints |
|---|---|---|
| Paginated | `data: { "items": [...], "total": 123, "page": 1, "size": 20 }` | most lists |
| Plain array | `data: [ ... ]` | `GET /categories`, `GET /cart`, `GET /chat/threads`, `GET /my-locations`, `POST /uploads` |
| Object | `data: { ... }` | everything else |

- Pagination query: `page` (int ≥ 1, default 1), `size` (int 1–100, default 20; chat messages default 30).
  More pages exist while `page * size < total`.
- IDs: 24-char hex strings, key `id` (never `_id`). Foreign keys end in `Id` (`producerId`, `userId`…).
- Dates: ISO-8601 UTC strings (`"2026-10-24T10:00:00.000Z"`). Parse with `DateTime.parse`.
- Numbers: `price`, `pricePerNight`, `rating`, `latitude`, `longitude` may be int or double in JSON —
  parse with `(j['x'] as num).toDouble()`.

### 1.4 Error codes → what the app does

| HTTP | `error.code` | Client behaviour |
|---|---|---|
| 400 | `BAD_REQUEST` | Show `error.message`. (Also used for account lockout, invalid reset links and malformed JSON.) |
| 401 | `UNAUTHENTICATED` | Refresh-and-retry once (§2.3) **except** on the `/auth/*` endpoints. |
| 403 | `FORBIDDEN` | Refresh once (role may be stale), retry; if still 403, show `error.message`. |
| 404 | `NOT_FOUND` | Show `error.message` or an empty state. |
| 409 | `CONFLICT` | Show `error.message`. |
| 413 | `PAYLOAD_TOO_LARGE` | Body over 100 KB — show `error.message` (`Cererea este prea mare.`). |
| 422 | `VALIDATION_ERROR` | Map `details[]` to form fields (§1.5); show `error.message` as summary. |
| 422 | `UNPROCESSABLE` | Show `error.message` (only uploads: not a valid image). |
| 429 | `TOO_MANY_REQUESTS` | Show `error.message`; back off. `RateLimit-Reset` header (seconds) is present on IP limits. |
| 500 | `INTERNAL_ERROR` | Generic error UI. In production `message` is `"A apărut o eroare. Încearcă din nou."`. |

Unknown codes: fall back to the HTTP status. **`error.message` is Romanian and written for the end
user — display it verbatim.** Never show raw `details` keys to users.

### 1.5 Validation errors (422)

```json
{ "data": null, "error": { "code": "VALIDATION_ERROR",
  "message": "Adresa de email nu este validă.",
  "details": [
    { "field": "body.email",    "message": "Adresa de email nu este validă." },
    { "field": "body.password", "message": "Câmpul pentru parola trebuie să aibă cel puțin 8 caractere." },
    { "field": "body.name",     "message": "Completează numele." } ] } }
```

- One entry per invalid field. `field` is prefixed with `body.`, `query.` or `params.` — **strip the
  prefix** and match the rest to the form input (`body.servedLocalities[0].id` → `servedLocalities[0].id`).
- `error.message` = the first detail's message.
- Unknown body keys are **silently ignored** (never a 422). Strings are trimmed server-side where noted.
- Generic message templates (the server fills `<label>` with a Romanian field name):
  `"Completează <label>."` (missing / empty), `"Valoarea pentru <label> nu are formatul corect."` (wrong
  type/format), `"Câmpul pentru <label> trebuie să aibă cel puțin N caractere."`,
  `"Câmpul pentru <label> poate avea cel mult N caractere."`, `"Valoarea pentru <label> trebuie să fie cel
  puțin N."`, `"Valoarea pentru <label> poate fi cel mult N."`, `"Alege cel puțin o opțiune la <label>."`,
  `"Valoarea pentru <label> trebuie să fie una dintre: a, b, c."`, `"Linkul pentru <label> nu este valid."`.
  Field-specific messages are listed per endpoint in §5.

### 1.6 Generic error messages (exact text)

| When | Status / code | `message` |
|---|---|---|
| No `Authorization: Bearer` header | 401 `UNAUTHENTICATED` | `Autentifică-te pentru a continua.` |
| Access token invalid or expired | 401 `UNAUTHENTICATED` | `Sesiunea a expirat. Autentifică-te din nou.` |
| Role not allowed | 403 `FORBIDDEN` | `Nu ai acces la această secțiune.` |
| Unknown route | 404 `NOT_FOUND` | `Ruta cerută nu există.` |
| Malformed id in a path (`/products/123`) | 422 `VALIDATION_ERROR` | `Valoarea pentru identificatorul nu are formatul corect.` (field `params.id`) |
| Body is not valid JSON | 400 `BAD_REQUEST` | `Corpul cererii nu este un JSON valid.` |
| Body over 100 KB | 413 `PAYLOAD_TOO_LARGE` | `Cererea este prea mare.` |
| Web origin not allowed (CORS) | 403 `FORBIDDEN` | `Originea cererii nu este permisă.` |
| Rate limit (IP) | 429 `TOO_MANY_REQUESTS` | `Prea multe cereri. Încearcă din nou peste câteva momente.` |
| Server fault (production) | 500 `INTERNAL_ERROR` | `A apărut o eroare. Încearcă din nou.` |

### 1.7 Rate limits

| Scope | Limit (production defaults) | Key |
|---|---|---|
| Every request | **600 per 15 min** | the signed-in **user** (valid token), else the client IP |
| `POST /auth/register`, `/auth/login`, `/auth/social` | 30 per 15 min | IP |
| `POST /auth/forgot-password` | 10 per 15 min per IP **and** 3 per hour per e-mail | IP / e-mail |
| `POST /auth/reset-password` | 10 per 15 min | IP |
| Sending chat messages (REST + WS combined) | 20 per 10 s | user |

Handle 429 gracefully everywhere (message + retry later; IP/user limits send a `RateLimit-Reset` header in
seconds). Still avoid needless polling: prefer the WebSocket for chat, cache catalogue pages, debounce
search/map requests.

---

## 2. Auth, tokens and roles

### 2.1 Model

- The app authenticates against **vt-api**. It always sends `Authorization: Bearer <accessToken>`
  (a vt-api JWT). **Never send a Firebase token** except as `idToken` to `POST /auth/social`.
- **Access token:** JWT HS256, TTL **15 min**, payload `{ sub: <userId>, role, iat, exp }`. You may decode
  it (no verification) to read `role` / `exp`, but prefer `GET /users/me` for the user.
- **Refresh token:** opaque 128-hex-char string, TTL **30 days**, **single use** (rotated on every refresh).
  Store both tokens in secure storage (`flutter_secure_storage`).
- **Roles:** `client` (default) · `business` (approved producer) · `admin`. The server derives the role
  from the token — never send a role. **Any role can host booking places** (`/my-locations`, §5.15): a host
  stays `client`. `business` is only for approved producers and unlocks `/shop/*`.

### 2.2 Login flows

| Flow | Call | Response `data` |
|---|---|---|
| Register | `POST /auth/register` `{ email, password, name }` | **201** `{ user: User, accessToken, refreshToken }` |
| Login | `POST /auth/login` `{ email, password }` | 200 `{ user: User, accessToken, refreshToken }` |
| Google / Apple | Sign in with Firebase on device → `POST /auth/social` `{ idToken }` | 200 `{ user: User, accessToken, refreshToken }` |
| Refresh | `POST /auth/refresh` `{ refreshToken }` | 200 `{ accessToken, refreshToken }` — **no `user`** |
| Logout | `POST /auth/logout` `{ refreshToken }` | **204** (also for unknown/used tokens) |

E-mails are stored lowercased (`Ana@X.ro` → `ana@x.ro`). Facebook login is not available.

### 2.3 Refresh rules (HTTP interceptor)

1. On **401 `UNAUTHENTICATED`** from any endpoint **other than** `/auth/*`:
   call `POST /auth/refresh` once (single-flight: queue concurrent requests behind one refresh), store the
   **new pair**, retry the original request once.
2. If refresh returns 401 → the session is over: clear tokens, go to login. Possible messages:
   `Sesiunea nu mai este validă. Autentifică-te din nou.` (unknown token),
   `Sesiunea a expirat. Autentifică-te din nou.` (expired),
   `Sesiunea a fost închisă. Autentifică-te din nou.` (a used token was replayed → **all** sessions of that
   login are revoked — usually two refreshes raced; that is why refresh must be single-flight),
   `Contul nu a fost găsit.`
3. On **403 `FORBIDDEN`**: refresh once and retry (the role in the token may be stale, e.g. just became
   `business`). Still 403 → show the message.
4. Always replace the stored refresh token with the one returned — the old one is now revoked.

### 2.4 Role change after partner approval

When an admin approves a partner application the server sets the user's role to `business`, creates their
Producer (status `approved`) and sends an FCM **data** message `{ "action": "REFRESH_TOKEN" }` to all the
user's registered devices. On receiving it: call `POST /auth/refresh`, then `GET /users/me` — the new
access token carries `role: "business"` and business screens become available. If push is not configured,
the next refresh (≤ 15 min) picks the role up anyway; a manual pull-to-refresh on the application status
screen should also refresh.

---

## 3. Status & visibility rules

| Entity | Statuses | Who sees non-approved |
|---|---|---|
| Product | `pending` · `approved` · `rejected` · `requestInfo` | owner (business) and admin |
| Producer | `pending` · `approved` · `rejected` | owner and admin |
| BookingPlace | `pending` · `approved` · `rejected` | owner and admin |
| PartnerApplication | `pending` · `approved` · `rejected` | applicant and admin |

- **Public lists (`GET /products`, `/producers`, `/booking-places`) return only `approved` items, for
  everyone** — anonymous, owner or admin — and `total` counts only those. No client-side status filtering is
  needed. Owners manage their listings via `/shop/products`, `/shop/producer`, `/my-locations`; admins via
  `/admin/*`.
- **Public details (`GET /products/:id`, `/producers/:id`, `/booking-places/:id`):** a non-approved item is
  a 404 for anonymous users and other users, but its owner and admins can still open it (preview).
- Public product lists do **not** hide `available: false` or `stock: 0` products — render them as
  unavailable.
- New producers/products/booking places start as `pending` and need admin approval. **Edits after approval
  do not reset the status** (they stay `approved`).
- **Product auto-approve (admin switch, off by default).** When an admin turns on `autoApproveProducts`
  (`PATCH /admin/settings`), a product created by a producer whose `status` is `approved` is stored as
  `approved` with `autoApproved: true` and is public at once. The producer's `verified` badge does not
  matter. A producer with `autoApproveProducts: false` (set per producer by an admin) still goes to review.
  Only **creation** is affected: edits never change a status, so a `rejected` or `requestInfo` product
  stays that way until an admin acts. Turning the switch off leaves existing products alone. Any admin
  decision (approve, reject, request info) sets `autoApproved` to `false`. Read the current value from
  the public `GET /config` (§5.18) to tell a business user, before they submit, whether the product
  will go live immediately.
- **Moderation feedback:**
  - `rejectionReason` (why it was rejected) is returned on Product, Producer, BookingPlace and
    PartnerApplication when set.
  - `moderationNote` (the admin's "request info" message) is returned on Product, Producer, BookingPlace
    and PartnerApplication. On Product/Producer/BookingPlace it comes **only from the owner/admin
    endpoints** (`/shop/*`, `/my-locations`, `/admin/*`), never from public ones; on PartnerApplication it
    is always included (only the applicant and admins can read an application).
  - `sections` — the per-section review: `{ "<section>": { "status": "pending"|"approved"|"requestChanges",
    "note"?: "…" } }` (always an object, possibly `{}`). Same visibility as `moderationNote`.
    Section names per kind:

    | Kind | Allowed `<section>` |
    |---|---|
    | Product | `details`, `images` |
    | Producer | `details`, `contact`, `images` |
    | BookingPlace | `details`, `contact`, `images` |
    | PartnerApplication | `details`, `contact`, `images` |

  - Approving clears `rejectionReason`, `moderationNote` **and `sections`**; rejecting sets
    `rejectionReason` and clears `moderationNote`; requesting info sets `moderationNote` and clears
    `rejectionReason`. A "request info" changes the status only for products (`requestInfo`); producers,
    booking places and applications keep theirs, so show `moderationNote` whenever it is present. Show
    these texts to the owner on "Magazinul meu" / "Locațiile mele" / the application status screen.

---

## 4. Models (exact JSON)

Optional keys marked "absent when unset" are omitted from the JSON, not `null`.

### 4.1 User — `GET /users/me`, auth responses
```jsonc
{
  "id": "…", "email": "ana@x.ro", "name": "Ana Pop",
  "phone": "0712345678",           // absent when unset
  "avatarUrl": "https://…",        // absent when unset
  "role": "client",                // client | business | admin
  "emailVerified": false,
  "provider": "google",            // google | apple — absent for e-mail accounts
  "createdAt": "…", "updatedAt": "…"
}
```

### 4.2 Category — `GET /categories`
```jsonc
{ "id": "…", "name": "Lactate", "slug": "lactate", "icon": "🧀" /* absent when unset */, "order": 3 }
```

### 4.3 Product
```jsonc
{
  "id": "…", "name": "Miere de salcâm", "description": "…",
  "price": 25, "unit": "kg",       // unit: kg | g | l | ml | piece | bunch | box — the price unit
  "volume": "500 g",                  // package size as written by the seller; absent when unset
  "stock": 10, "photos": ["https://…/uploads/x.webp"],
  "categoryId": "…", "producerId": "…",
  "producer": {                       // always present; null only if the producer was deleted
    "id": "…", "businessName": "Ferma Ionescu", "verified": true,
    "locality": "Bran", "county": "Brașov"   // null when unknown
  },
  "status": "approved", "available": true,
  "rating": 4.5, "reviewCount": 12,   // rating 0–5, one decimal; 0 when no reviews
  "rejectionReason": "…",             // absent when unset
  "moderationNote": "…",              // owner/admin endpoints only; absent when unset
  "sections": { "images": { "status": "requestChanges", "note": "…" } },   // owner/admin endpoints only (may be {})
  "autoApproved": false,              // owner/admin endpoints only; true = went live without review (§3)
  "createdAt": "…", "updatedAt": "…"
}
```
`producer` is embedded on every product response (lists, detail, cart, shop, admin) — use it for the shop
name/badge on product cards instead of fetching `GET /producers/:id`.

### 4.4 Producer (shop)
```jsonc
{
  "id": "…", "userId": "…", "businessName": "Ferma Ionescu", "description": "…",
  "website": "https://…",          // absent when unset
  "phone": "0712345678",           // absent when unset
  "email": "contact@ferma.ro",     // absent when unset
  "address": "Bran, Brașov",       // always present (composed "{locality}, {county}" if not sent)
  "locality": "Bran", "county": "Brașov",      // always present, null when unknown
  "latitude": 45.5152, "longitude": 25.3672,  // always present, null when unknown
  "servedLocalities": [ /* ServedLocality, order preserved — first = own locality */ ],
  "categories": ["<categoryId>"], "photos": ["https://…"],
  "verified": false, "status": "approved",
  "rejectionReason": "…",          // absent when unset
  "moderationNote": "…",           // owner/admin endpoints only; absent when unset
  "sections": { "contact": { "status": "requestChanges", "note": "…" } },  // owner/admin endpoints only
  "autoApproveProducts": true,     // owner/admin endpoints only; false = new products always reviewed (§3)
  "rating": 0, "reviewCount": 0,
  "createdAt": "…", "updatedAt": "…"
}
```
`ServedLocality`:
```jsonc
{ "id": "2:9341:Ineu", "name": "Ineu", "kind": "city" /* city | commune | village */,
  "countyCode": 2, "countyName": "Arad",
  "siruta": 9341,          // absent for villages
  "parentName": "Almaș" }  // villages only, absent otherwise
```

### 4.5 BookingPlace (accommodation)
```jsonc
{
  "id": "…", "userId": "…", "name": "Pensiunea Bradul",
  "type": "guesthouse",            // cabin | room | apartment | guesthouse | campsite — null when unset
  "description": "…",
  "address": "Str. Principală 10, Bran",
  "locality": "Bran", "county": "Brașov", "latitude": 45.5, "longitude": 25.3,   // null when unknown
  "phone": "0268123456", "email": "contact@x.ro", "website": "https://…",      // null when unset (not absent!)
  "capacity": 4, "pricePerNight": 200,
  "photos": ["…"], "amenities": ["wifi", "parcare"],
  "status": "approved", "rejectionReason": "…" /* absent when unset */,
  "moderationNote": "…",           // owner/admin endpoints only; absent when unset
  "sections": { … },               // owner/admin endpoints only
  "rating": 0, "reviewCount": 0,
  "createdAt": "…", "updatedAt": "…"
}
```
Note the asymmetry: Producer `phone`/`email`/`website` are **absent** when unset; BookingPlace
`phone`/`email`/`website` are **`null`** when unset.

### 4.6 PartnerApplication
```jsonc
{
  "id": "…", "userId": "…", "businessName": "…", "description": "…",
  "website": "https://…" /* absent when unset */, "phone": "0712345678" /* always present */,
  "email": "contact@ferma.ro",     // absent when unset
  "address": "…", "locality": "…", "county": "…", "latitude": null, "longitude": null,
  "servedLocalities": [ … ], "categories": ["…"],
  "photos": ["https://…"],         // always an array
  "status": "pending", "rejectionReason": "…" /* absent when unset */,
  "moderationNote": "…",           // absent when unset
  "sections": { "images": { "status": "requestChanges", "note": "…" } },   // always an object
  "createdAt": "…", "updatedAt": "…"
}
```
On approval the Producer is created from the application, including `photos` and `email`.

### 4.7 Review
```jsonc
{
  "id": "…", "userId": "…",
  "author": { "name": "Ana P.", "avatarUrl": null },   // null only if the account was deleted
  "targetType": "product",          // product | producer | bookingPlace
  "targetId": "…", "rating": 5,     // int 1–5
  "comment": "Foarte bună",         // absent when none (never an empty string)
  "createdAt": "…", "updatedAt": "…"
}
```
`author.name` is always the public short form (first name + last initial). Show it; do not try to
resolve `userId` to a name.

### 4.8 Favorite
```jsonc
{ "id": "<favoriteId>", "userId": "…", "targetType": "product", "targetId": "…", "createdAt": "…" }
```
Only a reference — fetch the target yourself. **Delete uses the favorite `id`, not `targetId`** (§5.8).

### 4.9 CartItem
```jsonc
{ "id": "…", "productId": "…", "quantity": 2,
  "product": { /* Product */ },     // ONLY in GET /cart, and absent if the product was deleted
  "createdAt": "…", "updatedAt": "…" }
```

### 4.10 Chat
```jsonc
// ChatThread
{
  "id": "…",
  "participants": ["<userId>", "<userId>"],
  "otherParticipant": {             // always the participant that is NOT the caller; null if account gone
    "id": "<userId>",               // same as userId (legacy key)
    "userId": "<userId>",
    "name": "Ferma Ionescu",        // may be null
    "avatarUrl": "https://…",       // may be null
    "kind": "producer",             // producer | bookingPlace | user
    "producerId": "…",              // only when kind = producer, else null
    "bookingPlaceId": null          // only when kind = bookingPlace, else null
  },
  "unreadCount": 2,                 // for the caller
  "lastMessageAt": "…",             // absent until the first message
  "lastMessagePreview": "…",        // absent until the first message; ≤ 120 chars, whitespace collapsed
  "createdAt": "…", "updatedAt": "…"
}
// Message
{ "id": "…", "threadId": "…", "senderId": "…", "text": "…",
  "clientMessageId": "…",           // absent unless the sender set one
  "readBy": ["<userId>"],           // sender is always included
  "createdAt": "…", "updatedAt": "…" }
```
`otherParticipant` resolution: thread opened from a booking place and the other user owns it → the
place's name/photo (`kind: bookingPlace`); else if the other user has a shop → shop name/photo
(`kind: producer`); else their own name/avatar (`kind: user`). A producer sees buyers as `kind: user`.
**Use `otherParticipant`; never guess the counterpart from `participants[1]`.**

### 4.11 Upload result — `POST /uploads`
```jsonc
[ { "url": "https://<host>/uploads/<uuid>.webp", "filename": "<uuid>.webp", "size": 48213, "width": 1600, "height": 1200 } ]
```

---

## 5. REST endpoints

Legend — Auth: 🔓 none · 🟡 optional (personalised when a token is sent) · 🔑 any logged-in user ·
🏪 `business` or `admin` · 🛡️ `admin`. All paths are under `/api`.

### 5.1 Auth — `/auth`

| Method & path | Auth | Request | Success | Errors (status · code · message) |
|---|---|---|---|---|
| `POST /auth/register` | 🔓 | `{ email, password (8–100), name (1–100, trimmed) }` | **201** `{ user, accessToken, refreshToken }` | 409 CONFLICT `Există deja un cont cu această adresă de email.` · 422 |
| `POST /auth/login` | 🔓 | `{ email, password }` | 200 `{ user, accessToken, refreshToken }` | 401 UNAUTHENTICATED `Email sau parolă incorecte.` · **400 BAD_REQUEST** `Contul este blocat temporar după prea multe încercări eșuate. Încearcă din nou peste N minute.` · 422 |
| `POST /auth/refresh` | 🔓 | `{ refreshToken }` | 200 `{ accessToken, refreshToken }` | 401 (messages in §2.3) |
| `POST /auth/logout` | 🔓 | `{ refreshToken }` | **204** empty | 422 if missing |
| `POST /auth/social` | 🔓 | `{ idToken }` (Firebase ID token) | 200 `{ user, accessToken, refreshToken }` | 400 `Autentificarea cu Google sau Apple nu este disponibilă momentan.` · 401 `Autentificarea nu a reușit. Încearcă din nou.` · 400 `Contul folosit nu are o adresă de email.` |
| `POST /auth/forgot-password` | 🔓 | `{ email }` | **204** empty — always, even for unknown or malformed e-mails | 429 |
| `POST /auth/reset-password` | 🔓 | `{ token, password (8–100) }` | **204** empty | **400 BAD_REQUEST** `Linkul de resetare a expirat sau a fost deja folosit.` · 422 · 429 |

- **Lockout:** 10 wrong passwords within 30 min lock the account for 30 min. The lock answer is a **400**,
  not 401 — show the message, don't refresh.
- **Password reset:** the e-mail contains `{PASSWORD_RESET_URL_BASE}?token=<128 hex chars>`, by default
  `https://<host>/reset-password?token=…` — a web page served by the API that completes the reset itself.
  The app only needs a "Forgot password" screen that calls `forgot-password` and shows "check your e-mail".
  If an app deep link is configured later, read `token` from the link and call `reset-password`. A
  successful reset **logs out every session** of that user. The link is valid 60 min, single use; asking
  again invalidates earlier links. Social-only accounts silently get no e-mail.
- After a 401 on `login` (wrong password) do **not** run the refresh interceptor.

### 5.2 Users — `/users`

| Method & path | Auth | Request | Success | Errors |
|---|---|---|---|---|
| `GET /users/me` | 🔑 | — | 200 User | 404 `Nu am găsit ce ai căutat.` (account deleted) |
| `PUT /users/me` | 🔑 | `{ name? (1–100), phone?, avatarUrl? (URL or null) }` | 200 User | 422 |
| `PUT /users/me/password` | 🔑 | `{ currentPassword, newPassword (8–100) }` | 200 `{ "message": "Parola a fost schimbată." }` | 400 `Conturile create cu Google sau Apple nu au parolă.` · **422** `Parola curentă este incorectă.` (field `body.currentPassword`) · 422 |

- `phone`: Romanian numbers only (§8). `""` clears it (then `phone` is absent in the response).
  `avatarUrl: null` clears it. Absent keys are left unchanged.
- Changing the password does **not** log out other sessions.

### 5.3 Categories — `/categories`

| `GET /categories` | 🔓 | — | 200 **plain array** of Category, sorted by `order` |
|---|---|---|---|

### 5.4 Products — `/products`

| Method & path | Auth | Query / params | Success | Errors |
|---|---|---|---|---|
| `GET /products` | 🟡 | `page, size, categoryId?, producerId?, search? (≤ 200), minPrice?, maxPrice?, lat?, lng?, radiusKm?, county?, locality?` | 200 paginated Product, newest first | 422 |
| `GET /products/:id` | 🟡 | — | 200 Product | 404 `Produsul nu a fost găsit.` |

- `search` is a full-text search on name/description.
- Area filter (§5.6) applies to products **through their producer's location**.
- Every product carries the embedded `producer` summary (§4.3).

### 5.5 Producers — `/producers`

| Method & path | Auth | Query | Success | Errors |
|---|---|---|---|---|
| `GET /producers` | 🟡 | `page, size, categoryId?, search?, verified? ("true"\|"false"), lat?, lng?, radiusKm?, county?, locality?` | 200 paginated Producer, newest first | 422 |
| `GET /producers/:id` | 🟡 | — | 200 Producer | 404 `Producătorul nu a fost găsit.` |

### 5.6 Area filter (products, producers, booking places)

- `lat` + `lng` + `radiusKm` — **all three or none**. `radiusKm` must be **0.5–200**.
  - Partial circle → 422, one detail per missing key: `Trimite împreună parametrii lat, lng și radiusKm.`
    (fields `query.lat` / `query.lng` / `query.radiusKm`).
  - Out of range → 422 on `query.radiusKm`: `Raza trebuie să fie între 0,5 și 200 km.`
  - **There is no default radius any more** — always send `radiusKm`.
- `county`, `locality`: case-insensitive exact match; usable alone or combined with the circle.
- Only items that have coordinates can match a circle. Nothing in the area → `items: []`, `total: 0` (not an error).

### 5.7 Booking places — `/booking-places`

| Method & path | Auth | Query | Success | Errors |
|---|---|---|---|---|
| `GET /booking-places` | 🟡 | `page, size, search?, lat?, lng?, radiusKm?, county?, locality?` | 200 paginated BookingPlace | 422 |
| `GET /booking-places/:id` | 🟡 | — | 200 BookingPlace | 404 `Cazarea nu a fost găsită.` |

### 5.8 Favorites — `/favorites`

| Method & path | Auth | Request | Success | Errors |
|---|---|---|---|---|
| `GET /favorites` | 🔑 | `targetType? (product\|producer\|bookingPlace), page, size` | 200 paginated Favorite, newest first | 422 |
| `POST /favorites` | 🔑 | `{ targetType, targetId }` | **201** Favorite | 404 `Elementul nu a fost găsit.` (missing, not approved, or wrong type) · 409 `Ai adăugat deja acest element la favorite.` · 422 |
| `DELETE /favorites/:id` | 🔑 | `:id` = **favorite id** | 200 `{ "removed": true }` | 404 `Elementul nu a fost găsit la favorite.` · 403 if it isn't yours |

To toggle a heart on a product: keep a map `targetId → favoriteId` from `GET /favorites`; on 409 when
adding, reload favorites to learn the id.

### 5.9 Cart — `/cart`

| Method & path | Auth | Request | Success | Errors |
|---|---|---|---|---|
| `GET /cart` | 🔑 | — | 200 **plain array** CartItem (with `product`), newest first | — |
| `POST /cart` | 🔑 | `{ productId, quantity (1–999) }` | **201** CartItem (no `product`) | 404 `Produsul nu a fost găsit.` (missing or not approved) · 409 `Produsul nu este disponibil momentan.` (`available: false`) · 422 `Poți avea cel mult 999 de bucăți din acest produs în coș.` (field `body.quantity`, when the line would exceed 999) · 422 |
| `PUT /cart/:productId` | 🔑 | `{ quantity (1–999) }` | 200 CartItem (no `product`) | 404 `Produsul nu este în coș.` · 422 |
| `DELETE /cart/:productId` | 🔑 | — | 200 `{ "removed": true }` | 404 `Produsul nu este în coș.` |
| `DELETE /cart` | 🔑 | — | 200 `{ "cleared": true }` | — |

- **`POST /cart` adds to the existing quantity** (2 + 3 = 5). To set an exact quantity use `PUT`.
- Paths use the **product id**, not the cart item id.
- Adding checks that the product exists, is approved and available. `stock` is **not** enforced. A product
  can become unavailable/unapproved after it was added — when rendering `GET /cart`, check
  `product.status == 'approved' && product.available` and mark the line otherwise. There is no
  checkout/order API.

### 5.10 Reviews — `/reviews`

| Method & path | Auth | Request | Success | Errors |
|---|---|---|---|---|
| `GET /reviews` | 🔓 | `targetType, targetId, page, size` (all required except page/size) | 200 paginated Review, newest first | 422 |
| `POST /reviews` | 🔑 | `{ targetType, targetId, rating (int 1–5), comment? (≤ 1000) }` | **201** Review | 404 `Elementul pe care vrei să-l evaluezi nu a fost găsit.` (missing, not approved, or wrong type) · 403 `Nu poți lăsa o recenzie pentru propriul tău anunț.` · 409 `Ai lăsat deja o recenzie pentru acest element.` · 422 |
| `PUT /reviews/:id` | 🔑 author | `{ rating?, comment? }` — at least one; `comment: ""` or `null` removes it | 200 Review | 403 `Poți modifica doar recenziile tale.` · 404 `Recenzia nu a fost găsită.` · 422 `Modifică nota sau comentariul.` |
| `DELETE /reviews/:id` | 🔑 author or 🛡️ | — | 200 `{ "deleted": true }` | 403 `Poți șterge doar recenziile tale.` · 404 `Recenzia nu a fost găsită.` |

- `rating` must be a JSON integer (`4`, not `"4"`).
- `comment` is trimmed; blank → no comment. Too long → 422 `Comentariul poate avea cel mult 1000 de caractere.`
- One review per user per target: on 409, offer "edit your review" (find it in the list by `userId`).
- Hide the "write a review" button on the user's own shop/products/places.
- Every create/edit/delete recomputes the target's `rating` and `reviewCount` — refetch the target after.

### 5.11 Devices (push tokens) — `/devices`

| Method & path | Auth | Request | Success | Errors |
|---|---|---|---|---|
| `POST /devices` | 🔑 | `{ token, platform? (ios\|android\|web, default android) }` | 200 `{ "registered": true }` | 422 |
| `DELETE /devices/:token` | 🔑 | URL-encode the token | 200 `{ "unregistered": true }` | 404 `Dispozitivul nu a fost găsit.` |

Register after every login and on FCM `onTokenRefresh`; unregister on logout **before** calling
`/auth/logout`. Registering a token that belonged to another user moves it to the current user.

### 5.12 Uploads — `/uploads`

| Method & path | Auth | Request | Success | Errors |
|---|---|---|---|---|
| `POST /uploads` | 🔑 | `multipart/form-data`, field name **`files`** (repeat for several), up to 10 files, ≤ 10 MB each; jpeg/png/webp/gif/avif/heic/heif | **201** plain array of Upload result, same order | 400 `Nu ai atașat nicio imagine.` · 400 `Tipul de fișier nu este acceptat: <mime>` · 400 `Imaginea este prea mare (maximum 10 MB).` · 400 `Poți încărca cel mult 10 imagini odată.` · 400 `Imaginile trebuie trimise în câmpul „files”.` · 422 UNPROCESSABLE `Fișierul nu este o imagine validă.` |
| `DELETE /uploads/:filename` | 🔑 uploader or 🛡️ | `<uuid>.webp` | 200 `{ "removed": true\|false }` | 403 `Poți șterge doar imaginile încărcate de tine.` · 404 `Imaginea nu a fost găsită.` · 422 bad filename |

Images are re-encoded to WebP (longest edge 1600 px). Upload first, then put the returned `url`s in
`photos` of the producer/product/booking place.

### 5.13 Partner application — `/partner`

| Method & path | Auth | Request | Success | Errors |
|---|---|---|---|---|
| `POST /partner/apply` | 🔑 | see below | **201** PartnerApplication (`status: pending`) | 409 `Ai deja o cerere de producător în așteptare.` · 409 `Ai deja un cont de producător pentru acest cont.` · 422 |
| `GET /partner/application` | 🔑 | — | 200 the **latest** PartnerApplication | 404 `Nu ai nicio cerere de producător.` |

Body:
```jsonc
{
  "businessName": "Ferma Bunicii",          // 2–100
  "description": "…",                       // 10–2000
  "website": "https://…",                   // optional, must be a full URL
  "phone": "0712 345 678",                  // REQUIRED, Romanian (§8)
  "locality": "Bran", "county": "Brașov",   // required (2–100 each)
  "latitude": 45.51, "longitude": 25.36,    // optional; both or neither; 0,0 rejected
  "address": "Str. …",                      // optional (5–200); defaults to "{locality}, {county}"
  "servedLocalities": [ … ],                // optional, ≤ 50, order preserved
  "categories": ["<categoryId>"],           // 1–10
  "email": "contact@ferma.ro",              // optional; lowercased
  "photos": ["https://…/uploads/x.webp"]    // optional, ≤ 20 URLs (upload first via /uploads)
}
```
After a rejection the user may apply again. On approval see §2.4 — `photos` and `email` are copied to the
new Producer. While pending, the admin may send `moderationNote` / `sections` feedback (§3); show it on the
application status screen.

### 5.14 Shop (own producer + own products) — `/shop` 🏪

A `client` gets 403 here — they must apply via `/partner/apply` first.

| Method & path | Request | Success | Errors |
|---|---|---|---|
| `GET /shop/producer` | — | 200 Producer (any status) | 404 `Nu ai încă un profil de producător.` |
| `POST /shop/producer` | body as partner apply (incl. optional `email`), `phone` optional, plus `photos?` (≤ 20 URLs) | **201** Producer (`status: pending`) | 409 `Ai deja un profil de producător.` · 422 |
| `PUT /shop/producer` | any subset; `website: null` clears; `phone: ""` clears; `email: ""` clears; `latitude/longitude: null` clears both; `address` is never rewritten automatically | 200 Producer | 404 `Nu ai încă un profil de producător.` · 422 |
| `GET /shop/products` | `page, size, status? (pending\|approved\|rejected\|requestInfo)` | 200 paginated Product — **all statuses**, newest first | 404 `Creează mai întâi profilul de producător.` |
| `POST /shop/products` | `{ name (2–200), description (10–2000), price (≥ 0), unit, volume? (≤ 50, e.g. "500 g"), stock? (int ≥ 0, default 0), photos? (≤ 20 URLs), categoryId, available? (default true) }` | **201** Product — `status: pending`, or `approved` + `autoApproved: true` when auto-approve applies (§3) | 404 `Creează mai întâi profilul de producător.` · 422 |
| `PUT /shop/products/:id` | any subset of the above; `volume: ""` or `null` clears | 200 Product | 404 `Produsul nu a fost găsit.` · 422 |
| `DELETE /shop/products/:id` | — | 200 `{ "deleted": true }` | 404 `Produsul nu a fost găsit.` |

Responses from `/shop/*` include the owner-only moderation fields (`moderationNote`, `sections`,
`autoApproved` / `autoApproveProducts`, §3). A business user normally already has a Producer (created on
approval) — `GET /shop/producer` is the "Magazinul meu" header; `POST /shop/producer` is for admins/edge cases. "Magazinul meu" lists products
with `GET /shop/products` (not `/products?producerId=`).

### 5.15 My locations (own booking places) — `/my-locations` 🔑

Open to **any signed-in user**: hosts list cabins, rooms or guesthouses without a partner application, and
keep the `client` role (no token refresh needed). Each place is moderated on its own (§3); a user only ever
sees and changes their own places.

| Method & path | Request | Success | Errors |
|---|---|---|---|
| `GET /my-locations` | — | 200 **plain array** BookingPlace (all statuses), newest first | — |
| `POST /my-locations` | see below | **201** BookingPlace (`status: pending`) | 422 |
| `PUT /my-locations/:id` | any subset | 200 BookingPlace | 404 `Cazarea nu a fost găsită.` · 422 |
| `DELETE /my-locations/:id` | — | 200 `{ "deleted": true }` | 404 `Cazarea nu a fost găsită.` |

```jsonc
{
  "name": "Cabana la Munte",               // 2–100
  "type": "cabin",                         // optional: cabin | room | apartment | guesthouse | campsite; null clears (PUT)
  "description": "…",                      // 10–2000
  "address": "Sat Fundata, jud. Brașov",   // REQUIRED on create (5–200)
  "locality": "Fundata", "county": "Brașov",   // optional (2–100)
  "latitude": 45.45, "longitude": 25.26,   // optional; both or neither; 0,0 rejected; null clears (PUT)
  "phone": "0268 123 456",                 // optional, Romanian (§8); "" clears (PUT)
  "email": "contact@cabana.ro",            // optional; lowercased; "" clears (PUT)
  "website": "https://cabana.ro",          // optional, full URL; "" clears (PUT)
  "capacity": 6,                           // int ≥ 1
  "pricePerNight": 350,                    // ≥ 0
  "photos": [], "amenities": ["wifi"]      // ≤ 20 URLs / ≤ 50 strings (≤ 100 chars)
}
```
On PUT an absent key leaves the field untouched. Responses include the owner-only `moderationNote` and
`sections` (§3).

### 5.16 Chat — `/chat` 🔑

| Method & path | Request | Success | Errors |
|---|---|---|---|
| `GET /chat/unread-count` | — | 200 `{ "total": 7, "threads": 3 }` | — |
| `GET /chat/threads` | — | 200 **plain array** ChatThread, most recent activity first | — |
| `POST /chat/threads` | `{ participantId, participantType? }` | **201** new / **200** existing ChatThread | 404 `Destinatarul nu a fost găsit.` · 422 `Nu îți poți trimite mesaje ție.` (field `body.participantId`) · 422 `Destinatarul nu a fost găsit.` (malformed id) |
| `GET /chat/threads/:id/messages` | `page, size (1–100, default 30)` **or** `before, size` | 200 paginated Message, **newest first** | 403 `Nu ai acces la această conversație.` · 404 `Conversația nu a fost găsită.` · 422 `Parametru de paginare invalid.` · 422 `Mesajul de referință nu a fost găsit.` (field `query.before`) |
| `POST /chat/threads/:id/messages` | `{ text, clientMessageId? }` | **201** new / **200** replay, Message | 403 · 404 (as above) · 409 `Identificatorul mesajului a fost deja folosit.` · 422 · **429** `Trimiți mesaje prea repede. Încearcă din nou în câteva secunde.` |
| `POST /chat/threads/:id/read` | — | 200 `{ "ok": true }` | 403 · 404 |

- **Opening a thread:** `participantId` may be a **producer id**, a **booking place id** or a **user id**.
  Send `participantType` (`producer` | `bookingPlace` | `user`) when you know it — then only that kind is
  looked up (a mismatch is a 404). Without it the server tries producer → booking place → user.
  The call is idempotent: the same user pair + same target always returns the same thread (201 first,
  200 after). Opening from a shop page and from that owner's booking place gives **two different** threads.
- **Messages paging:** newest first. Initial load: `?size=30`. Infinite scroll upward:
  `?before=<id of the oldest loaded message>&size=30` (an ISO date also works, exclusive). In cursor mode
  `page` is always 1 and `total` = number of messages **older than the cursor** → more exist while
  `total > items.length`. Sort chronologically for display.
- **Sending:** `text` is trimmed, 1–2000 chars → 422 `Mesajul nu poate fi gol.` /
  `Mesajul poate avea cel mult 2000 de caractere.` Generate a **UUID v4 `clientMessageId`** per message
  (`[A-Za-z0-9-]`, ≤ 64 → else 422 `Identificator de mesaj invalid.`), show an optimistic bubble keyed by
  it, and retry with the **same** id on timeout — the server returns the stored message (200) instead of a
  duplicate. Replace the optimistic bubble when the stored message (REST response or WS echo) carries the
  same `clientMessageId`.
- **Unread:** `unreadCount` on each thread and `GET /chat/unread-count` count messages the caller did not
  send and has not read. Call `POST …/read` when a conversation is opened/visible. Idempotent.
- With the WebSocket connected, stop polling — all of the above is pushed (§7).

### 5.17 Admin — `/admin` 🛡️

Lists take `status?`, `page`, `size` and return paginated items, newest first. `GET /admin/producers` also
takes `verified? ("true"|"false")`, combinable with `status` — "producers to verify" is
`?status=approved&verified=false`. `GET /admin/products` also takes `autoApproved? ("true"|"false")` —
"went live without review" is `?autoApproved=true`.

| Method & path | Body | Success `data` |
|---|---|---|
| `GET /admin/products` · `GET /admin/products/:id` | — | Product |
| `PATCH /admin/products/:id/approve` | — | Product |
| `PATCH /admin/products/:id/reject` | `{ reason }` (required) | Product (`rejectionReason` set) |
| `PATCH /admin/products/:id/request-info` | `{ note? }` | Product (`status: requestInfo`) |
| `PATCH /admin/products/:id/sections/:section` | `{ action: approve\|requestChanges, note? }` | Product |
| `GET /admin/producers` · `GET /admin/producers/:id` | — | Producer |
| `PATCH /admin/producers/:id/approve` · `/reject {reason}` | | Producer |
| `PATCH /admin/producers/:id/request-info` | `{ note? }` | Producer (status unchanged) |
| `PATCH /admin/producers/:id/verify` | — | Producer (`verified: true`) |
| `PATCH /admin/producers/:id/auto-approve` | `{ enabled: bool }` | Producer (`autoApproveProducts` set) |
| `PATCH /admin/producers/:id/sections/:section` | `{ action: approve\|requestChanges, note? }` | Producer |
| `GET /admin/applications` · `GET /admin/applications/:id` | — | PartnerApplication |
| `PATCH /admin/applications/:id/approve` | — | PartnerApplication — also creates the Producer, promotes the user, pushes REFRESH_TOKEN (§2.4) |
| `PATCH /admin/applications/:id/reject` | `{ reason }` | PartnerApplication |
| `PATCH /admin/applications/:id/request-info` | `{ note? }` | PartnerApplication (`moderationNote` set, stays `pending`) |
| `PATCH /admin/applications/:id/sections/:section` | `{ action: approve\|requestChanges, note? }` | PartnerApplication |
| `GET /admin/booking-places` · `GET /admin/booking-places/:id` | — | BookingPlace |
| `PATCH /admin/booking-places/:id/approve` · `/reject {reason}` · `/request-info {note?}` | | BookingPlace |
| `PATCH /admin/booking-places/:id/sections/:section` | `{ action: approve\|requestChanges, note? }` | BookingPlace |
| `GET /admin/settings` | — | `{ autoApproveProducts, updatedAt, updatedBy }` (`updatedAt`/`updatedBy` null until first change) |
| `PATCH /admin/settings` | any subset of `{ autoApproveProducts: bool }` | same as GET — takes effect on the next request |

`:section` must be one of the names for that kind (§3) — anything else is a 422 on `params.section`. A
section decision without `note` clears the previous note. Admin responses include the moderation fields
(`rejectionReason`, `moderationNote`, `sections`).
Errors: 404 `Produsul nu a fost găsit.` / `Producătorul nu a fost găsit.` / `Cererea nu a fost găsită.` /
`Cazarea nu a fost găsită.`; 400 `Cererea a fost deja procesată.` (approve / reject / request-info /
section review on a non-pending application). Admins moderate reviews with `DELETE /reviews/:id`.
Boolean switches (`autoApproveProducts`, `enabled`) must be JSON `true`/`false` — anything else is a 422
`Valoarea trebuie să fie adevărat sau fals.`; an unknown key in `PATCH /admin/settings` is a 422 on
`body.<key>` (`Câmpuri necunoscute în cerere: <key>.`).

### 5.18 App config — `/config`

| Method & path | Auth | Success |
|---|---|---|
| `GET /config` | 🔓 | 200 `{ "autoApproveProducts": bool }` — `Cache-Control: public, max-age=60` |

Read it on the "add product" screen to say whether the product goes live immediately or waits for review.
It reflects only the global switch; the product is auto-approved only if the shop is also approved and
not excluded (§3), so trust the `status` in the `POST /shop/products` response.

---

## 6. Duplicate-key conflicts (409) you may see

Besides the endpoint-specific 409s above, a race on a unique field returns 409 `CONFLICT` with one of:
`Există deja un cont cu această adresă de email.` · `Ai deja un cont de producător.` ·
`Ai adăugat deja acest element la favorite.` · `Ai lăsat deja o recenzie pentru acest element.` ·
`Acest dispozitiv este deja înregistrat.` · `Există deja o înregistrare pentru acest cont.`

---

## 7. Realtime chat — STOMP 1.2 over WebSocket

REST stays the source of truth; the socket is a live mirror. After any reconnect, resync with
`GET /chat/threads` + `GET /chat/unread-count` (+ messages of the open thread). Messages sent via REST are
pushed exactly like messages sent via the socket.

### 7.1 Connect

```dart
StompClient(config: StompConfig(
  url: 'wss://<host>/ws',
  stompConnectHeaders: {'Authorization': 'Bearer $accessToken'},
  heartbeatOutgoing: const Duration(seconds: 10),
  heartbeatIncoming: const Duration(seconds: 10),
  onConnect: …, onStompError: …, onWebSocketDone: …,
));
```
- Browsers can't set WS headers: on Flutter Web connect to `wss://<host>/ws?token=<accessToken>` and send
  `CONNECT` without the header.
- Reply: `CONNECTED` with `version:1.2`, `heart-beat:10000,10000`, `user-name:<userId>`.
- Heart-beats: the server sends `\n` every 10 s and closes a socket silent for 2 intervals. A socket must
  `CONNECT` within 10 s of opening (else closed with code 4000).
- **Token expiry:** when the access token expires the server sends `ERROR` `UNAUTHENTICATED` and closes.
  Refresh the token and reconnect (ideally refresh proactively ~1 min before `exp` and reconnect).
- Max **5** sockets per user; a 6th evicts the **oldest**: it receives `ERROR` with header
  `message:SESSION_LIMIT` and body `{"code":"SESSION_LIMIT","message":"Conversațiile sunt deschise pe prea
  multe dispozitive."}`, then is closed with WebSocket code 4008. **On `SESSION_LIMIT`, stop
  auto-reconnecting** (show the message; reconnect only on a user action such as reopening chat).

### 7.2 Subscriptions

Give every `SUBSCRIBE` an `id`; add `receipt:<id>` to get a `RECEIPT` when accepted.

| Destination | Payload | When |
|---|---|---|
| `/user/queue/thread/<threadId>` | Message | a message is stored in that thread — to both participants, **sender included** |
| `/user/queue/threads` | **one** ChatThread (not a list), with the recipient's own `unreadCount` | after every send (both sides) and after you read a thread (your devices) |
| `/user/queue/unread` | `{ "total": 7, "threads": 3 }` | your unread total changed |
| `/user/queue/read` | `{ "threadId", "readerId", "readAt" }` | the **other** participant read the thread → show "Văzut" |
| `/user/queue/typing` | `{ "threadId", "userId" }` | the other participant is typing (show 3–5 s) |

W3 (`/threads`) is a single object: upsert it into the list by `id`, re-sort by `lastMessageAt`.
Subscribing to a thread you're not in → `ERROR` `FORBIDDEN`; missing thread → `NOT_FOUND`; unknown
destination → `BAD_REQUEST`. The socket stays open.

### 7.3 Sending

| Destination | Body | Same as |
|---|---|---|
| `/app/chat.send` | `{ "threadId", "text", "clientMessageId"? }` | `POST /chat/threads/:id/messages` |
| `/app/chat.read` | `{ "threadId" }` | `POST /chat/threads/:id/read` |
| `/app/chat.typing` | `{ "threadId" }` | — (not stored; at most 1 per 3 s per thread is forwarded) |

Add `receipt:<id>` to each `SEND`; the server replies `RECEIPT` (`receipt-id:<id>`) once the message is
stored / the read applied. A retried `clientMessageId` gets a RECEIPT but is not stored or pushed twice.
Reading an already-read thread pushes nothing.

### 7.4 ERROR frames

```
ERROR
message:VALIDATION_ERROR
content-type:application/json
receipt-id:r-42

{"code":"VALIDATION_ERROR","message":"Mesajul nu poate fi gol.","details":[{"field":"body.text","message":"Mesajul nu poate fi gol."}]}
```

| `code` | When | Socket |
|---|---|---|
| `UNAUTHENTICATED` (`Sesiunea a expirat.`) | bad/expired token, or any frame before CONNECT | **closed** → refresh + reconnect |
| `SESSION_LIMIT` | this socket was evicted by a 6th one for the same user | **closed** → do **not** reconnect automatically |
| `BAD_REQUEST` `Mesajul este prea mare.` | frame > 16 KB | **closed** |
| `VALIDATION_ERROR` | invalid body (`details[]` with `body.` prefix; `threadId` → `Conversația nu este validă.`) | open |
| `BAD_REQUEST` | body not JSON (`Mesajul trimis nu este valid.`), unknown command/destination, SUBSCRIBE without `id` | open |
| `FORBIDDEN` / `NOT_FOUND` | thread not yours / missing | open |
| `CONFLICT` | `clientMessageId` used in another thread | open |
| `TOO_MANY_REQUESTS` | > 20 messages / 10 s (shared with REST) | open |
| `INTERNAL_ERROR` | server fault | open |

`receipt-id` is present when the failing frame had a `receipt` — use it to fail the right optimistic bubble.

---

## 8. Field rules reference

| Field | Rule | Message on failure |
|---|---|---|
| `phone` (users/me, partner apply, shop producer, my-locations) | Spaces `.` `-` `(` `)` stripped; `0040…` → `+40…`; national form must match `^0[23789]\d{8}$` (mobile 07…, landline 02…/03…, also 08…/09…). Stored as `07…` or `+407…`. Required on partner apply only; `""` clears elsewhere. Foreign numbers are rejected. | `Numărul de telefon nu este valid. Folosește formatul 07xxxxxxxx sau +407xxxxxxxx.` |
| `email` (auth) | valid e-mail; stored lowercased | `Adresa de email nu este validă.` |
| `email` (my-locations, shop producer, partner apply) | optional valid e-mail, lowercased; `""` clears on update | `Adresa de email nu este validă.` |
| `volume` (product) | optional, ≤ 50 chars after trim; `""`/`null` clears on PUT | generic length message |
| `type` (booking place) | optional, one of `cabin`, `room`, `apartment`, `guesthouse`, `campsite`; `null` clears on PUT | `Valoarea pentru tipul cazării trebuie să fie una dintre: cabin, room, apartment, guesthouse, campsite.` |
| partner `photos` | optional, ≤ 20 URLs | generic |
| `website` (producer, partner) | full `http(s)://` URL — **no scheme is added for you** | `Linkul pentru site-ul web nu este valid.` |
| `website` (my-locations) | full URL; `""` clears | `Adresa site-ului nu este validă. Folosește formatul https://exemplu.ro.` |
| `password` (register, reset) | 8–100 chars | generic length message |
| `newPassword` (change password) | 8–100 chars | generic length message |
| `businessName` | 2–100, trimmed | generic |
| `description` (producer, product, booking place) | **10**–2000, trimmed | generic |
| `locality`, `county` | 2–100; required on producer/partner create (unless a legacy `"locality, county"` address is sent) | `Completează localitatea.` / `Completează județul.` |
| `latitude` / `longitude` | numbers in [-90, 90] / [-180, 180]; both or neither; `0,0` rejected | `Trimite împreună latitudinea și longitudinea.` / `Alege un punct pe hartă — coordonatele 0, 0 nu sunt o locație validă.` |
| `servedLocalities` | optional array ≤ 50 of ServedLocality (§4.4); `kind ∈ city\|commune\|village`; `countyCode` 1–99 | generic |
| `categories` | 1–10 category ids | generic |
| `photos` | ≤ 20 URLs | generic |
| `capacity` / `pricePerNight` | int ≥ 1 / number ≥ 0 | generic |
| `price` / `stock` | number ≥ 0 / int ≥ 0 | generic |
| `rating` | JSON int 1–5 | generic |
| review `comment` | ≤ 1000 after trim | `Comentariul poate avea cel mult 1000 de caractere.` |
| chat `text` | 1–2000 after trim | `Mesajul nu poate fi gol.` / `Mesajul poate avea cel mult 2000 de caractere.` |
| `clientMessageId` | `[A-Za-z0-9-]{1,64}` | `Identificator de mesaj invalid.` |
| `radiusKm` | 0.5–200, with `lat` + `lng` | `Raza trebuie să fie între 0,5 și 200 km.` |
| `size` | int 1–100 | chat: `Parametru de paginare invalid.`; elsewhere generic |

Validate in the forms with the same rules so users rarely see a 422.

---

## 9. Recent server changes the app must adopt

| Area | Change | App action |
|---|---|---|
| Chat thread | `otherParticipant` now `{ id, userId, name, avatarUrl, kind, producerId, bookingPlaceId }`; `name` may be null | Update model; label rows from it; tap → open producer/booking place by `kind`; stop fetching `/producers`, `/booking-places` just to name rows |
| Chat open | `participantId` may be producer / booking place / user id; `participantType` accepted; self → 422 | Send the id you have plus `participantType`; hide "message" on own listings |
| Chat unread | `GET /chat/unread-count`; `unreadCount` is per caller | Drive the nav badge from it (or from WS `/user/queue/unread`); stop summing thread lists |
| Chat messages | `before` cursor; default size 30; `clientMessageId` idempotency; 429 on spam | Implement infinite scroll + optimistic sending |
| Chat realtime | Full STOMP spec (§7); W3 pushes **one thread**, not the list | Wire `stomp_dart_client`; drop 30 s polling while connected |
| Chat push | FCM notification `{ title, body, data: { route: "/chat/<threadId>", threadId, type: "chat_message" } }` when the recipient has no socket open | Deep-link on `data.route` |
| Area filter | `radiusKm` now **required** with `lat`/`lng`, 0.5–200; no 25 km default | Always send all three |
| Phone | Romanian numbers only | Same rule in every phone field; normalise before sending is optional |
| Reviews | `author` added; own-listing → 403; unapproved/missing target → 404; `PUT`/`DELETE /reviews/:id` | Show author; "edit/delete my review"; hide review button on own listings |
| Password reset | `POST /auth/forgot-password` → 204; reset handled on a web page | Add "Ai uitat parola?" screen |
| Producer / Partner / BookingPlace | `locality`, `county`, `latitude`, `longitude` always present (null when unknown); `servedLocalities` on producer & application; BookingPlace `phone`/`email`/`website` | Update models and forms |
| Moderation | `rejectionReason` now also on Producer; `moderationNote` + product `sections` on owner endpoints; approve clears old feedback | Show the feedback on owner screens |
| Change password | Wrong current password → **422** on `body.currentPassword` (was 401); success message Romanian; `newPassword` max 100 | Field error, no refresh |
| Cart / favorites | Adding a missing/unapproved product → 404; unavailable → 409; cart line capped at 999 → 422; favoriting a missing/unapproved target → 404 | Handle the new errors |
| Uploads | Romanian error messages; only the uploader (or admin) can delete an image | Show messages as-is; only delete your own uploads |
| Transport | Malformed JSON → 400, body > 100 KB → 413, disallowed web origin → 403 (were 500) | Map per §1.4 |
| Rate limit | 600 / 15 min per signed-in user (was 100 per IP) | Keep 429 handling |
| Public lists | `GET /products`, `/producers`, `/booking-places` are approved-only for everyone; `total` is exact | Remove client-side status filtering on catalogue screens |
| Product | `producer` summary embedded everywhere; `volume` added | Use `product.producer` on cards; stop per-producer fetches; add volume to form/model |
| Moderation | `sections` on Producer, BookingPlace, PartnerApplication (+ Product), fixed section names per kind; application `moderationNote`; approve clears `sections` | Send `details` / `contact` / `images` only; show feedback |
| Admin | `PATCH …/sections/:section` for producers, booking places, applications; `PATCH /admin/applications/:id/request-info`; `GET /admin/producers?verified=false` | Wire the actions that were no-ops; "to verify" list = `status=approved&verified=false` |
| New fields | PartnerApplication `email` + `photos` (copied to the Producer on approval); Producer `email`; BookingPlace `type` | Send and display them |
| Realtime | Evicted socket gets `ERROR message:SESSION_LIMIT` before close 4008 | Stop reconnecting on `SESSION_LIMIT` |
| My locations | `/my-locations` open to every signed-in user (was `business`/`admin` only); hosts stay `client` | Show "Listează o cazare" / "Locațiile mele" to clients too; no partner application for hosts |
| Auto-approve | Admin switch `autoApproveProducts` (`GET`/`PATCH /admin/settings`, public `GET /config`); per-producer `PATCH /admin/producers/:id/auto-approve`; Product `autoApproved`, Producer `autoApproveProducts` (owner/admin); `GET /admin/products?autoApproved=` | Admin: settings toggle, per-shop toggle, "unreviewed" filter. Business: use `status` from the create response; optional hint from `/config` |

---

## 10. Remaining server quirks (current behaviour)

### 10.1 `POST /chat/threads/:id/read` returns `{ "ok": true }`
Ignore the body.

### 10.2 Product `stock` is informational
The server never decrements or enforces `stock`; only `available` blocks adding to the cart. Show stock as
information, don't rely on it.

### 10.3 Edits after approval keep the item approved
Changing an approved product, shop or booking place does not send it back to moderation (§3).

### 10.4 `PUT /users/me/password` doesn't log out other sessions
Only a password **reset** revokes all sessions. After a change, the current tokens keep working.

---

## 11. Alignment checklist for the Flutter agent

- [ ] HTTP client: envelope parsing; 204 handling; `details[]` → form errors with prefix stripped; error
      code switch per §1.4; Romanian `error.message` shown verbatim.
- [ ] Interceptor: single-flight refresh on 401 (not for `/auth/*`); store the rotated refresh token every
      time; one refresh+retry on 403; logout on refresh 401.
- [ ] 400 (malformed JSON), 413 (`PAYLOAD_TOO_LARGE`) and 429 handled with the server message.
- [ ] Lockout (400 on login) and reset-link errors (400) shown as messages, no refresh.
- [ ] FCM: register device after login / on token refresh; unregister before logout; handle
      `{ action: "REFRESH_TOKEN" }` (refresh + reload `/users/me`); deep-link `data.route` for chat.
- [ ] Models updated exactly as §4 (absent vs null, `num` parsing, `otherParticipant`, `author`,
      `clientMessageId`, location fields, `servedLocalities`, BookingPlace contact fields).
- [ ] Public catalogue screens use the lists as-is (approved-only, exact `total`); owner screens use
      `/shop/*` and `/my-locations`.
- [ ] Product cards use the embedded `product.producer`; no per-producer fetch.
- [ ] Area filter always sends `lat`, `lng`, `radiusKm` (0.5–200).
- [ ] Phone validation = §8 rule everywhere.
- [ ] Favorites delete by favorite `id`; cart `POST` increments, `PUT` sets; handle cart 404/409/422 and
      favorites 404.
- [ ] Owner screens and the application status screen show `rejectionReason`, `moderationNote` and
      `sections` feedback.
- [ ] Admin console: section reviews only with the allowed names (§3), application request-info, "to
      verify" list with `verified=false`.
- [ ] Profile shows "Listează o cazare" (→ `/my-locations`) to clients; hosts are not sent through the
      partner application.
- [ ] Auto-approve: admin settings toggle + per-producer toggle + `autoApproved` filter; "add product"
      shows the `/config` hint and trusts the returned `status` (may be `approved` right away).
- [ ] Forms send the new fields: application `email`/`photos`, producer `email`, product `volume`,
      booking place `type`.
- [ ] Change password: 422 on `currentPassword` shown on the field; own success text.
- [ ] Reviews: author display, edit/delete own, hide on own listings, handle 403/404/409.
- [ ] Partner flow: apply → `GET /partner/application` status → on approval refresh role.
- [ ] Shop / My locations forms send the documented bodies (my-locations `address` is required).
- [ ] Uploads: multipart field `files`, ≤ 10 × 10 MB, then use returned `url`s.
- [ ] Chat REST: `participantType`, unread endpoint, cursor paging, `clientMessageId` + optimistic UI, 429.
- [ ] Chat WS: connect/heart-beat/token-expiry reconnect, no reconnect on `SESSION_LIMIT`, subscriptions §7.2,
      `/app/chat.*` sends with receipts, ERROR handling §7.4, resync after reconnect, stop polling.
- [ ] "Forgot password" screen.
- [ ] 429 handling and no needless polling (§1.7).
