# Prompt for the vt-api agent

> Paste everything below the line into the Claude session working on `vt-api`.
> Optionally attach `docs/BACKEND_TASKS.md` from the Flutter repo — it holds the long-form
> rationale for B1–B9. This prompt is self-contained without it.

---

You are working on **vt-api**, the Node.js + Express + MongoDB backend of **Viață la Țară**, a
farm-to-consumer marketplace. The Flutter client (Android / iOS / Web) is already integrated against
this API and has shipped several features that the server does not support yet. Your job is to close
those gaps. Read the whole brief before changing anything, then work through it in the **priority
order** in §2.

## 1. Contract rules — do not break these

The client's error handling and DTO mappers are built on them.

- **Envelope on every response, errors included:**
  ```jsonc
  { "data": <payload>, "error": null }
  { "data": null, "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [ { "field": "body.phone", "message": "..." } ] } }
  ```
- **`error.message` is shown to the end user verbatim.** Write it in **Romanian**, addressed to the
  user, not to a developer (e.g. *"Mesajul nu poate fi gol."*, not *"text is required"*).
- **`error.code`** is what the client switches on: `UNAUTHENTICATED` (401), `FORBIDDEN` (403),
  `NOT_FOUND` (404), `CONFLICT` (409), `VALIDATION_ERROR` (422), `BAD_REQUEST` (400),
  `INTERNAL_ERROR` (500). Unknown codes fall back to the HTTP status.
- **`details[].field`** carries a `body.` / `query.` / `params.` prefix; the client strips it and
  attaches the message to the matching form input. Return one entry per invalid field.
- **Additive changes only.** Keep every field and endpoint that exists today (`address`,
  `participants` as an id array, etc.). Old app builds are in testers' hands.
- **Auth stays as it is:** `Authorization: Bearer <accessToken>`; a 401 with `UNAUTHENTICATED`
  triggers the client's refresh-and-retry, a 403 also triggers one refresh (stale role).
- **Product visibility:** public endpoints return only `approved` products. Owners and admins see
  every status.
- Dates are ISO-8601 UTC strings. IDs are strings.

## 2. Priority order

| # | Task | Why it matters now |
|---|---|---|
| 1 | **C1–C6 Chat REST** | The app's bottom-nav unread badge and the conversation list depend on it |
| 2 | **W1–W8 Chat realtime (STOMP over `/ws`)** | Replaces the client's 30 s polling with live delivery |
| 3 | **S1 Shop products visibility** | Owners can't see their pending products on "Magazinul meu" |
| 4 | **B1, B3, B9 Structured location** | The client already sends these fields; the server drops them |
| 5 | **B2 Area filter** | Home map filters on mock data until this ships |
| 6 | **B7 Booking contact**, **B8 Password reset**, **B4 Duplicate-key errors**, **B6 Phone check** | Smaller gaps, client side is done |
| 7 | **P1 Push for chat (optional)** | Only useful once Firebase is configured client-side |

---

## 3. Chat — REST (C1–C6)

Current endpoints, all authenticated, keep them working:

| Method | Path | Body / query | `data` |
|---|---|---|---|
| GET | `/api/chat/threads` | — | **plain array** of ChatThreadModel |
| POST | `/api/chat/threads` | `{participantId}` | ChatThreadModel |
| GET | `/api/chat/threads/:id/messages` | `page, size` | paginated MessageModel |
| POST | `/api/chat/threads/:id/messages` | `{text}` | MessageModel |
| POST | `/api/chat/threads/:id/read` | — | — |

### C1 — Unread total endpoint (new)

The app shows the total number of unread messages as a badge on the Chat tab and currently polls
`GET /api/chat/threads` every 30 s to sum `unreadCount`. Add a cheap endpoint for it:

```
GET /api/chat/unread-count   →   { "data": { "total": 7, "threads": 3 } }
```

- `total` = messages in the caller's threads where `senderId != caller` and `caller ∉ readBy`.
- `threads` = number of threads with at least one such message.
- Must be a single indexed query/aggregation — it is polled. Add an index that supports it
  (e.g. `{ threadId: 1, senderId: 1, readBy: 1 }` on messages, or maintain a per-participant
  unread counter on the thread document and update it on send/read).

### C2 — `unreadCount` must be per caller (verify)

`unreadCount` on each ChatThreadModel must be computed **for the requesting user** with the same
rule as C1, not stored as a single number on the thread. A producer and a buyer looking at the same
thread see different counts. Add a test for it.

### C3 — Mark read semantics (verify / fix)

`POST /api/chat/threads/:id/read`:
- adds the caller to `readBy` on **every** message in the thread not sent by the caller;
- is idempotent; returns `204` (or `200` with `data: null`);
- `403 FORBIDDEN` if the caller is not a participant, `404 NOT_FOUND` if the thread doesn't exist;
- after it succeeds, emits W5 (read receipt to the other party) and W4 (new unread total to the
  caller's other devices).

### C4 — Counterpart identity on threads (was B5)

`participants` is an array of bare user ids and there is no public endpoint to turn an id into a
name. The client currently fetches `/producers`, `/booking-places` and `/users/me` on every visit
just to label rows, and still can't name a plain buyer. It also guesses the counterpart as
`participants[1]`, which is wrong whenever the caller is the producer.

Add, **additively**, on every ChatThreadModel the server returns (REST and WS):

```jsonc
{
  "id": "t_1",
  "participants": ["u_1", "u_2"],                 // unchanged
  "otherParticipant": {                           // new — always "the participant that is not the caller"
    "userId": "u_1",
    "name": "Ferma Ionescu",                      // shop businessName → booking place name → user displayName → null
    "avatarUrl": "https://...",                   // shop/place first photo → user avatar → null
    "kind": "producer",                           // "producer" | "bookingPlace" | "user"
    "producerId": "p_9",                          // when kind = producer, else null
    "bookingPlaceId": null                        // when kind = bookingPlace, else null
  },
  "lastMessagePreview": "...",
  "lastMessageAt": "2026-10-24T10:00:00Z",
  "unreadCount": 2,
  "createdAt": "...", "updatedAt": "..."
}
```

Resolve it in one query (aggregation `$lookup` or a batched lookup per page), not one query per
thread. Name resolution order: the user's producer `businessName`, then their booking place `name`
(when the thread was opened from a booking place — see C5), then the user's display name.

### C5 — Opening a thread (`POST /api/chat/threads`)

The client sends `participantId`, which can be a **producer id**, a **booking place id** or a
**user id** depending on the screen the user came from. Today it is ambiguous what the server stores.

- Resolve `participantId` server-side: producer → its owner `userId`; booking place → its owner
  `userId`; otherwise treat as a user id. `404 NOT_FOUND` (*"Destinatarul nu a fost găsit."*) when
  nothing matches.
- Always store **user ids** in `participants`. Store the context on the thread
  (`context: { kind: "producer" | "bookingPlace" | "user", id }`) so C4 can name it correctly.
- **Idempotent:** if a thread between the two users with the same context already exists, return it
  (`200`) instead of creating a duplicate. Use a unique index on the sorted participant pair + context
  so concurrent taps can't create two.
- `422 VALIDATION_ERROR` when the caller targets themselves (*"Nu îți poți trimite mesaje ție."*).
- Optional: accept an explicit `{ participantType: "producer" | "bookingPlace" | "user" }` alongside
  `participantId` to skip the guessing. The client will start sending it once you confirm.

### C6 — Messages

- `GET /api/chat/threads/:id/messages`: keep `page, size` (newest page first — the client sorts
  chronologically). **Add cursor paging** `before=<messageId|ISO date>&size=` for infinite scroll
  upward; `size` default 30, max 100.
- `POST /api/chat/threads/:id/messages`: accept an optional `clientMessageId` (UUID, ≤ 64 chars). If
  a message with the same `(senderId, clientMessageId)` exists, return it instead of inserting —
  lets the client retry safely and dedupe its optimistic bubble against the WS echo. Return it in
  MessageModel.
- On send: update the thread's `lastMessagePreview` (first 120 chars, whitespace collapsed),
  `lastMessageAt`, and emit W2 + W3 + W4.
- Validation and limits in §7.

---

## 4. Chat — realtime over STOMP (W1–W8)

The server already speaks a minimal STOMP 1.2 dialect at `ws(s)://<host>/ws` with
`/user/queue/thread/<threadId>` and `/user/queue/threads`. The client is about to wire it with
`stomp_dart_client`. Harden and extend it as follows. **REST stays the source of truth**; everything
here is a live mirror of it, so a client that misses events can always resync via REST.

### W1 — Connect & auth
- URL `ws(s)://<host>/ws`. Token via `CONNECT` header `Authorization: Bearer <accessToken>`
  (keep `?token=` as a fallback — browsers can't set headers on WebSocket).
- Invalid/expired token → `ERROR` frame with header `message:UNAUTHENTICATED` and a JSON body
  `{ "code": "UNAUTHENTICATED", "message": "Sesiunea a expirat." }`, then close. The client refreshes
  the token and reconnects.
- When a connected socket's token expires, send the same `ERROR` and close; don't silently keep it.
- Support **heart-beating** (`heart-beat:10000,10000`) and drop sockets that miss 2 beats.
- Allow multiple concurrent sockets per user (phone + tablet + web) and fan out to all of them.
- Return `CONNECTED` with `version:1.2` and the negotiated `heart-beat`.

### W2 — New message in a thread
`SUBSCRIBE /user/queue/thread/<threadId>` → `MESSAGE` with a MessageModel body (including
`clientMessageId` when present), delivered to **both** participants, sender included (so the
sender's other devices update and the sender can confirm its optimistic bubble).
Reject subscriptions to threads the user isn't in with an `ERROR` (`FORBIDDEN`).

### W3 — Thread list updates
`SUBSCRIBE /user/queue/threads` → after every send or read, push **only the changed thread**
(a single ChatThreadModel with C4's `otherParticipant` and the recipient's own `unreadCount`) instead
of the whole list. If you keep sending the full array, say so — the client will handle either, but a
single object is preferred. Pick one and document it.

### W4 — Unread total (new)
`SUBSCRIBE /user/queue/unread` → `{ "total": 7, "threads": 3 }` (same shape as C1), pushed to the
user whenever their total changes (message received, thread read on any device). This drives the
bottom-nav badge directly.

### W5 — Read receipts (new)
`SUBSCRIBE /user/queue/read` → `{ "threadId": "t_1", "readerId": "u_2", "readAt": "..." }`, pushed to
the **other** participant when C3 runs, so their "Văzut" indicator updates.

### W6 — Sending over the socket
- `SEND destination:/app/chat.send` with body `{ "threadId", "text", "clientMessageId" }`. Same
  validation, persistence and fan-out as the REST `POST .../messages`. Keep accepting any destination
  for backward compatibility, but document `/app/chat.send` as canonical.
- `SEND destination:/app/chat.read` with body `{ "threadId" }` — same as C3.
- Honour the STOMP `receipt` header: reply with `RECEIPT receipt-id:<id>` once persisted.
- On validation failure reply with an `ERROR` frame whose body is the standard error object
  `{ "code": "VALIDATION_ERROR", "message": "...", "details": [...] }` plus header
  `receipt-id:<id>` when one was sent. **Do not close the connection** for validation errors —
  only for auth failures.

### W7 — Typing indicator (optional, low priority)
`SEND /app/chat.typing { threadId }` → `SUBSCRIBE /user/queue/typing` →
`{ threadId, userId }`, ephemeral, not persisted, throttled to 1 per 3 s per user per thread.

### W8 — Abuse limits
- Max frame 16 KB; reject larger with `ERROR` + close.
- Rate limit sends: 20 messages / 10 s per user (REST and WS share the bucket) → `ERROR`
  `{ code: "TOO_MANY_REQUESTS", message: "Trimiți mesaje prea repede. Încearcă din nou în câteva secunde." }`
  (HTTP 429 on REST).
- Max 5 concurrent sockets per user; close the oldest beyond that.

**Deliverable for W:** update the realtime section of `FLUTTER_API_INTEGRATION.md` (or the README)
with every destination, payload and error, plus one end-to-end test that opens two sockets as two
users and checks W2–W5.

---

## 5. Push notification on new message (P1 — optional)

`POST /api/devices` already exists. When a message is sent and the recipient has **no open socket**,
send an FCM notification to their registered devices:

```jsonc
{
  "notification": { "title": "<otherParticipant.name of the sender>", "body": "<preview, ≤ 120 chars>" },
  "data": { "route": "/chat/<threadId>", "threadId": "<threadId>", "type": "chat_message" }
}
```

The client deep-links on `data.route`. Collapse per thread (`collapse_key` / `apns-collapse-id` =
threadId). Remove tokens FCM reports as unregistered. The client still runs on a stub push data
source until Firebase is configured, so this is lowest priority.

---

## 6. Other gaps (S1, B1–B9)

### S1 — Shop products visibility
"Magazinul meu" lists the owner's products. Confirm/ensure:
- `GET /api/shop/products` returns the caller's products in **every** status (`pending`, `approved`,
  `rejected`, `requestInfo`) with `status` and `rejectionReason`, paginated, newest first. Add an
  optional `status` filter.
- `GET /api/products?producerId=` stays **approved-only**, even for the owner. The client will switch
  the shop page to `/shop/products`.

### B1 — Structured location on Producer
Accept `latitude`, `longitude`, `locality`, `county` on `POST /api/partner/apply`,
`POST /api/shop/producer`, `PUT /api/shop/producer` (the client already sends them). Return them on
every ProducerModel / PartnerApplicationModel (`/producers`, `/producers/:id`, `/shop/producer`,
admin equivalents). Keep `address`; if absent, derive it as `"{locality}, {county}"`.

### B2 — Area filter
Add `lat`, `lng`, `radiusKm` to `GET /api/products`, `GET /api/producers`, `GET /api/booking-places`
(the client already sends them; the server ignores them). Use a `2dsphere` index and `$geoWithin`
(`$centerSphere`). Products inherit their producer's location (join or denormalise). No match →
empty `items`, not an error. Validation in §7.

### B3 — Structured location on BookingPlace
Same four fields on `POST /api/my-locations`, `PUT /api/my-locations/:id`, returned on every
BookingPlaceModel (`/booking-places`, `/booking-places/:id`, `/my-locations`, admin).

### B4 — Duplicate keys
Catch Mongo `E11000` everywhere (start with `/partner/apply`, `/shop/producer`, chat thread create)
and return `409 CONFLICT` with a Romanian message naming the collision, e.g. *"Există deja o cerere de
producător pentru acest cont."* A second partner application must be 409, never 500.

### B6 — Verify `PUT /api/users/me` persists `phone`
Confirm `phone` is whitelisted, returned by `GET /api/users/me`, and that `+407xxxxxxxx` passes.

### B7 — Booking place contact
Accept and persist flat `phone`, `email`, `website` on `/my-locations` POST/PUT and return them on
every BookingPlaceModel. On PUT, `""` clears the field, an absent key leaves it untouched.

### B8 — Password reset
- `POST /api/auth/forgot-password` `{email}` → **always `204`**, even for unknown e-mails (no
  enumeration). Mails a single-use link, valid 60 min. Rate limit 3 / hour / e-mail and 10 / hour / IP.
- `POST /api/auth/reset-password` `{token, password}` → `204`; invalidates the token and **all**
  refresh tokens of that user. Expired/used token → `422` *"Linkul de resetare a expirat. Cere unul nou."*
- Store only a hash of the token. Tell us the link format the e-mail uses (web page now; we'll add an
  app deep link later).

### B9 — `servedLocalities` on Producer
Accept and persist on the three producer write endpoints, return on every ProducerModel, **preserve
order** (first entry = the shop's own locality):

```json
"servedLocalities": [
  { "id": "2:9341:Ineu", "name": "Ineu", "kind": "city", "countyCode": 2, "countyName": "Arad", "siruta": 9341 },
  { "id": "2:9743:Cil", "name": "Cil", "kind": "village", "countyCode": 2, "countyName": "Arad", "parentName": "Almaș" }
]
```

---

## 7. Validation rules

Trim strings before validating. Reject unknown fields silently (strip), don't 422 on them. Each
failure → `422 VALIDATION_ERROR` with a `details[]` entry per field, Romanian messages.

| Field | Where | Rule | Message (suggested) |
|---|---|---|---|
| `text` | chat send (REST + WS) | required, 1–2000 chars after trim, not only whitespace | *"Mesajul nu poate fi gol."* / *"Mesajul poate avea cel mult 2000 de caractere."* |
| `clientMessageId` | chat send | optional, ≤ 64 chars, `[A-Za-z0-9-]` | *"Identificator de mesaj invalid."* |
| `participantId` | thread create | required, valid id, not the caller | *"Destinatarul nu a fost găsit."* |
| `threadId` (params/WS) | all chat | valid id, caller is a participant (else 403) | *"Nu ai acces la această conversație."* |
| `size` | paginated lists | int 1–100, default 20 (30 for messages) | *"Parametru de paginare invalid."* |
| `phone` | partner apply, shop producer, users/me, my-locations | normalise by stripping spaces `.` `-` `(` `)`; `0040…` → `+40…`; then national form must match `^0[23789]\d{8}$` (accept `07xxxxxxxx` and `+407xxxxxxxx`). Required on partner apply, optional elsewhere | *"Număr de telefon invalid."* |
| `email` | auth, my-locations | RFC-ish, ≤ 254 chars, lowercased | *"Adresă de e-mail invalidă."* |
| `website` | producer, my-locations | optional, `http(s)://` URL ≤ 300 chars; prepend `https://` if scheme missing | *"Adresă web invalidă."* |
| `password` | register, reset | ≥ 8 chars | *"Parola trebuie să aibă cel puțin 8 caractere."* |
| `businessName` | producer | required, 2–100 | *"Numele afacerii trebuie să aibă între 2 și 100 de caractere."* |
| `description` | producer, booking place, product | ≤ 2000 | *"Descrierea poate avea cel mult 2000 de caractere."* |
| `locality`, `county` | producer, booking place | required on create, 2–100 | *"Alege localitatea."* / *"Alege județul."* |
| `latitude` / `longitude` | producer, booking place, area filter | optional on write; numbers in [-90, 90] / [-180, 180]; **reject `0,0`** (client's "unset" sentinel); both or neither | *"Coordonate invalide."* |
| `radiusKm` | area filter | 0.5–200; `lat`, `lng`, `radiusKm` all-or-none | *"Raza trebuie să fie între 0,5 și 200 km."* |
| `servedLocalities` | producer | array, 1–50 items; `id` matches `^\d+:\d+:.+$`; `kind ∈ {city, commune, village}`; `siruta` int when kind ≠ village; no duplicate `id` | *"Localitate invalidă."* |
| `capacity` | booking place | int ≥ 1 | *"Capacitatea trebuie să fie cel puțin 1."* |
| `pricePerNight` | booking place | number ≥ 0 | *"Prețul nu poate fi negativ."* |

If any of your existing rules are stricter than this table, keep yours and list them in your report
so the client forms can match.

---

## 8. Definition of done

1. Every task above implemented or explicitly declined with a reason.
2. Tests: unit tests for C1/C2 unread math, C5 idempotency, validation 422s; an e2e WS test for
   W2–W5; existing tests green.
3. `vt-api.postman_collection.json` updated with every new/changed endpoint.
4. `FLUTTER_API_INTEGRATION.md` updated (endpoint table + realtime section).
5. **A short report back** answering:
   - B2: radius filter shipped as specified? Any param name differences?
   - C4: exact `otherParticipant` shape shipped.
   - C5: does the server now accept `participantType`?
   - W3: single changed thread or full array?
   - W6: canonical send destinations and the exact ERROR/RECEIPT formats.
   - B8: reset-link URL format.
   - Any validation rule that differs from §7.
