# Backend tasks — vt-api

Work the Flutter client needs from `vt-api`, extracted from the 14-item fix pass in
[`FIX_PLAN.md`](./FIX_PLAN.md). Everything the client could fix on its own has shipped; what is
listed here is the remainder, all of it marked `[BACKEND]` in that plan.

Two of these (**B1**, **B2**) block a feature that is already live in the app and currently runs on
mock data. **B3** and **B5** are real data gaps the client cannot work around. **B4** is a
diagnosability fix. **B6** is verification only.

| # | Task | Blocks | Size |
|---|---|---|---|
| B1 | Coordinates + locality/county on Producer | P12, P13 | M |
| B2 | Area filter on catalogue listings | P12 | M |
| B3 | Coordinates + locality/county on BookingPlace | P12 | S |
| B4 | Never surface a duplicate key as a generic 500 | P14 | S |
| B5 | Participant identity on chat threads | P3 | S |
| B6 | Confirm `PUT /users/me` persists `phone` | P5 | XS |
| B7 | Contact details on BookingPlace | P9 | S |
| B8 | Password reset (`POST /api/auth/forgot-password`) | P1 | M |
| B9 | `servedLocalities` on Producer | P13 | S |

---

## Contract rules the client depends on

Please keep these intact — the app's `ErrorInterceptor` and DTO mappers are built on them.

- **Envelope**, on every response including errors:
  ```jsonc
  { "data": <payload>, "error": null }
  { "data": null, "error": { "code": "STRING_CODE", "message": "...", "details": [ { "field": "body.phone", "message": "..." } ] } }
  ```
- **`error.message` is shown to the user verbatim.** As of this pass the app no longer replaces
  backend errors with "a apărut o eroare" — it renders `error.message` directly. Please write these
  messages in Romanian, addressed to the end user, not to a developer.
- **`error.code`** is what the client switches on: `UNAUTHENTICATED`, `FORBIDDEN`, `NOT_FOUND`,
  `CONFLICT`, `VALIDATION_ERROR` / `UNPROCESSABLE` / `BAD_REQUEST`, `INTERNAL_ERROR`. Unknown codes
  fall back to the HTTP status.
- **`details[].field`** may carry a `body.` / `query.` / `params.` prefix; the client strips it and
  matches the suffix to a form field.
- **Additive changes only.** Keep every field that exists today, `address` included — the app still
  reads it as a fallback (see B1).

---

## B1 — Coordinates + locality/county on Producer

**Why.** The app models a producer's location as `{latitude, longitude, locality, county, address}`,
but the API only carries a free-text `address`. Two consequences today:

1. Lat/lng arrive as `0, 0`, so a producer can never be matched against a map area (B2).
2. The client has to guess structure out of the string. `producerFromApi` currently splits `address`
   on the first comma into locality/county, because writers join them as `"locality, county"`. That
   is a workaround, and it is wrong for any address that does not follow that shape.

There is also a product decision behind it: a producer page can no longer be created without a
location (P13), and existing producers without one now get a prompt to set it. That only becomes
meaningful once the location is a real field.

**Endpoints.**

Accept on write:

| Method | Path | Current body | Add |
|---|---|---|---|
| POST | `/api/partner/apply` | `{businessName, description, website?, phone, address, categories[]}` | `latitude`, `longitude`, `locality`, `county` |
| POST | `/api/shop/producer` | `{businessName, description, website?, phone?, address, categories[], photos?}` | same |
| PUT | `/api/shop/producer` | partial of the above | same |

Return on read — `ProducerModel`, everywhere it is serialized (`GET /api/producers`,
`GET /api/producers/:id`, `GET /api/shop/producer`, `GET /api/admin/producers`,
`GET /api/admin/producers/:id`) and `PartnerApplicationModel`:

```jsonc
{
  "id": "...",
  "businessName": "...",
  "address": "Bran, Brașov",        // keep — existing clients still read it
  "locality": "Bran",               // new
  "county": "Brașov",               // new
  "latitude": 45.5152,              // new, nullable
  "longitude": 25.3672,             // new, nullable
  "...": "unchanged"
}
```

**Suggested rules.**

- `locality` and `county` required on create; `latitude`/`longitude` optional (the app collects them
  but a producer may leave them blank — see *Client status*).
- Keep writing `address` server-side as `"{locality}, {county}"` when it is not supplied, so nothing
  that reads `address` breaks.
- Validate lat/lng ranges and reject `0, 0` — that is the app's current "no location" sentinel and
  should never be stored as a real value.

**Acceptance.** `GET /api/producers/:id` returns non-null `locality`, `county` and (when set)
`latitude`/`longitude` for a producer created through `POST /api/shop/producer` with those fields.

**Client status — the app already sends all four fields.** "Devino producător" and the shop editor
now use the same location block as a booking place (`LocationFields` in
`lib/core/theme/widgets/location_fields.dart`): locality, county, latitude, longitude and a "use
current location" button. `POST /api/partner/apply` and `POST`/`PUT /api/shop/producer` carry
`locality`, `county`, `latitude`, `longitude` alongside the existing `address`, exactly as
`POST /api/my-locations` already does. They are dropped on the floor until this task lands.

On read, `producerFromApi` and `partnerApplicationFromApi` already prefer the structured fields and
only fall back to comma-splitting `address` when they are absent, so the workaround retires by
itself the moment the server sends them.

---

## B2 — Area filter on catalogue listings

**Why.** The home screen's "Alege locația" tab is a working feature: the user picks a centre on the
map, sets a radius, and the app shows what is in that area. The booking-place side filters on real
coordinates. **The product side filters on hard-coded mock distances**, because `GET /api/products`
takes no geographic parameters and products carry no location at all. That is the single biggest
gap in this list.

**Endpoints.** Add to the existing query strings:

| Method | Path | Current query | Add |
|---|---|---|---|
| GET | `/api/products` | `page, size, categoryId?, producerId?, search?, minPrice?, maxPrice?` | `lat?`, `lng?`, `radiusKm?` **or** `county?`, `locality?` |
| GET | `/api/producers` | `page, size, categoryId?, search?, verified?` | same |
| GET | `/api/booking-places` | `page, size, search?` | same |

**Which filter shape?** Either works for the client — please pick one and tell us which:

- **Radius** (`lat`, `lng`, `radiusKm`) matches the UI exactly: the map already produces a centre and
  a radius, so nothing has to be translated. Needs a `2dsphere` index on the producer / booking-place
  location and a `$geoWithin` / `$nearSphere` query.
- **Administrative** (`county`, `locality`) is simpler to index and query, but the app would have to
  drop the radius slider or reverse-geocode the pin, which it cannot currently do.

Radius is the better fit for what is on screen today.

**Products have no location of their own** — they should inherit their producer's, so the filter is
"products whose producer falls in this area". A lookup/join at query time is fine; denormalising the
producer's coordinates onto the product document is faster if listing volume warrants it.

**Acceptance.** `GET /api/products?lat=45.51&lng=25.36&radiusKm=15` returns only products whose
producer is inside that circle, paginated as usual, and returns an empty `items` array rather than an
error when nothing matches.

**Client status — the app already sends the parameters.** Confirming an area on the map now issues
`GET /products?lat=&lng=&radiusKm=` and `GET /booking-places?lat=&lng=&radiusKm=` (the area is part
of the Riverpod family key, so every confirm is a fresh request). The server currently ignores them
and answers unfiltered, so the app still narrows the response itself behind the
`_kClientSideAreaFilter` constant in
`lib/features/home/presentation/widgets/location_tab_body.dart` — products against mock distances,
booking places against `haversineKm`. When the endpoints filter by area, flip that constant to
`false` and delete `_filterProductsWithin` / `_filterPlacesWithin`; no other client change is needed.

---

## B3 — Coordinates + locality/county on BookingPlace

**Why.** Same gap as B1, on the other entity — and this one needs **no client change at all**. The
booking-place mapper (`lib/features/booking/data/models/booking_place_dto.dart`) already reads
`latitude`, `longitude`, `locality` and `county` from the JSON. `BookingPlaceModel` does not send
them, so they default to `0`, every place lands at the same point off the coast of Africa, and the
area filter discards all of them.

**Endpoints.**

- Accept `latitude`, `longitude`, `locality`, `county` on `POST /api/my-locations` and
  `PUT /api/my-locations/:id` (current body: `{name, description, address, capacity, pricePerNight, photos?, amenities?}`).
- Return them in `BookingPlaceModel` on `GET /api/booking-places`, `GET /api/booking-places/:id`,
  `GET /api/my-locations`, and the admin equivalents.

**Acceptance.** A booking place created with coordinates comes back with the same coordinates, and
appears in the home map's "Cazare" list when the picked area contains it.

---

## B4 — Never surface a duplicate key as a generic 500

**Why.** "Devino producător" failed on two test devices while working on the developer's own phone.
The client-side half is fixed (the phone number was optional in the form but is required by
`POST /api/partner/apply`, and every backend error is now shown verbatim instead of being swallowed).
The remaining risk is on the server: if a unique-index violation escapes as a bare 500, the user now
sees whatever generic `message` accompanies it, which still tells them nothing.

**Requested.**

- Catch Mongo `E11000` on `POST /api/partner/apply` and `POST /api/shop/producer` and return
  **409 `CONFLICT`** with a message naming the field that collided — e.g. *"Există deja o cerere de
  producător pentru acest cont."* or *"Acest număr de telefon este deja folosit."*
- A second application from a user who already has one should be **409**, not 500.
- Return **422 `VALIDATION_ERROR`** with `details[].field` for every field-level rejection, so the app
  can attach the message to the offending input rather than showing it as a banner.
- Please confirm the `phone` rule on `/api/partner/apply`: required or optional, and the accepted
  format. The client now normalises to `07xxxxxxxx` / `+407xxxxxxxx` (spaces, dots, dashes and
  parentheses stripped) before sending — worth checking that passes your validator.

**Acceptance.** Submitting the same partner application twice returns
`409 { "error": { "code": "CONFLICT", "message": "<Romanian, user-facing>" } }`, and the app shows
that sentence.

---

## B5 — Participant identity on chat threads

**Why.** `GET /api/chat/threads` returns `participants` as an array of bare user IDs. The app has no
way to turn an ID into a name or an avatar without an extra request per row, so **every conversation
in the list is labelled "Producător" with a placeholder avatar**. The thread list is otherwise
working — the false "Nicio conversație" empty state was a client-side caching bug and is fixed.

**Requested.** Either shape works:

```jsonc
// A — expand participants
{
  "id": "...",
  "participants": [
    { "id": "u_1", "name": "Ferma Ionescu", "avatarUrl": "https://..." },
    { "id": "u_2", "name": "Andrei P.", "avatarUrl": null }
  ],
  "lastMessagePreview": "...", "lastMessageAt": "...", "unreadCount": 2
}

// B — add the resolved counterpart, leave `participants` as IDs
{
  "id": "...",
  "participants": ["u_1", "u_2"],
  "otherParticipant": { "id": "u_1", "name": "Ferma Ionescu", "avatarUrl": "https://..." },
  "...": "unchanged"
}
```

**B is cheaper for us** — it is additive, and it also settles a second problem: with plain IDs the
client cannot tell which participant is the *other* party, so it currently assumes `participants[1]`,
which is wrong whenever the caller is the producer.

**Acceptance.** The chat list shows each counterpart's real name and avatar, from one request.

---

## B6 — Confirm `PUT /users/me` persists `phone`

**Verification only, no change expected.** The profile phone number appeared not to save. The cause
was client-side (the screen renders the auth session's user, but saving only invalidated a different
provider) and is fixed. Before closing it out, please confirm on your side:

- `phone` is in the `$set` whitelist for `PUT /api/users/me` and is not silently dropped.
- `GET /api/users/me` returns the stored value afterwards.
- No validator rejects `+407xxxxxxxx` — the client may now send either that or `07xxxxxxxx`.

---

## B7 — Contact details on BookingPlace

**Why.** The booking-place detail screen has a Contact section (phone / e-mail / website) and the
owner form collects all three, but `BookingPlaceModel` neither accepts nor returns them, so the
section always renders "—" and a guest has no way to reach the host.

**Client side is done.** `POST /api/my-locations` and `PUT /api/my-locations/:id` now send flat
`phone`, `email` and `website` alongside the existing body, and the mapper
(`lib/features/booking/data/models/booking_place_dto.dart`) reads them back from either a nested
`contact` object or the same flat keys — whichever the server chooses is fine.

**Endpoints.**

- Accept and persist `phone`, `email`, `website` on `POST /api/my-locations` and
  `PUT /api/my-locations/:id`. On PUT, `''` means "clear this field" and an absent key means
  "leave it alone".
- Return them on `GET /api/booking-places`, `GET /api/booking-places/:id`, `GET /api/my-locations`
  and the admin equivalents.

**Acceptance.** A place saved with a phone number shows a tappable "Sună" row on its detail page
after a reload, and clearing the field in the owner form removes the row.

---

## B8 — Password reset

**Why.** The login screen has an "Ai uitat parola?" link and there is no endpoint behind it. The
only password route today is `PUT /api/users/me/password`, which needs an authenticated session —
useless to someone who cannot log in.

**Client side is done.** Tapping the link opens a dialog, pre-filled with whatever was typed in the
e-mail field, and posts to `POST /api/auth/forgot-password` with `{email}`. Success shows a neutral
confirmation; an `AppException` message is shown as-is. Until this endpoint exists the request
fails with the API's not-found error.

**Endpoints.**

- `POST /api/auth/forgot-password` — public, body `{email}`, response `204`. Mails a single-use,
  time-limited reset link. **Always return 204**, including for an unknown address: a different
  response would let anyone enumerate registered e-mails.
- `POST /api/auth/reset-password` — public, body `{token, password}` (password ≥ 8, same rule as
  register), response `204`. Invalidates the token and every refresh token for that user.

**Also needed:** the URL the mail links to. A plain web page is fine for now; if it should open the
app instead, tell us the link format and we will add the deep-link route — the client has no
reset-token screen yet.

**Acceptance.** Requesting a reset for a registered address delivers an e-mail; the link sets a new
password; the old password no longer works and existing sessions are logged out.

---

## B9 — `servedLocalities` on Producer

**Why.** A shop now picks its locality from the INS SIRUTA dataset
(`assets/data/romania_localities.json`, 42 counties / ~17k places), which carries a stable id and a
SIRUTA code — things the free-text `locality`/`county` strings lose. The API has no field for it, so
it is sent and dropped: the owner re-picks on every edit, and buyers cannot be matched to shops by
anything sturdier than a string compare.

**The field is an array holding one entry today.** The form is single-select, but "the localities a
shop serves" is plural in the product brief, so the shape is already the plural one — widening it
later costs no contract change. Treat a one-element array as the normal case and preserve order.

**Client side is done.** `POST /api/partner/apply`, `POST /api/shop/producer` and
`PUT /api/shop/producer` all send:

```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ș" }
]
```

`id` is `<countyCode>:<parentSiruta>:<name>` and is stable — store it as the key. `kind` is one of
`city` / `commune` / `village`; `siruta` is present for cities and communes only (villages have no
code of their own in SIRUTA); `parentName` is present for villages.

**Endpoints.** Accept and persist the array on the three write endpoints above, and return it on
`GET /api/producers`, `GET /api/producers/:id`, `GET /api/shop/producer` and the admin equivalents.
The first entry is the shop's own locality — it is what `locality`/`county` are derived from
client-side, so the order must be preserved.

**Worth having later:** filtering `GET /api/products` / `GET /api/producers` by a locality id, so a
buyer sees only shops that deliver to them. Not needed for this phase.

**Booking places are deliberately not part of this.** `/my-locations` keeps taking plain
`locality`/`county` strings, which it already stores (see B3 for their coordinates). Only the
producer gained a structured locality.

**Acceptance.** A shop saved with a locality comes back with the same entry (id and SIRUTA intact),
and editing the shop opens with it already filled in.

---

## Not backend work

Listed so nobody picks them up by mistake:

- **Realtime chat.** `messageStream` is an empty stream and the app is REST-only; wiring the STOMP
  `/ws` endpoint is client work, not an API change.
- **Push notifications.** `POST /api/devices` exists; the client is still on a stub push datasource
  pending Firebase configuration.
- **Social login.** `/api/auth/social` exists; Google/Apple/Facebook are commented out of
  `pubspec.yaml` and throw "not configured" on the client.
