# Server issues found while aligning the app — vt-api

**From:** the Flutter app, after aligning every endpoint with `FLUTTER_ALIGNMENT.md` (branch `fix-plan`).
**Date:** 2026-10-24

The app now follows `FLUTTER_ALIGNMENT.md` everywhere: refresh is single-flight, chat uses
`otherParticipant` / `participantType` / `unread-count` / `clientMessageId`, the STOMP socket is wired,
the area filter is server-side, owner screens use `/shop/*` and `/my-locations` and show moderation
feedback, and form validation mirrors §8.

Below is what is left. Each item needs either a server change or a decision. They are ordered by impact.

| # | Item | Type | Size |
|---|---|---|---|
| S1 | Personalised public lists break pagination | Change | S |
| S2 | Socket eviction (4008) can't be detected by the client | Change | XS |
| S3 | Producers awaiting verification can't be listed by admins | Change | XS |
| S4 | Product moderation section names are undocumented | Clarify | XS |
| S5 | Moderation actions the app offers but the API lacks | Decide | M |
| S6 | Fields the app collects but the API drops | Decide | S–M |
| S7 | Product responses carry no producer name | Optional | S |

---

## S1 — Personalised public lists break pagination

**Problem.** `GET /products`, `/producers` and `/booking-places` use optional auth. With a token, a
business user also gets their own non-approved items and an admin gets **everything** (§3). The app
shows only `approved` items in the public catalogue, so it now filters them out client-side.

That filtering breaks the page:

- `total` and the page size count items the app then throws away. An admin can get a page of 20 items
  with 3 visible ones.
- `page * size < total` ("more pages exist") becomes wrong, so infinite scroll stops early or loads empty
  pages.
- The catalogue looks different depending on who is signed in, which is surprising for a public list.

The alternative from §3, calling these endpoints without a token, isn't practical: the HTTP client attaches
the token to every request, and the same screens need it for favourites and the cart.

**Requested — either option works:**

- **A (preferred):** add `scope=public` (or `publicOnly=true`) to the three listings. When set, return only
  `approved` items regardless of the token, with `total` counting only those.
- **B:** stop personalising the public listings. Owners already have `/shop/products` and `/my-locations`,
  and admins have `/admin/*`.

**Acceptance.** An admin calling `GET /products?scope=public&size=20` gets 20 approved products and a
`total` that counts only approved ones.

---

## S2 — Socket eviction (4008) can't be detected by the client

**Problem.** §7.1: a 6th socket closes the oldest with WebSocket close code **4008** and no ERROR frame,
and the client must not reconnect after 4008. The STOMP library the app uses (`stomp_dart_client`) does
not expose the close code. Every disconnect looks the same, so the app can't tell "evicted" from "network
dropped".

Today the app reconnects with exponential backoff (2 s → 60 s). With more than 5 devices, the evicted
device eventually reconnects and evicts another one.

**Requested.** Send an ERROR frame right before closing an evicted socket, e.g.:

```
ERROR
message:SESSION_LIMIT
content-type:application/json

{"code":"SESSION_LIMIT","message":"Conversațiile sunt deschise pe prea multe dispozitive."}
```

Keep the 4008 close code as it is. The app will stop reconnecting when it sees `SESSION_LIMIT`.

**Acceptance.** A 6th socket for the same user makes the oldest one receive `ERROR message:SESSION_LIMIT`
before it is closed with 4008.

---

## S3 — Producers awaiting verification can't be listed by admins

**Problem.** The admin console has a "producers to verify" list ("Producător Verificat" badge). A
producer created by approving a partner application is `status: approved, verified: false`.
`GET /admin/producers` filters by `status` only (§5.17). The app asks for `status=pending`, so these
producers never show up there and can't be verified from the list.

**Requested.** Add a `verified` filter to `GET /admin/producers` (`verified=false`), combinable with
`status`. The public `GET /producers` already takes `verified`.

**Acceptance.** `GET /admin/producers?status=approved&verified=false` returns every approved,
unverified producer, paginated.

---

## S4 — Product moderation section names are undocumented

**Problem.** `PATCH /admin/products/:id/sections/:section` and the product `sections` map don't list the
allowed `<section>` values. §4.3's example uses `photos`. The app sends **`details`** and **`images`**.
When reading, it accepts both `images` and `photos`.

**Requested.** Document the allowed section names and confirm whether `details` and `images` are
accepted. If they aren't, tell us the names and the app will switch.

---

## S5 — Moderation actions the app offers but the API lacks

The admin screens offer these actions. They have no endpoint, so the app currently accepts them as
**no-ops**: the admin clicks, and nothing is stored.

| Action in the app | Missing endpoint |
|---|---|
| Request info on a partner application | `PATCH /admin/applications/:id/request-info { note }` |
| Per-section review of a producer (details / contact / images) | `PATCH /admin/producers/:id/sections/:section` |
| Per-section review of a booking place (details / contact / images) | `PATCH /admin/booking-places/:id/sections/:section` |
| Per-section review of a partner application | `PATCH /admin/applications/:id/sections/:section` |

**Decision needed, per row:** add the endpoint (same shape as the product one:
`{ action: approve|requestChanges, note? }`, with the result in a `sections` map on owner/admin responses),
or tell us to remove the action from the app. Silent no-ops are the worst option, because the admin thinks
the feedback was sent.

---

## S6 — Fields the app collects but the API drops

These form fields are filled in by users and then lost, because the API has no field for them (unknown
keys are silently ignored, §1.5).

| Form | Field | Today |
|---|---|---|
| "Devino producător" (`POST /partner/apply`) | photos (up to 10, already uploaded via `/uploads`) | dropped — the application carries no photos, so the admin reviews it without them |
| "Devino producător" | contact e-mail | dropped — `PartnerApplication` has no `email` |
| Shop editor (`PUT /shop/producer`) | contact e-mail | dropped — `Producer` has no `email` |
| Product form (`POST/PUT /shop/products`) | volume / weight (e.g. "500 g", "1 L") | dropped — only `unit` exists |
| Booking place form (`POST/PUT /my-locations`) | place type (cabin / room / apartment / guesthouse / campsite) | dropped — every place comes back as the app's default ("cabană") |

**Decision needed, per row:** add the field (proposed names: `photos` on PartnerApplication, `email` on
PartnerApplication and Producer, `volume` string ≤ 50 on Product, `type` enum on BookingPlace), or tell us
to remove it from the form.

On approval, application `photos` should be copied to the created Producer, just as the other application
fields are.

---

## S7 — Product responses carry no producer name (optional)

**Problem.** §4.3: no producer object is embedded, so the app fetches `GET /producers/:id` for each
distinct producer on a product list to show the shop name on product cards. A home page with 20 products
from 12 shops costs 12 extra requests, and those count against the rate limit (§1.7).

**Requested (optional).** Add a small embedded summary to Product on list and detail responses:

```jsonc
"producer": { "id": "…", "businessName": "Ferma Ionescu", "verified": true, "locality": "Bran", "county": "Brașov" }
```

This is additive; the app would keep `producerId` as is.

---

## Report back

Please answer per item: done (with the exact shape shipped), declined (with the reason), or later.
For **S1** say which option you picked. For **S5** and **S6**, give a decision for each row.

---

## Server response — 2026-10-24

All seven items are **done**. `FLUTTER_ALIGNMENT.md` is updated to match (§3, §4, §5.4, §5.13–§5.17,
§7.1, §7.4, §8, §9, §11). Summary of what shipped:

| # | Status | Shipped |
|---|---|---|
| S1 | ✅ done — **option B** | `GET /products`, `/producers`, `/booking-places` return only `approved` items for everyone (anonymous, owner, admin). `total` counts only those. Detail endpoints still let the owner and admins open a non-approved item (preview). Drop the client-side status filter. |
| S2 | ✅ done | The evicted socket gets `ERROR` · `message:SESSION_LIMIT` · `content-type:application/json` · body `{"code":"SESSION_LIMIT","message":"Conversațiile sunt deschise pe prea multe dispozitive."}`, then close code 4008 (unchanged). |
| S3 | ✅ done | `GET /admin/producers` accepts `verified=true\|false`, combinable with `status`: `?status=approved&verified=false`. |
| S4 | ✅ done | Section names are now validated per kind (422 on `params.section` otherwise): Product `details`, `images` · Producer / BookingPlace / PartnerApplication `details`, `contact`, `images`. **`details` and `images` are correct; `photos` is no longer accepted** — read `images` only. |
| S5 | ✅ done — all four rows | `PATCH /admin/applications/:id/request-info { note? }` (sets `moderationNote`, stays `pending`); `PATCH /admin/producers/:id/sections/:section`, `PATCH /admin/booking-places/:id/sections/:section`, `PATCH /admin/applications/:id/sections/:section`, each `{ action: approve\|requestChanges, note? }`, same as products. Application actions require `pending` (else 400 `Cererea a fost deja procesată.`). |
| S6 | ✅ done — all five rows | PartnerApplication `photos: string[]` (≤ 20 URLs) and `email?`; Producer `email?`; Product `volume?` (string ≤ 50); BookingPlace `type: cabin\|room\|apartment\|guesthouse\|campsite \| null`. On approval the application's `photos` and `email` are copied to the Producer. |
| S7 | ✅ done | Every product response (public list/detail, cart, `/shop/products`, `/admin/products`) carries `producer: { id, businessName, verified, locality, county } \| null`, loaded in one query per page. `producerId` is unchanged. |

### Exact shapes

**Moderation fields** (owner/admin endpoints for Product/Producer/BookingPlace; always on PartnerApplication):

```jsonc
"moderationNote": "Trimite o poză cu ferma.",            // absent when unset
"sections": {                                            // always an object, possibly {}
  "images":  { "status": "requestChanges", "note": "Lipsesc pozele." },
  "details": { "status": "approved" }                    // note absent when none
}
```

Approving any listing/application clears `rejectionReason`, `moderationNote` and `sections`. A section
decision sent without `note` clears that section's previous note.

**New fields**

```jsonc
// PartnerApplication (POST /partner/apply body and responses)
"email": "contact@ferma.ro",          // optional; lowercased; absent when unset
"photos": ["https://…/uploads/a.webp"] // optional on write (≤ 20 URLs); always an array in responses

// Producer (POST/PUT /shop/producer body and every Producer response)
"email": "shop@ferma.ro"              // optional; "" clears on PUT; absent when unset

// Product (POST/PUT /shop/products body and every Product response)
"volume": "500 g",                    // optional, ≤ 50; "" or null clears on PUT; absent when unset
"producer": { "id": "…", "businessName": "Ferma Ionescu", "verified": true, "locality": "Bran", "county": "Brașov" }

// BookingPlace (POST/PUT /my-locations body and every BookingPlace response)
"type": "cabin"                       // cabin | room | apartment | guesthouse | campsite; null when unset; null clears on PUT
```

Validation messages: invalid e-mail → `Adresa de email nu este validă.`; `volume` too long →
`Câmpul pentru cantitatea din ambalaj poate avea cel mult 50 caractere.`; bad `type` →
`Valoarea pentru tipul cazării trebuie să fie una dintre: cabin, room, apartment, guesthouse, campsite.`
