# GrowKit app → backend contract

**Canonical contract for the Flutter app.** Backend behavior is implemented to match this document.

**Base URL (production):** `https://farm-backend-kiei.onrender.com`  
**Public HTML pages (no auth):** `/` (home) · `/privacy-policy` · `/terms-of-service` · `/about-growkit`  
**Routes:** flat — no `/api/v1` prefix  
**Content-Type:** `application/json`  
**Auth:** after OTP verify, every protected call sends:

```
Authorization: Bearer <jwt>
```

JWT lifetime: 7 days. Optional response header `X-Access-Token` on `POST /verify-otp` and `POST /users/profile` — app stores that token too.

**Error shape (all 4xx/5xx)**

```json
{ "success": false, "message": "Human-readable message" }
```

App shows `message` to the user. `401` → treat as logged out / unauthorized.

Keys may be **camelCase or snake_case**. App accepts both.

---

## Backend behavior notes (implementation)

| Topic | Behavior |
|--------|----------|
| **Listing `priceInr`** | Omitted unless viewer is **Premium** or **listing owner**. App should treat missing key as locked price. |
| **Listing `boost`** | `true` on `GET /mall/listings` (and other listing JSON) when this signed-in viewer already `POST /mall/engagements/boost`’d that listing. Tap Boost still POSTs `/boost` / `/boost/remove`. Distinct from seller spotlight `boostExpiresAt`. |
| **`disableScreenshot`** | Always on User JSON. **`true` for internal users** (3-series / ActiveAdmin `internal`), `false` for everyone else. App blocks screenshots when `true`. |
| **`PATCH /users/me`** | Returns **User object at top level** (not `{ "user": ... }`). |
| **`POST /users/profile`** | Returns `{ "user": { ... } }`. |
| **`POST /photos`** | Multipart field `file` or `image`, or JSON `{ "url": "https://..." }`. Response `{ "id", "url" }`. Use `photoIds` on listing create. |
| **OTP / TOTP** | Real numbers: random OTP (SMS or `OTP_INCLUDE_IN_RESPONSE`). **3-series** internal: **Google Authenticator** via `totpSetup` on `/request-otp` — no fixed `123456`. |
| **`GET /get-initial-data`** | Home boot (replaces `GET /feed` on open). `{ premium, disableScreenshot, unreadNotificationCount, feed: { items }, mallForYou }`. Shimmer until it returns. |
| **`GET /feed`** | Pending in-app offers only. Welcome gift is `free_subscription` / `mall_premium` / 3 months / CTA `Grab`. Hidden after claim or while the user is already premium. Claim: `POST /feed/free_subscription/claim`. |
| **`GET /doctors`** | All active rows from the `doctors` table + helpline. Query `latitude`/`longitude` (or saved location) only to set `distanceKm`. Empty `items` when the table is empty (not 404, not a mock list). Visit request: `POST /doctors/:id/consultations` `{ "type": "visit" }` → `{ "status": "requested" }`. |
| **`POST /devices`** | Register FCM token `{ "token", "platform": "android"|"ios" }`. Unregister: `POST /devices/unregister` `{ "token" }`. Inbox writes send FCM to those tokens. Call/share/boost are digested by cron, not instant. |
| **`POST /internal/alerts/*`** | Cron only. Header `X-Cron-Secret`. App never calls these. Tap routing uses FCM `data.screen`. |

---

## Shared objects the app parses

### User

```json
{
  "id": 1,
  "phone": 9123456789,
  "name": "Ravi",
  "role": "farmer",
  "signedUp": true,
  "internal": false,
  "village": "Nandyal",
  "language": "te",
  "premium": false,
  "disableScreenshot": false,
  "location": {
    "id": "1",
    "village": "Nandyal",
    "latitude": 15.47,
    "longitude": 78.48,
    "active": true
  },
  "photo": { "id": 1, "url": "https://..." }
}
```

| Field | App expects |
|-------|-------------|
| `id` | int or string → stored as int |
| `phone` | int or `"9123456789"` |
| `signedUp` | `false` → show name screen; `true` → Home |
| `role` | `farmer` (also accepts `agent`, `merchant`) |
| `language` | `en` \| `hi` \| `te` \| `ta` |
| `premium` | bool — unlocks Mall prices / call / negotiate |
| `disableScreenshot` | bool — `true` for **internal** users (same as `internal`); app blocks screenshots |
| omitted keys | treated as null |

### Location

```json
{
  "id": "1",
  "village": "Nandyal",
  "latitude": 15.47,
  "longitude": 78.48,
  "active": true
}
```

App stores `id` and sends it back as `locationId` when creating a listing.

### `GET /mall/listings` feed

Same shape as Flutter `MallFeedPage`:

```json
{
  "listings": [ /* Listing — up to per_page (default 3) */ ],
  "live_members_count": 186,
  "has_more": true,
  "per_page": 3
}
```

**Pagination (infinite scroll):** send ids already on screen so the next chunk skips them.

| Query | Description |
|-------|-------------|
| `loaded_listings` / `loadedListings` | Comma-separated ids, JSON array string, or repeated `loaded_listings[]` — **excluded** from results |
| `per_page` / `perPage` / `limit` | Optional; default **3** (max 50). Env `MALL_FEED_PER_PAGE`. |

First load: omit `loaded_listings` → first 3 listings. Each “load more”: pass **all** loaded ids → next 3 not in that set. `has_more: false` when no further rows.

With Bearer token, each listing includes **`boost: true`** if this user already boosted it, else **`boost: false`**. Mall open only needs this feed; `GET /mall/engagements/boost/mine` is optional.

**`live_members_count`:** random integer **1–10_000** on each request.

Other listing routes still return a single Listing or an array of listings.

### Listing

```json
{
  "id": "1",
  "title": "Murrah buffalo",
  "priceInr": 85000,
  "category": "animals",
  "village": "Nandyal",
  "locationId": "1",
  "seller": {
    "id": 1,
    "name": "Ravi",
    "phone": 9123456789,
    "photo": { "id": 10, "url": "https://.../photos/10/file", "uploaded": true }
  },
  "photos": [
    { "id": 42, "url": "https://.../photos/42/file", "uploaded": true }
  ],
  "icon": 59677,
  "description": "Healthy, 2nd lactation",
  "createdAt": "2026-09-13T09:00:00Z",
  "boostExpiresAt": null,
  "listingExpiresAt": "2026-09-20T09:00:00Z",
  "interestCount": 0,
  "status": "active",
  "boost": false,
  "imageUrl": "https://...",
  "imageUrls": ["https://..."],
  "animalType": "buffalo",
  "breed": "Murrah",
  "lactation": "second",
  "milkLitersPerDay": 12.0,
  "vetVerificationRequested": false,
  "vetVerified": false
}
```

`priceInr` **may be omitted** when the caller is not Premium and not the seller.

`boost` is **`true`** when this signed-in viewer already boosted the listing (reel boost). Unsigned requests send `boost: false`.

| Field | Allowed values |
|-------|----------------|
| `category` | `animals`, `used_machines`, `produce`, `other` |
| `animalType` | `cow`, `buffalo`, `ox`, `padi`, `calf`, `goat`, `sheep` |
| `lactation` | `not_calved`, `first`, `second`, `other` |
| `status` | `active`, `sold` |
| `seller` | `{ id, name, phone, photo? }` — **`seller.id` is the seller user id** (no separate `sellerUserId`) |
| `photos` | Listing images for the gallery — `{ id, url, uploaded }[]`; same URLs as `imageUrls` |
| `icon` | Material icon codepoint by category (`animals` → `0xe91d`, etc.) |

### Interest · Offer · Subscription

Same shapes as in the previous `docs/api.md` — see [api.md](api.md) for full field lists.

---

## Photo upload (app — not seed URLs)

Use **multipart upload** from the phone. Bytes are stored in **Aiven MySQL** (`photos.file_data`), so uploads survive Render redeploys.

### 1. `POST /photos`

**Auth:** Bearer required.

**Multipart** (preferred for camera/gallery):

| Field | Value |
|-------|--------|
| `file` or `image` | JPEG/PNG/WebP, max 10 MB |

**201**

```json
{
  "id": 42,
  "url": "https://farm-backend-kiei.onrender.com/photos/42/file",
  "uploaded": true
}
```

Use **`url`** in listing cards; **`id`** when creating a listing.

**Production:** set Render env **`APP_HOST=https://your-service.onrender.com`** so `url` is correct.

Do **not** use JSON `{ "url": "https://..." }` from the app unless you host files elsewhere — that path is for admin/seed only.

### 2. `POST /mall/listings`

```json
{
  "title": "...",
  "priceInr": 85000,
  "category": "animals",
  "locationId": "1",
  "description": "...",
  "photoIds": ["42", "43"]
}
```

### 3. Profile photo (optional)

`PATCH /users/me` with `"photo_url": "<url from step 1>"`.

### Flutter (Dio example)

```dart
final form = FormData.fromMap({
  'file': await MultipartFile.fromFile(path, filename: 'photo.jpg'),
});
await dio.post('$baseUrl/photos', data: form,
  options: Options(headers: {'Authorization': 'Bearer $token'}));
```

---

## 10a. My Mall & earnings

**Auth:** Bearer required. Path **`user_id`** = logged-in user’s numeric **`users.id`** (not phone).

### `GET /mall/my-mall/:user_id`

Flutter: `MallRepository.fetchMyMall(userId)`.

```json
{
  "active": [ /* Listing[] — status "active" */ ],
  "sold": [ /* Listing[] — status "sold" + sale */ ],
  "reported": [ /* Listing[] — status "reported" + reportCount */ ],
  "offers": {
    "onMyListings": [ /* MallOffer[] — buyers on your listings */ ],
    "sent": [ /* MallOffer[] — you sent on others’ listings */ ]
  }
}
```

**Tabs:** use buckets directly (no client-side status filtering). A listing that is **sold and reported** appears in **`sold[]` and `reported[]`**. Same for **active + reported** → **`active[]` and `reported[]`**.

**`sold[]` — `sale` object** (from **`PATCH .../sold`** body when saved, else accepted offer, else list price):

| Field | Type |
|-------|------|
| `salePriceInr` | int — final amount (Sold tab headline) |
| `buyerName` | string? |
| `buyerPhone` | string? |
| `buyerUserId` | string? |
| `soldAt` | ISO8601 |

`priceInr` on the listing is the original list price (strikethrough when ≠ `salePriceInr`).

**`reported[]`:** `reportCount` (int), `status: "reported"`.

Legacy `{ listings, offers, offersSent }` may still be parsed by older app builds; **buckets are preferred**.

Snake_case OK (`on_my_listings`, `report_count`, `sale_price_inr`, …).

**Offer (negotiation) object** — same as [api.md Offer object](api.md#offer-object):

| Field | Type |
|-------|------|
| `id` | string |
| `listingId` | string |
| `listingTitle` | string |
| `buyerName` | string |
| `buyerPhone` | string |
| `village` | string |
| `listingPriceInr` | int |
| `offerPriceInr` | int |
| `status` | `pending` \| `accepted` \| `rejected` |
| `createdAt` | ISO8601 |

### `GET /mall/earnings/:user_id`

```json
{
  "activeListingCount": 2,
  "soldCount": 5,
  "soldTotalInr": 420000,
  "purchaseCount": 1,
  "purchaseTotalInr": 12000,
  "offersSentCount": 3,
  "offersSentTotalInr": 95000,
  "interestCount": 8
}
```

**403** if `user_id` ≠ token user.

---

## 10b. In-app notifications inbox

**Auth:** Bearer required. Path **`user_id`** = logged-in user’s numeric **`users.id`** (same rule as My Mall). **403** if path id ≠ JWT user.

Separate from **`mall_engagements`** (reel share/call/boost). Inbox rows live in **`notifications`** table.

### Flow

1. Home open / pull-to-refresh → **`GET /notifications/:user_id/count`** (badge only).
2. Bell tap → **`GET /notifications/:user_id`** (list + count).
3. After list loads OK → **`POST /notifications/:user_id/read`** (mark all read; badge → 0).

### Notification object (single shape for all types)

Always: `id`, `type`, `unread`, `createdAt`.  
Optional (omit key when unused): `user`, `listing`, `count`, `offerId`, `offerPriceInr`.

**`type`:** `boost` · `call` · `share` · `report` · `negotiate` · `listing_views` · `profile_view` · `weather_rain` · `mall_digest` · `mall_reminder` · `listing_quiet` · `mall_nudge` · `crop_prices`

| type | Fields |
|------|--------|
| boost, call, share, report | `user` + `listing` |
| negotiate | `user` + `listing` + `offerId` + `offerPriceInr` |
| listing_views, mall_digest | `listing?` + `count` |
| profile_view | `user` only (no `listing`) |
| weather_rain, mall_nudge, mall_reminder, crop_prices | none extra |
| listing_quiet | `listing` |

**`user`:** `{ id, name, village?, photo? }` — person who acted (not “actor”).  
**`listing`:** `{ id, title }`.

Snake_case OK (`unread_count`, `offer_price_inr`, …).

### `GET /notifications/:user_id/count`

```json
{ "unreadCount": 4 }
```

### `GET /notifications/:user_id`

Newest first. No pagination in this pass.

```json
{
  "unreadCount": 4,
  "notifications": [ /* Notification[] */ ]
}
```

### `POST /notifications/:user_id/read`

Marks **all** notifications read for that user.

```json
{ "unreadCount": 0 }
```

**404** unknown user · **403** wrong user id

---

## Session APIs (summary)

| # | Method | Path | Auth |
|---|--------|------|------|
| 1 | GET | `/health` | no |
| 1b | GET | `/privacy-policy`, `/terms-of-service`, `/about-growkit` | no — HTML for Play Store / in-app links |
| 2 | POST | `/request-otp` | no |
| 3 | POST | `/verify-otp` | no |
| 4 | POST | `/users/profile` | Bearer |
| 5 | GET | `/users/me` | Bearer (optional refresh) |
| 6 | PATCH | `/users/me` | Bearer → **User root JSON** |
| 7 | POST | `/user-locations` | Bearer |
| 8 | GET | `/mall/listings` | optional; Bearer adds `boost` on listings this user already boosted |
| 9 | GET | `/mall/listings/:id` | optional |
| 10 | GET | `/mall/my-mall/:user_id` | Bearer (own id) |
| 10b | GET | `/notifications/:user_id/count`, `GET /notifications/:user_id`, `POST …/read` | Bearer (own id) |
| 10e | POST | `/devices`, `/devices/unregister` | Bearer — FCM token register / remove |
| 10f | POST | `/internal/alerts/weather`, `/mall`, `/crop-prices` | `X-Cron-Secret` — GitHub cron, not the app |
| 10c | GET | `/mall/earnings/:user_id` | Bearer (own id) |
| 10d | GET | `/mall/listings/user/:user_id` | optional |
| 11 | POST | `/mall/listings` | Bearer |
| 12 | PATCH | `/mall/listings/:id/sold` | Bearer owner |
| 13 | POST | `/mall/listings/:id/report` | Bearer |
| 14 | POST | `/users/blocks` | Bearer |
| 15 | GET | `/mall/spotlight/remaining` | Bearer |
| 16 | POST | `/mall/spotlight/use` | Bearer owner |
| 17 | POST | `/mall/interests` | Bearer |
| 18–19 | GET | `/mall/interests/buyer`, `/seller` | Bearer |
| 20–22 | POST/PATCH/GET | `/mall/offers`, `/:id/respond`, buyer/seller lists | Bearer (+ Premium for create); respond = seller only |
| 22–26 | POST/GET | `/mall/engagements/share`, `/call`, `/boost`, `/boost/remove`, `GET …/boost/mine` | Bearer (+ Premium for call); not own listing |
| 22b | GET | `/mall/favorites` | Bearer — latest 5 listings this buyer called or shared `{ "favorites": [ Listing ] }` |
| 24 | GET | `/ssp-paywall` | no (`price_inr` + `priceInr` + `plans`) |
| 24b | GET | `/plans` | no — monthly / quarterly / yearly catalog |
| 24c | GET/POST | `/get-initial-data`, `/feed`, `/feed/:id/claim` | Bearer — home boot + welcome gift (`free_subscription`, 3 months) |
| 24d | GET/POST | `/doctors`, `/doctors/:id/consultations` | Bearer — Farm Doctor list + field visit |
| 25–26 | GET/POST | `/subscriptions/me`, `/subscriptions` | Bearer (`plan` = monthly/quarterly/yearly) |
| 27 | GET | `/dashboard` | optional (not wired in app yet) |
| — | POST | `/photos` | Bearer |

Detailed request/response examples: **[api.md](api.md)**.

---

## External hosts (not this API)

| Host | Purpose |
|------|---------|
| `https://api.open-meteo.com` | Home weather |
| `https://api.data.gov.in` | Mandi prices |

---

## Demo on production

| Phone | OTP |
|-------|-----|
| Any `3xxxxxxxxx` (10 digits) | Scan `totpSetup`, then 6-digit **authenticator** code |
| Seed examples | `3123456789`, `3876543210` |

Requires **`REDIS_URL`** on Render for OTP Redis keys.
