# Flutter ↔ Backend API Contract Map Written 2026-07-10 from a full read of both sides of the wire (every Flutter call site in `lib/` and every backend route it touches). This is the context document for `plans/next-enhancements.md` sections 6-7 (Flutter) and `backend/plans/next-enhancements.md` section 9 (backend counterparts) — read it before picking up any of those tasks. Gap IDs (**G1**-**G10**) below are referenced from the task entries so a future session can trace a task back to the evidence. ## Endpoint inventory (what the app actually calls) | # | Flutter call site | Method + path | Auth | Backend route | |---|---|---|---|---| | 1 | `auth_provider.dart` `login()` | `POST /api/v1/auth/login` | none (issues token) | `api/v1/auth/login/route.ts` | | 2 | `auth_provider.dart` `checkLoginState()` | `GET /api/v1/auth/me` | Bearer | `api/v1/auth/me/route.ts` | | 3 | `pending_documents_provider.dart` `_uploadAndProcess` | `POST /api/v1/documents/upload` (multipart: `image`, `latitude?`, `longitude?`, `scan_mode`) | Bearer, 401 enforced | `api/v1/documents/upload/route.ts` | | 4 | `pending_documents_provider.dart` `_pollUntilParsed` (every 2s, ≤130×) and `documents_screen.dart` `_loadDocuments` | `GET /api/v1/documents` | Bearer, 401 enforced | `api/v1/documents/route.ts` | | 5 | `editor_screen.dart` save, `product_editor_logic.dart` `_submit`, `pending_documents_provider.dart` `retrySync` | `PUT /api/v1/documents/:id` (`toPutPayload()`) | Bearer, 401 + per-store 403 | `api/v1/documents/[id]/route.ts` | | 6 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `GET /api/skus` — via `apiBaseUrl.replaceAll('/api/v1', '/api/skus')` | **none** (classic dev route) | `api/skus/route.ts` | | 7 | `product_editor_logic.dart` `_fetchClassificationAndSkus` | `POST /api/scan-pfm` (JSON `{image_base64}`) — same base-URL string hack | **none** (classic dev route) | `api/scan-pfm/route.ts` | | 8 | `app_config.dart` `_isBackendReachable` (startup probe) | `POST /api/v1/auth/login` with `{}` | none | same as #1 | Base URL: `AppConfig.apiBaseUrl` resolved once at startup (LAN first, ngrok fallback), frozen into the singleton Dio client (`api_client.dart:13`). Timeouts: `connectTimeout` 10s, `receiveTimeout` 240s (sized for upload's synchronous 210s-worst-case parse; every other call inherits it). ## Response envelopes - **v1 success**: `{ status: "success", message?, data: ... }` — Flutter reads `response.data['data']`. - **v1 error**: `{ status: "error", error: { statusCode, code, message } }` (`utils/api-error.ts` → `lib/http-status.ts`), mirrored by `lib/core/network/api_exception.dart` (`ApiException.fromDioException`). These two are deliberately kept in sync — keep it that way. - **Classic routes**: ad-hoc shapes — `GET /api/skus` returns `{ skus: [...] }`, `POST /api/scan-pfm` returns a flat `{ classification, ocr, possibleMatches, layoutParsingResult }`. No envelope, no auth, CORS-open. ## The DO document lifecycle (happy path, as implemented) 1. Capture → blur check → `addDocument()` persists a `PendingDocument` to Hive (`uploading`) → multipart POST to `/documents/upload` with `scan_mode` (`'DO'` or `'Product'` from `scanModeProvider`). 2. Upload route: dedups by `file_hash`+`kode_toko`; inserts `documents` row (`parsed=false`); **synchronously** calls internal `/api/parse` (210s abort); parse writes `metadata` JSONB + `ocr_items` rows and sets `parsed=true`. Response `data` is a **stub** DocumentModel (real `id`, empty header/items). 3. Client flips item to `processing` and polls `GET /documents` every 2s, looking for its `id` in the full list with `parsed==true` (the list endpoint filters `WHERE parsed = true`, so "appears in list" *is* the parse-done signal). 4. On success the pending card routes to `/editor` (or `/product-editor` when `scanMode == 'Product'`) with `pendingId` passed as `state.extra`. 5. Editor save: builds `DocumentModel` (`id = _document?.id ?? pendingId ?? now-ms`), saves to Hive `documentBox`, `PUT /documents/:id`; on success removes the pending item, on failure marks it `syncFailed` (retry = re-PUT via `retrySync`). 6. `documents_screen._loadDocuments`: shows Hive cache, then fetches the server list, **clears the whole Hive box**, and re-saves only server rows. The PUT route writes `metadata` in **both** shapes (legacy web keys `noPO`/ `customerInfo`/… *and* mobile `header`/`shipment` sub-objects); the GET list maps whichever exists. `DocumentModel.fromJson`/`toPutPayload` match this contract. ## Gaps found (G1-G10) **G1 — No `GET /api/v1/documents/:id`, and no parse status in the contract.** `api/v1/documents/[id]/route.ts` only has PUT. The poller must fetch the *entire* list every 2s (server side: one `documents` query + one `ocr_items` query **per document per poll** — N+1 that grows with history size). Because the list filters `parsed=true`, the client cannot distinguish "still parsing" / "parse failed" / "not mine": a server-side parse failure (upload route swallows it, `upload/route.ts:134`) surfaces only as the client's generic 260s timeout ("Gagal mengekstrak data (Timeout)"). Match detection also relies on a Dart object *identity* trick (`found != doc`, `pending_documents_provider.dart:133`). **G2 — Product flow calls unauthenticated dev routes via URL string-hacking.** `product_editor_logic.dart:57,69` rewrites the base URL with `.replaceAll('/api/v1', '/api/skus' | '/api/scan-pfm')`. Those classic routes are documented (backend/CLAUDE.md) as dev-only, never-authed, and not part of the production surface; backend task 4.5 (shipped) restricts the public ngrok tunnel to `/api/v1/*`, so both calls are expected to fail off-LAN. The existing v1 alternative `GET /api/v1/master/skus` is **admin-only** (403 for store accounts) and uses a different envelope (`{status,data}` vs `{skus}`), so the client can't just switch paths. **G3 — RESOLVED 2026-07-10 (root task 7.2 / backend task 11.1).** Double classification per product scan. Upload with `scan_mode=Product` already ran the classifier inside `/api/parse` and stored only the top-1 SKU as the document's single item. The product editor then re-read the image file, base64-encoded it (~MBs through Dio JSON), and re-ran the whole classify+OCR pipeline via `/api/scan-pfm` — ignoring the stored parse result except as a lat/lng fallback. Two GPU passes per photo; the reviewed result could disagree with the stored one. Fixed by having `/api/parse`'s Product branch call the same shared `classifyAndMatchProduct()` used by `POST /api/v1/scan-product` (task 9.3) and persist the full result (top-5 `possibleMatches` + OCR `extractedExpiryDate`) in `documents.metadata.productScan`, surfaced by `document-mapper.ts`. The editor now reads this directly from the document instead of re-classifying — one GPU pass per photo, editor opens instantly like DO Scan's does. **G4 — Product documents are typed by magic strings, `scan_mode` is never persisted.** The backend fabricates placeholder metadata for product scans (`PO-PRODUCT-001`, `noSO 1002003004`, `DO-PRODUCT-999`, `plat B 1234 PFM`, `order_untuk: "PRODUCT SCAN"` — `parse/route.ts:107-135`) and the Flutter side detects "is a product doc" by `orderUntuk == 'PRODUCT SCAN'` (`documents_screen.dart:110`, `product_editor_logic.dart:36`). `scan_mode` is sent at upload and forwarded to parse but never stored in `documents` nor returned by GET, and `DocumentModel` has no doc-type field. Editing `orderUntuk` silently moves a doc between tabs. **G5 — PUT to a client-generated ID can never succeed.** Both editors fall back to `finalDoc.id = widget.pendingId ?? now-ms` when `_document` is null (e.g. `pendingId` no longer found in the provider — the `orElse` stub at `product_editor_logic.dart:26` makes this reachable). The PUT then targets `/documents/<13-digit ms timestamp>`; the backend `parseInt`s it into a value that can't match (or even fit) the int4 `documents.id` → 404/500 → the item is stuck in `syncFailed` and every retry re-fails identically. **G6 — Server refresh wipes locally-saved-but-unsynced documents.** After a failed PUT the editors keep the corrected doc in Hive (`saveDocument(finalDoc)`) and mark the pending item `syncFailed` — but `documents_screen._loadDocuments` (`documents_screen.dart:55`) does `documentBox.clear()` and refills from the server list, deleting the local-only copy from history. Recovery survives only via the pending item's embedded `document`; the history list lies in between. **G7 — Mock data presented as real data in the product editor.** Offline/error fallback fabricates three hardcoded SKU "matches" with fake confidences (`product_editor_logic.dart:116-126`) and fake batch/expiry dates (`'15/12/2026','20/04/2027'` — also used whenever OCR extracted no expiry date, line 103). A reviewer cannot tell mock from model output. AGENTS.md §5 wants mock data behind an explicit Demo/Live switch, not silently inlined in the live path. **G8 — Route params ride on `state.extra`.** `/editor` and `/product-editor` receive `pendingId` via `GoRoute state.extra` (`app_router.dart:34,41`), which does not survive process death/state restoration and can't be deep-linked; a restored editor gets `pendingId == null` and renders empty (feeding G5). **G9 — `checkLoginState` error handling is string-typed and fail-open.** `auth_provider.dart:30` detects 401 by `e.toString().contains('401')` instead of `ApiException.from(e).statusCode`, and any *other* failure (timeout, 500, dead tunnel) silently keeps the user "logged in" with a possibly-stale profile in SharedPreferences. Plan task 1.1's original claim ("never pings the server") is stale — `/auth/me` *is* called now; the residual gap is this fragile detection. **G10 — Dedup response is a second, emptier stub.** A duplicate upload returns the *original* document's id with empty header/items and no `parsed` flag (`upload/route.ts:75-96`); the client treats it as fresh and re-polls. Works if the original parsed; if the original's parse failed (G1), the second client also burns the full 260s timeout. Low severity on its own — folds into G1's fix. **G11 — No draft/confirmed distinction — `parsed` triggers list visibility, not user confirmation.** `GET /api/v1/documents` filters `WHERE parsed = true`. `parsed` is set the instant the backend's OCR pass finishes (`api/parse/route.ts`), which happens **synchronously right after upload** — before the mobile user ever taps "Simpan & Konfirmasi" in the editor (that action only happens on `PUT /api/v1/documents/:id`). A scan the user captured, previewed, and then backed out of (without confirming) is already sitting in the server's document list with blank/placeholder fields — visible both to the store account and to admin. Fix: separate "OCR finished" (`parsed`) from "user confirmed" (`confirmed`) as two distinct booleans, gate list visibility on the latter. Root cause of the `document_card.dart:25` fallback to the placeholder string "Staff Toko" (empty `namaPenerima` on an unconfirmed DO document). Task: backend §10.1 (adds column + gates list) → Flutter §8.2 (adds `confirmed` field to `DocumentModel`). Added 2026-07-10 from user APK testing feedback. **G12 — Product Scan documents carry fabricated PO/SO/DO placeholder values with no real-world referent.** `api/parse/route.ts` Product-scan branch (lines ~107-118) stamps every Product Scan upload with `noPO: "PO-PRODUCT-001"`, `noSO: "1002003004"`, `noDO: "DO-PRODUCT-999"` — concepts that don't apply to a product verification scan. Not user-facing: `pdf_service.dart`'s Product receipt branch never prints them (shows "Nomor Batch"/expiry instead), and `product_editor_submit_logic.dart`'s `_submit()` never reads them (it builds `noPo`/`noSo`/`noDo` itself from the user's PO-link dropdown/batch selection). Only visible in raw debug logs. Same class of issue as the already-fixed G7 (fabricated SKU matches/dates) — dishonest raw data, low risk to remove since nothing meaningful depends on the values. Task: backend §10.2 (bundled with §10.1, same file). Added 2026-07-10 from user APK testing feedback. ## Ownership - Flutter-side fixes: root `plans/next-enhancements.md` §6 (G1 client half, G5, G6) and §7 (G2 client half, G3, G4 client half, G7) and §8 (G11 client half). G8/G9 are folded into existing §1/§4-adjacent tasks as noted there. - Backend counterparts: `backend/plans/next-enhancements.md` §9 (G1/G10 server half: GET-by-id + parse status; G2: non-admin v1 SKU read + v1 scan endpoint; G4 server half: persist and return `scan_mode`) and §10 (G11 server half: `confirmed` column + list filter + PUT flip; G12: remove fabricated PO/SO/DO). - Cross-cutting sequencing: backend §9 ships first; Flutter §6/§7 consume it. Backend §10 ships first; Flutter §8.2 consumes it. Flutter §8.1 is independent.