# Behavision API ↔ Loyaly Merchant OS — gap analysis Audit date: 2026-09-09 · API: `https://platform.loyaly.ai` · Frontend: this repo --- ## Headline finding **These are two different products.** This frontend was built as a **loyalty & rewards console**: LYT points, reward catalogues, engagement activities (spin / selfie / scratch / challenge / referral), campaigns, redemption liability, commerce orders. Behavision is a **camera-based footfall & visitor-recognition platform**: sites, cameras, face templates, arrivals, visitor identity, footfall and conversion reports. They overlap on roughly **one third** of the surface — the part that is genuinely about *people arriving at a shop and buying something*. The rest of the UI has no data source in this API and never will until a loyalty backend exists. So "remove all hardcodes" has a consequence that needs a decision, not a guess: **removing the fixtures makes about half the current UI go blank.** §4 lays out the options. The reverse is also true and more interesting: **Behavision exposes a lot of real product this console does not surface at all** — a visitor directory, live camera views, site health checks, an invitation/join flow, device management, GDPR erasure. See §5. --- ## 1. What maps — build these for real | Screen / panel | Behavision endpoint | Notes | |---|---|---| | Login | `POST /api/auth/login` | different token model — §3 | | Session restore | `GET /api/auth/me` | | | Logout | `POST /api/auth/logout` | | | Dashboard · Visitors KPI | `GET /api/reports/footfall` | use server `total`, never sum buckets | | Dashboard · Purchases KPI | `GET /api/reports/conversion` | | | Dashboard · Revenue KPI | `GET /api/reports/conversion` | | | Dashboard · Footfall chart | `GET /api/reports/footfall?bucket=day` | | | Dashboard · Revenue chart | `GET /api/reports/conversion?bucket=day` | | | Dashboard · Visitors vs purchases | both reports | | | Dashboard · Conversion chart | `GET /api/reports/conversion` | | | Dashboard · Peak hours heatmap | `GET /api/reports/footfall?bucket=hour` | 7×24 grid from hourly buckets | | Dashboard · Period rollup | `…?bucket=week\|month` | | | Dashboard · Store comparison | `GET /api/reports/footfall?site=` per site | | | Dashboard · Recent activity feed | `GET /api/visits` | **arrivals only** — see below | | Stores list | `GET /api/sites` | + `fraction_below_gate`, cameras up | | Store detail | `GET /api/sites/{id}/check` | five-step smoke test | | Settings · Team | `GET /api/team`, `PATCH /api/team/{id}` | replaces `INITIAL_STAFF` | | Settings · Security → sessions | `GET/DELETE /api/auth/sessions`, `POST …/revoke-others` | replaces `INITIAL_SESSIONS` | | Store switcher (`STORE_OPTIONS`) | `GET /api/sites` | **critical — scopes every request** | **Customer journey** maps partially and honestly: | stage | source | |---|---| | Visit | footfall `total` | | Engage | ✗ no source | | Purchase | conversion | | Return | footfall `returning` | | Refer | ✗ no source | **Activity feed caveat.** The current feed renders five event kinds (`reward_redeemed`, `staff_checked_in`, `purchase`, `reward_expired`, `store_opened`). `/api/visits` supplies **arrivals only**. Four of the five kinds have no source. The feed becomes an arrivals feed — which is arguably the better screen, and is what the spec calls "the screen a mobile app is for". --- ## 2. What does NOT map — no endpoint exists | Area | Frontend surface | Status | |---|---|---| | **LYT programme** | entire `/lyts` page: rewards, claimed/used, redemption rate, outstanding liability, expiry alerts, reward usage chart, LYTs issued | ✗ no rewards concept in the API | | **Activity intelligence** | walk / selfie / spin / scratch / brand / challenge / friend / shop / event, impact chains, attribution, activity grouping | ✗ only *visit* has an analogue | | **Campaign performance** | campaign funnels | ✗ | | **Store insights** | "What needs your attention", AI briefing | ✗ | | **Commerce** | orders, products, categories, stock, payment methods, refunds, AOV, alerts | ✗ except revenue/basket via conversion report; `POST /api/purchases` writes but there is no orders read | | **Staff module** | attendance (present/absent/late/leave), punctuality, sales per head, rewards issued, performance score | ✗ **different concept** — `/api/team` is console *user accounts*, not shop-floor rostering | | **Settings · Profile** | business name, GSTIN, timezone, currency, LYTs per ₹100 | ✗ no business-profile endpoint | | **Settings · Roles matrix** | per-permission grid | ✗ role is a single enum via `PATCH /api/team/{id}` | | **Settings · Integrations** | connected apps | ✗ | | **Settings · API & Webhooks** | keys, endpoints, delivery logs | ✗ | | **Settings · Billing** | plan, invoices, payout account | ✗ | | **Settings · Security → audit log, 2FA** | | ✗ (sessions *do* exist) | | **Loyaly AI** | chat panel | ✗ | --- ## 3. Architectural changes required These are not cosmetic. Each one has a defined failure mode. ### 3.1 Auth: cookie+HMAC → Bearer JWT with rotation Today: `proxy.ts` gates every route on an httpOnly cookie carrying an HMAC-signed payload minted locally by `sessionToken.ts`. There is no upstream. Behavision: `access_token` + `refresh_token`, both rotating. **Recommendation — keep the Next routes as a BFF (Backend-For-Frontend).** Tokens live server-side; the browser keeps only the existing httpOnly cookie. This is not conservatism, it buys five specific things: 1. `proxy.ts` and the whole SSR gate keep working unchanged. 2. Tokens never reach JS, so an XSS cannot exfiltrate a refresh token. 3. **Web `` cannot send `Authorization`.** A BFF can proxy `/api/faces/…` and the browser just uses a normal ``. Otherwise every avatar needs fetch + `createObjectURL` + revoke-on-unmount. 4. The single-flight refresh lock lives in one server process, not in N tabs. 5. CORS and `SameSite=None` disappear as problems. **Three client rules the spec calls out — all must be implemented in the BFF:** - `401` + `"error": "token_expired"` → refresh **once**, retry, silently. Any other 401 is a real sign-out. - **Serialise refresh behind one lock.** Refresh tokens are single-use; four concurrent panels would each spend it and three would lose. This dashboard fires **9 parallel requests on one page load** — it will hit this on the first expiry, every time, without the lock. - **Persist rotated tokens before using them.** A crash between refresh and persist leaves a token the server has already invalidated. Also: marshal the request body before the first attempt — a retry re-sends it, and a stream is spent after the first read. ### 3.2 Response envelope Frontend expects `{data, meta:{generatedAt, range, storeId}}`. Behavision returns bare objects/arrays. **`meta.generatedAt` is load-bearing** — every relative timestamp, the greeting, and every days-to-expiry countdown measures against the server clock, never `Date.now()`. The BFF must synthesise it (from `polled_at`, or the response time) or ~20 `useResource` call sites all need rewriting. Wrap in the BFF. Cheapest correct option by a wide margin. ### 3.3 Scope parameters `?storeId=all&range=30d` → `?site=&from=YYYY-MM-DD&to=YYYY-MM-DD&tz=…&bucket=…` - `storeId: 'all'` → omit `site` entirely. - `RangeKey` → concrete `from`/`to`. `to` is **inclusive**. - Send `tz`; buckets come back as **local wall time with no offset**. - Use `site` (documented spelling). An unknown query param is *silently ignored*, so `site_id` in the wrong place returns the whole estate instead of an error. ### 3.4 Report arithmetic — three traps - **Do not sum buckets to get the total.** `total` is unique people over the window; someone who came Monday and Thursday is 1 person, 2 bucket-visitors. Show the server's `total`, visits underneath. - **`new + returning` can be less than `total`** — a site sending counts without templates records real footfall by an unidentified person. - **Do not `new Date()` a bucket label.** They are wall-time strings with no offset; parsing shifts every label into the viewer's zone. `formatDayLabel()` currently does `new Date(iso)` — it pins `timeZone:'UTC'` so it survives, but bucket labels should be passed through, not parsed. ### 3.5 Errors Behavision: `{"error": "invalid_code", "message": "…"}` (flat). Frontend: `{error: {code, message}}` (nested), with codes `internal|not_found|bad_request|unauthorized`. Map in the BFF. And **show the server's `message`** — it is written for humans; branch on `error`, never on the prose. New codes to handle: `token_expired`, `too_many_attempts` (429), `last_owner` (409), `invalid_code` (404), **`501` = feature off for this deployment, not an error**. `404` is also what another tenant's data returns — never surface it as "deleted". ### 3.6 Cursor pagination — new concept `GET /api/visits` is cursor-based and lossless; polling by timestamp **permanently skips rows** when a burst exceeds `limit`. `useResource` has no cursor concept — the arrivals feed needs a cursor-aware hook. - Echo the returned `cursor` on every poll. - An empty poll returns **your own cursor**, not `""`. - A cursor that fails to parse → drop it, re-poll without one. - Delivery is at-least-once → de-duplicate on `visit_id`. - `GET /api/visits/stream` (SSE) is the low-latency path; needs an HTTP client that can set `Authorization` (browser `EventSource` cannot). ### 3.7 Images — new subsystem - **Missing photo is data, not an error.** Images are off by default across the product; `available: false` with a `reason` is the normal case. Render initials, never an error state. - `auth: true` → send Bearer. `auth` absent/false → presigned, use directly. **Do not infer from the URL shape.** Treat any relative URL as needing auth. - **Every hand-out is written to the audit log.** Fetch once per screen, not once per component — two components asking for one face puts two rows in "who looked at my customers" for one glance. - Web: object URL + **revoke on unmount**, or a screen left open all afternoon holds hundreds of copies of one photograph. (The BFF proxy in §3.1 avoids this entirely.) ### 3.8 Roles `UserRole = 'owner' | 'manager' | 'analyst'` → **`'staff' | 'manager' | 'owner'`**. `analyst` does not exist. Platform admin = `role === 'admin'` **and** empty `client_id` — the two together, never the role alone. ### 3.9 References instead of uuids `V-42`, `chennai`, `Office1`, `priya@tenext.in` all work in paths and filters, and are **immutable** — safe to put in a URL or a saved report. Display names are not. The store switcher should key on `site_slug`, and deep links should use it. Ambiguity resolves to nothing, not a guess. Unknown ref: **404 in a path, 400 in a filter**. --- ## 4. The decision: what happens to the unmapped half The API gives a clean idiom for this: **`501` — "the feature is off for this deployment, not an error."** | | option | result | |---|---|---| | **A** | Delete the unmapped modules | Smallest, most honest app. Lose `/lyts`, `/commerce`, activity intelligence, campaigns, insights, staff attendance, most of settings. Recoverable from git when a loyalty backend ships. | | **B** | Keep the UI, render "not available in this deployment" | Nothing hardcoded, nothing invented, screens stay for when the backend arrives. Costs a small unavailable-state component. | | **C** | Keep fixtures behind an explicit `DEMO_MODE` flag | Sales demos keep working; real deployments show B. Highest complexity. | **My recommendation: B**, with A for `/commerce` specifically — commerce is the one module with no seam at all (10 components import a service synchronously), so there is nothing to preserve, and conversion-report revenue can move onto the dashboard where it belongs. Either way **no invented number survives**, which is what you asked for. --- ## 5. Real product this console is not exposing Worth knowing before we decide what to delete — the API supports screens that do not exist here yet: - **Visitor directory** — `GET /api/visitors?q=` search by name/phone/`V-42`, `/history`, `PUT /profile` (name, phone, notes), and **`DELETE` erasure** (destroys face template + photo, keeps visits unlinked, irreversible; a `502` means *nothing* was deleted and must be reported as failure, never swallowed). - **Live camera view** — `GET /api/cameras/{id}/live`, SSE relayed from the shop PC. - **Camera management** — `GET /api/cameras` with latest still, `PATCH /api/cameras/{id}`. - **Site health** — `GET /api/sites/{id}/check`, five-step smoke test. - **Invitation / join flow** — mint a code, preview it unauthenticated, redeem it into a full session. Replaces the fake "Add Staff Member" form entirely. - **Device management** — the user's own signed-in devices, with `current` marked. - **Arrivals SSE stream** — the live feed. --- ## 6. Proposed sequence 1. **BFF + auth** — Behavision login/refresh/logout/me behind the existing cookie; single-flight refresh; envelope + error adapters; scope→params mapper. Nothing else can be wired until this exists. 2. **Kill the highest-risk hardcode** — `STORE_OPTIONS` → `GET /api/sites`. 3. **Reports** — dashboard KPIs, footfall, conversion, peak hours, rollup, comparison. 4. **Arrivals** — cursor-aware feed replacing the mock activity timeline. 5. **Team + Sessions + Invitations** — replaces four fake settings screens with real ones. 6. **Apply the §4 decision** to everything unmapped; delete `src/features/*/mock/**` and `src/shared/mock/`. 7. **Verify** — typecheck, build, walk every screen against the live API.