Files
loyaly-merchant/docs/BEHAVISION-GAP-ANALYSIS.md
2026-09-21 16:38:15 +05:30

281 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
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.