# 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 |

---

## Backend status — all six shipped

| # | Status | Notes |
|---|---|---|
| B1 | ✅ | `locality`, `county`, `latitude`, `longitude` on Producer and PartnerApplication, on write and on every read. `address` unchanged. |
| B2 | ✅ | **Radius** — `lat`, `lng`, `radiusKm` (county/locality also accepted). |
| B3 | ✅ | Coordinates round-trip; booking places are now findable by area. |
| B4 | ✅ | `E11000` → 409 `CONFLICT`; second application → 409; field errors → 422 with `details[]`. |
| B5 | ✅ | Shape **B** — `otherParticipant`, `participants` untouched. |
| B6 | ✅ | Confirmed, plus phone normalisation. |

### The two questions you asked

**Which filter shape?** **Radius.** `lat` + `lng` + `radiusKm` on all three listings, backed by a
`2dsphere` index. `radiusKm` is optional and defaults to **25 km** (max 1000); `county` and
`locality` are accepted too (case-insensitive exact match) if you ever want them, and can be combined
with a circle. `radiusKm` without a centre — or one coordinate without the other — is a 422 on
`query.radiusKm` / `query.lat` / `query.lng`. An area that contains nothing returns
`items: [], total: 0`, not an error.

**The `phone` rule on `/api/partner/apply`:** **required**, and your normalisation passes. The server
now strips spaces, dots, dashes and parentheses itself, then requires `^\+?[0-9]{6,15}$` — so
`0712345678`, `+40712345678` and `0712 345.678` are all accepted and stored normalised. Anything else
is a 422 on `body.phone`. On `/api/shop/producer` and `PUT /users/me` the same rule applies but the
field stays optional; sending `""` there clears the stored number.

### Things worth knowing before you wire the client up

- **`locality` / `county` are required on create** (`/api/partner/apply`, `POST /api/shop/producer`),
  with one transitional exception: if you send neither but do send an `address` containing a comma,
  the server splits it the way `producerFromApi` used to and logs a warning. That keeps the currently
  shipped app working, so the deploy is not order-sensitive. It goes away once your update ships —
  tell us when, and we drop it.
- **`address` is now optional on create** and composed as `"{locality}, {county}"` when omitted. If
  you do send it, it is stored verbatim, and updates never rewrite it — so a detailed street address
  survives a locality edit.
- **`locality`, `county`, `latitude`, `longitude` are always present as keys**, `null` when unknown,
  on `ProducerModel`, `PartnerApplicationModel` and `BookingPlaceModel`. Existing rows created before
  this pass read back `null` until their owner sets a location.
- **`0, 0` is rejected** on write, on both entities, with a message telling the user to pick a point.
  Coordinates must be sent as a pair.
- **Error messages are Romanian and user-facing across the whole API** now, not just on the endpoints
  in this list — `error.code` and `details[].field` are unchanged.
- **Approving a partner application copies the location** onto the Producer it creates.

---

## 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 for now (the app has no
  map picker on this flow yet — 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.** Once this lands, `producerFromApi` in
`lib/core/network/dto/core_dtos.dart` drops its comma-splitting workaround and reads the real
fields. A map picker in "Devino producător" and the shop editor is queued behind this — it was
deliberately not built, because there is currently nowhere to send the pin.

---

## 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 filter runs client-side on mock distances and is marked `[BACKEND]` in
`lib/features/home/presentation/widgets/location_tab_body.dart`
(`_filterProductsWithin`). When the query parameters exist, that method is deleted and the selected
area is passed straight into `productsProvider`.

---

## 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`.

---

## 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.
