14 KiB
Behavision API ↔ Loyaly Merchant OS — gap analysis
Audit date: 2026-09-09 · API: https://mcp.loyaly.ai · Frontend: this repo
(Host corrected 2026-09-21: this line read https://platform.loyaly.ai, which
serves this console, not the API. See src/shared/config/platformApi.ts:76-80.)
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:
proxy.tsand the whole SSR gate keep working unchanged.- Tokens never reach JS, so an XSS cannot exfiltrate a refresh token.
- Web
<img>cannot sendAuthorization. A BFF can proxy/api/faces/…and the browser just uses a normal<img src>. Otherwise every avatar needs fetch +createObjectURL+ revoke-on-unmount. - The single-flight refresh lock lives in one server process, not in N tabs.
- CORS and
SameSite=Nonedisappear 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=<slug>&from=YYYY-MM-DD&to=YYYY-MM-DD&tz=…&bucket=…
storeId: 'all'→ omitsiteentirely.RangeKey→ concretefrom/to.tois inclusive.- Send
tz; buckets come back as local wall time with no offset. - Use
site(documented spelling). An unknown query param is silently ignored, sosite_idin 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.
totalis unique people over the window; someone who came Monday and Thursday is 1 person, 2 bucket-visitors. Show the server'stotal, visits underneath. new + returningcan be less thantotal— 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 doesnew Date(iso)— it pinstimeZone:'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
cursoron 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 setAuthorization(browserEventSourcecannot).
3.7 Images — new subsystem
- Missing photo is data, not an error. Images are off by default across the
product;
available: falsewith areasonis the normal case. Render initials, never an error state. auth: true→ send Bearer.authabsent/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), andDELETEerasure (destroys face template + photo, keeps visits unlinked, irreversible; a502means 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/cameraswith 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
currentmarked. - Arrivals SSE stream — the live feed.
6. Proposed sequence
- 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.
- Kill the highest-risk hardcode —
STORE_OPTIONS→GET /api/sites. - Reports — dashboard KPIs, footfall, conversion, peak hours, rollup, comparison.
- Arrivals — cursor-aware feed replacing the mock activity timeline.
- Team + Sessions + Invitations — replaces four fake settings screens with real ones.
- Apply the §4 decision to everything unmapped; delete
src/features/*/mock/**andsrc/shared/mock/. - Verify — typecheck, build, walk every screen against the live API.