12 KiB
Loyaly Merchant OS — API inventory & backend wiring plan
Audit date: 2026-09-09 · Branch main · Audited against the running app on :3100
Purpose: establish exactly what data the frontend consumes today, which of it is fake, and what a real backend must expose. Compare this against your API spec and mark each row match / rename / missing / extra.
1. How data flows today
component → hook (useResource) → repository (owns the URL) → httpClient
│
NEXT_PUBLIC_API_BASE ────┤
│
(unset) → Next route handler → fixture generator
(set) → YOUR BACKEND
The seam already exists and works. src/shared/services/httpClient.ts line 22:
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
Set that to your API host and every endpoint in §2 bypasses the local route handlers entirely. No component changes. This is the intended cutover.
Response envelope — every endpoint must return this shape (src/shared/types/api.ts):
// success
{ "data": <T>, "meta": { "generatedAt": "ISO-8601", "range": "30d", "storeId": "all" } }
// failure (any non-2xx, or 2xx with error present)
{ "error": { "code": "internal|not_found|bad_request|unauthorized", "message": "…" } }
meta.generatedAt is required: the UI measures every relative time
("2 hours ago", days-to-expiry, the greeting) against the server clock, never
Date.now().
Scope query — every analytics endpoint is filtered by the same two params:
| param | values |
|---|---|
storeId |
all | a store id |
range |
7d | 30d | 90d | mtd | ytd | custom |
Auth — session is an httpOnly cookie. credentials: 'same-origin' is sent on
every request. A cross-origin backend needs CORS + SameSite=None; Secure, or
keep the Next routes as a thin proxy.
2. Endpoints that EXIST (24) — all fixture-backed
Legend: T = TypeScript response type, defined in the file noted.
Auth — src/features/auth/types/auth.ts
| Method | Path | Body / Query | Returns |
|---|---|---|---|
| POST | /api/auth/login |
{email, password, rememberMe} (JSON and form-encoded) |
AuthSession |
| POST | /api/auth/logout |
{} |
{ok: boolean} |
| GET | /api/auth/session |
— | AuthSession | null |
AuthSession = {user: {id, email, name, role: 'owner'|'manager'|'analyst', organisation}, expiresAt}
Dashboard — src/features/dashboard/types/dashboard.ts + intelligence.ts
| Method | Path | Extra query | Returns |
|---|---|---|---|
| GET | /api/dashboard/kpis |
scope | Kpi[] |
| GET | /api/dashboard/timeseries |
scope | TimePoint[] |
| GET | /api/dashboard/peak-hours |
scope | HourCell[] |
| GET | /api/dashboard/activity |
scope | ActivityEvent[] |
| GET | /api/dashboard/store-comparison |
scope | StoreComparison[] |
| GET | /api/dashboard/reward-usage |
scope | RewardUsagePoint[] |
| GET | /api/dashboard/performance |
scope + granularity=weekly|monthly |
PeriodPoint[] |
| GET | /api/dashboard/briefing |
scope | DashboardBriefing |
| GET | /api/dashboard/activity-metrics |
scope | ActivityMetric[] |
| GET | /api/dashboard/journey |
scope | JourneyStage[] |
| GET | /api/dashboard/campaigns |
scope | CampaignSummary[] |
| GET | /api/dashboard/insights |
scope | Insight[] |
Key shapes:
Kpi { id:'visitors'|'purchases'|'revenue'|'activeRewards', label, value,
unit:'count'|'inr'|'lyt'|'pct', deltaPct, isRiseGood, trend:{t,v}[] }
TimePoint { t:'YYYY-MM-DD', visitors, purchases, revenue, conversion }
HourCell { day:0-6 (0=Mon), hour:0-23, value }
ActivityEvent{ id, at:ISO, kind:'reward_redeemed'|'staff_checked_in'|'purchase'
|'reward_expired'|'store_opened', title, detail?, storeId }
PeriodPoint { label:'W32'|'Aug', visitors, purchases, revenue }
Insight { id, severity:'info'|'success'|'warning'|'error', title, body,
action?:{label, href} }
Activity intelligence (intelligence.ts) — the model shared by Dashboard and Lyts:
ActivityMetric {
id: 'walk'|'visit'|'selfie'|'spin'|'scratch'|'brand'|'challenge'|'friend'|'shop'|'event'
label, description
group: 'engagement'|'growth'|'commerce'
accent: 'warm'|'cool' // fixed per activity, travels on the payload
count, deltaPct
status: 'live'|'paused'|'draft'
lytsIssued // MEASURED (ledger). 1 LYT = ₹1
isFeatured // the 6 the dashboard summarises
impact: {
customers // MEASURED
rewardClaims? // omitted, never 0, when activity grants none
repeatVisits, purchases // ATTRIBUTED
attributedRevenueInr // ATTRIBUTED — never name this `revenue`
attribution: 'estimated'|'observed'
}
}
JourneyStage { id:'visit'|'engage'|'purchase'|'return'|'refer', label, value, conversionPct? }
CampaignSummary { id, name, activityId, accent, status:'live'|'ended'|'scheduled',
steps:{label,value}[], attributedRevenueInr, attribution }
Contract rule.
attributionis not decorative. When it is'estimated'the UI shows a disclosure ("Attribution is estimated…") beside every attributed figure; when your backend can join purchases to activity events, return'observed'and the disclosure disappears with no frontend change. Invariants the UI assumes:purchases ≤ repeatVisits ≤ customers ≤ count.
Lyts — src/features/lyts/types/reward.ts
| Method | Path | Returns |
|---|---|---|
| GET | /api/lyts/rewards |
Reward[] |
| GET | /api/lyts/redemptions |
TimePoint[] ⚠️ |
| GET | /api/lyts/activity |
ActivityEvent[] |
Reward { id, name, costLyt, claimed, used, expiresAt: ISO|null, status: 'active'|'paused'|'expiring'|'expired' }
⚠️ Known contract smell: redemptions reuses TimePoint, and the Lyts charts
read visitors as "Issued" and purchases as "Redeemed". Your backend should
return a purpose-built shape — {t, issued, redeemed} — and I'll update the two
chart call sites.
Stores — src/features/stores/types/store.ts
| Method | Path | Returns |
|---|---|---|
| GET | /api/stores |
Store[] |
| GET | /api/stores/:storeId |
Store (404 → not_found) |
Store { id, name, status:'open'|'closed'|'maintenance', visitors, purchases, revenueInr, conversionPct, staffCount }
Staff — src/features/staff/types/staff.ts
| Method | Path | Returns |
|---|---|---|
| GET | /api/staff |
StaffMember[] |
| GET | /api/staff/summary |
StaffSummary |
| GET | /api/staff/attendance |
AttendancePoint[] |
Settings — src/features/settings/types/settings.ts
| Method | Path | Returns |
|---|---|---|
| GET | /api/settings/profile |
MerchantProfile |
| PATCH | /api/settings/profile |
MerchantProfile (merged) — does not persist today |
3. Endpoints that DO NOT EXIST — must be created
These screens are live in the UI with no API, no repository, and no persistence.
Mutations are useState only: refresh the page and every change is gone.
3a. Commerce — the worst-wired module
/commerce imports commerceService.ts directly and synchronously from 10
component files. No repository, no route handler, no loading or error state.
Needed:
| Method | Path | Returns |
|---|---|---|
| GET | /api/commerce/kpis |
revenue, orders, AOV, net sales, refunds, conversion (value + delta each) |
| GET | /api/commerce/revenue-trend |
{t, revenue, target}[] |
| GET | /api/commerce/orders-trend |
{t, orders}[] |
| GET | /api/commerce/payment-breakdown |
{method, amount, sharePct}[] |
| GET | /api/commerce/store-performance |
{storeId, name, revenueInr}[] |
| GET | /api/commerce/top-products |
{id, name, category, unitsSold, revenueInr, growthPct, stockCount, stockStatus}[] |
| GET | /api/commerce/alerts |
{id, severity, title, body}[] |
| GET | /api/commerce/orders |
recent orders feed |
3b. Settings — 7 screens, all local-state fakes
| Screen | Needed endpoints |
|---|---|
| Team & Staff | GET/POST/PATCH/DELETE /api/settings/team (+ suspend, password reset, invite) |
| Stores | GET/POST/PATCH/DELETE /api/settings/stores |
| Roles & Permissions | GET/PUT /api/settings/roles (permission matrix) |
| Integrations | GET /api/settings/integrations, POST/DELETE .../:id/connection |
| API & Webhooks | GET/POST/DELETE /api/settings/api-keys, GET/POST/DELETE /api/settings/webhooks, GET /api/settings/webhooks/logs |
| Security | GET /api/settings/sessions, DELETE /api/settings/sessions/:id, GET /api/settings/audit-log, POST /api/auth/password, POST/DELETE /api/auth/2fa |
| Billing | GET /api/settings/billing, GET /api/settings/invoices, PATCH /api/settings/payout-account |
3c. Loyaly AI
The chat panel streams from services/ai/mockAi.ts — a local prompt classifier
picking canned templates. loyalyAiRepository is the only repository in the app
that imports a mock directly.
Needed: POST /api/ai/chat (streaming — SSE or chunked), plus
GET/POST/DELETE /api/ai/conversations if history is to survive reload.
3d. Store switcher — critical
STORE_OPTIONS is a hardcoded array in src/shared/providers/WorkspaceProvider.tsx
(line 45), duplicated from the fixture roster. This array scopes every request in
the app. It must be fed from GET /api/stores, or a real merchant sees fixture
store names in the global switcher.
4. Hardcoded / dummy data inventory
| Location | What | Removal |
|---|---|---|
src/features/*/mock/*.ts (11 files, ~1,970 lines) |
all fixture generators | delete at cutover |
src/shared/mock/rng.ts |
seeded RNG | delete at cutover |
src/features/commerce/services/commerceService.ts |
KPIs, trends, products, alerts, store multipliers | replace with repository |
src/features/settings/components/*.tsx × 7 |
INITIAL_STAFF, INITIAL_STORES, INITIAL_MATRIX, INITIAL_APPS, INITIAL_KEYS, INITIAL_WEBHOOKS, WEBHOOK_LOGS, INITIAL_SESSIONS, AUDIT_LOGS, INVOICES |
replace with hooks |
src/shared/providers/WorkspaceProvider.tsx |
STORE_OPTIONS |
feed from /api/stores |
src/features/loyaly-ai/services/ai/** (~570 lines) |
prompt router + reply templates | replace with real AI endpoint |
src/features/auth/mock/users.mock.ts |
5 users, plaintext passwords | replace verifyCredentials() body |
src/app/api/**/route.ts (24 files) |
fixture wiring + ?_state= simulation |
delete or keep as proxy — see §6 |
Auth note. verifyCredentials() is already the single credential seam — its
body becomes an HTTP call and nothing else changes. But it currently returns
unknown_email vs wrong_password separately, which is a user-enumeration
oracle. Collapse both to one message at the route when you go live.
AUTH_SECRET must be set in production (sessionToken.ts throws without it).
5. What I recommend NOT doing yet
Deleting the fixtures before the real endpoints exist leaves every screen in an
error state and removes the only way to verify the wiring. The fixtures are also
the executable spec — intelligence.mock.ts encodes the funnel invariants
your backend has to honour.
Order that keeps the app working at every step:
- Now (backend-independent): add the missing seams — commerce repository +
route, settings hooks + routes,
STORE_OPTIONSfrom/api/stores, AI repository seam. Fixtures stay behind them. Every module then has one file to swap. - You send the API spec MD. I diff it against §2/§3 and report match / rename / missing / extra per endpoint.
- Cutover: point
NEXT_PUBLIC_API_BASEat the real host, adapt any shape mismatches in the repository layer only, deletemock/+src/app/api/**. - Verify: typecheck, build, and walk every screen with the network tab.
6. Decision needed from you
Do the Next.js route handlers stay?
- A — Direct: frontend calls your backend. Delete
src/app/api/**. Needs CORS and a cross-origin-safe session cookie. - B — Proxy (recommended): keep the route handlers, replace each fixture call
with a
fetchto your backend. Session cookie stays first-party, your API host is never exposed to the browser, and the?_state=error|empty|loadingdev harness keeps working.