278 lines
14 KiB
Markdown
278 lines
14 KiB
Markdown
# 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 `<img>` cannot send `Authorization`.** A BFF can proxy
|
||
`/api/faces/…` and the browser just uses a normal `<img src>`. 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=<slug>&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.
|
||
|