# 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.