Chuyển tới nội dung chính

Nearest store

In one line: a brand banner comes into view → the app asks this endpoint which of that brand's branches the customer should go to (the closest one taking orders) → on tap, the app opens that branch's menu.

When you'd use this​

Some home-screen banners promote a brand, not a specific branch ("Order from Burger Bros"). The brand might have 30 branches across the city, so the banner can't point at one of them in advance — the right branch depends on where the customer is standing when they tap.

This endpoint answers that one question: "for this brand, which branch should this customer land on?" It returns a store id, or nothing. It does not return a deeplink or the store itself — the app does that part.

You only need it for banners whose link_type is nearest_branch.

Example: a real tap​

Ids and names below are illustrative.

  1. Sara opens the app in Dubai Marina. The home screen shows a banner for Burger Bros with link_type = nearest_branch and nearest_branch_merchant_id = 42.
  2. The banner scrolls into view. The app already knows the brand (42) and Sara's location, so it calls this endpoint right away (don't wait for the tap): GET /api/v2/client/banners/merchant/42/nearest-store?latitude=25.08&longitude=55.14
  3. The answer is store 1874 — Burger Bros JBR, 1.2 km away and taking orders.
  4. Sara taps. The app opens store 1874 via Merchant by id and shows its menu.

What if it's different?

  • A closer branch is closed, but JBR is taking orders. JBR still wins — a branch that's taking orders within 12 km always beats a closer one that isn't.
  • No branch within 12 km is taking orders. She lands on the closest branch at any distance, even if it's closed or offline, and sees that on its screen.
  • The brand has no active branch at all. The store id comes back null. The tap does nothing. No error screen.

How to call it​

GET /api/v2/client/banners/merchant/:merchantId/nearest-store?latitude=...&longitude=...

Endpoint: GET /api/v2/client/banners/merchant/{merchantId}/nearest-store — OpenAPI operation ClientNearestStoreController_getNearestStore in openapi/order-v2-client.json, where the exact request and response fields live.

  1. Take :merchantId from the banner's nearest_branch_merchant_id field.
  2. Send the user's current latitude / longitude.
  3. Fire the call when the banner becomes visible, and give it a short timeout (~1.5 s).
  4. Store id returned → open that store. null or timed out → treat the tap as a no-op.

No auth is needed (same as Banners).

Watch out​

  • Use nearest_branch_merchant_id, not merchant_id or id. The banner has three id-looking fields and only one is right.
  • A wrong id doesn't error. You get 200 with a null store id — exactly like "no branch nearby." If nearest_branch banners never open anything, check which id you're sending first.
  • null also means "server had a problem." The app can't tell an outage from "no branch"; only the server log can.

Details (for backend)​

Everything below is the reasoning and the exact rules — read it when you're changing this endpoint or debugging a surprising result.

Module: src/modules/client/nearest-store/. Public endpoint, no auth, consistent with Banners. Source for the rules below: docs/api/nearest-store.md and docs/superpowers/specs/2026-09-22-banner-nearest-branch-deeplink-design.md in locate-order-v2-api, cross-checked against nearest-store.service.ts and nearest-store.constant.ts on 2026-09-25 — code and doc agree.

:merchantId is merchants.id, taken from the banner​

The path param is the banner's nearest_branch_merchant_id — a real merchants.id, and a different id space from every other "merchant"-labelled field nearby. See the hazard table below and the fuller one on Merchant by id.

Store selection​

Only the brand's stores with management_status = 'active' and a real location are candidates. A store at (0, 0) counts as having no location — that's how this data marks one missing — and is never returned.

  1. The nearest operational store within NEAREST_STORE_RADIUS_KM (12 km) wins. Operational means is_online = true (NULL counts as offline) and store_disabled_ordering = false.
  2. Otherwise the nearest active store wins, at any distance, online or not, taking orders or not. The customer lands on it and sees that it's closed or offline.

A closer non-operational store never beats an operational one within 12 km. An operational store past 12 km gets no priority over a closer inactive one — if a closed store is nearer, the customer is sent to the closed one. Ties (two branches at the same distance) are broken by the lower store id, so the same request always returns the same answer. City zones are not consulted.

This endpoint does not reuse GET /stores/branches, which already orders branches by distance but requires the store to be open under its schedule, filters by collection_id rather than merchants.id, and runs a heavier query built for a different purpose (full branch payloads, not a single id).

The merchant_id hazard​

The banner object the app reads from Banners carries three integer-looking candidates, and only one belongs here:

Field on the bannerWhat it really isSend it as :merchantId?
nearest_branch_merchant_idmerchants.idYes — this one.
merchant_idthe banner's own store_id (see Banners)No
idthe banner's own idNo

None of the wrong ones error. They all resolve to a 200 with a null store id, identical to "no branch nearby" — a mis-integration is invisible in logs. This is also why anyone configuring a banner takes the merchant id from the Rails admin's merchants table, never from a store response's merchant_id (which is stores.collection_id — see Merchant by id). Passing a collection_id here doesn't error either; it just resolves as an unknown merchant.

Response behavior​

A null store id is the app's only signal to treat the tap as a no-op — it covers "merchant doesn't exist," "brand has no active store," and "the query failed," all identically, on purpose: a banner is a secondary entry point, and an infrastructure blip shouldn't put an error screen in front of the customer. The trade-off is that on the client side an outage looks exactly like "no branch nearby" — the server log (merchant id + error) is the only way to tell them apart. Field-level shape of both outcomes is in the OpenAPI operation named in the Endpoint callout above.

Performance and client guidance​

Measured on the dev database (4242 stores, worst-case brand ~132 active stores): DB execution ~1.1 ms, planning ~3.3 ms, via a bitmap index scan — no sequential scan. That leads to three decisions worth knowing if this ever needs revisiting:

  • No cache. At ~1 ms of DB time, a cache would mainly add a coordinate-rounded key and an invalidation problem every time a store's status changes.
  • The app should prefetch this call when a nearest_branch banner becomes visible, not wait for the tap — both the coordinates and nearest_branch_merchant_id are already known at render time.
  • The app needs a short client-side timeout (~1.5s) that falls back to treating the result as a null store id rather than leaving the tap unresponsive.

Testing​

Unit tests: npx jest src/modules/client/nearest-store. An integration suite (test/nearest-store/nearest-store.integration-spec.ts) runs the real service against a seeded brand with branches in several online/offline/ordering-disabled/no-city-zone states, inside a transaction that's always rolled back. See docs/api/nearest-store.md in locate-order-v2-api for the full seed layout and manual QA cases (docs/qa/2026-09-22-nearest-branch-banner-qa.md).