Compare commits

..

7 Commits

Author SHA1 Message Date
2d8739e9a4 update api and login 2026-09-29 00:44:48 +05:30
b36a30385c update the adminpage ui 2026-09-25 16:29:41 +05:30
91e4db8217 admin login issue for timeout issue 2026-09-22 00:23:34 +05:30
b60b8baef6 admin login issue 2026-09-21 16:38:15 +05:30
8456c5e852 login timeout issue fix 2026-09-19 23:22:45 +05:30
02aadaf091 fix floor and sales error 2026-09-19 16:26:21 +05:30
ea3dbbeaf3 login issue 2026-09-19 13:12:19 +05:30
289 changed files with 20168 additions and 1516 deletions

83
.env
View File

@@ -1,14 +1,79 @@
# Runtime configuration. Committed on purpose - it carries no secret.
# Precedence: process.env > .env.production.local > .env.local > .env.production > .env
# ---------------------------------------------------------------------------
# Production runtime configuration. COMMITTED ON PURPOSE — carries no secret.
# ---------------------------------------------------------------------------
#
# The platform API. https://mcp.loyaly.ai is the default in every environment
# and the only value production accepts; it is written here so `docker run`
# is self-describing. NOT platform.loyaly.ai - that host serves this console.
# This file is the production environment. It is read by `next build` and, more
# importantly, by the standalone `server.js` at boot (Next calls loadEnvConfig
# on the server's working directory), so the deployed container knows the
# platform host without anyone remembering to type it into a dashboard.
#
# ── Precedence, exactly as @next/env resolves it ────────────────────────────
#
# 1. real process.env (Dokploy / docker -e / systemd) ← always wins
# 2. .env.production.local
# 3. .env.local ← LOCAL DEV ONLY. Never enters the image.
# 4. .env.production
# 5. .env ← this file, the floor everything falls back to
#
# A value already present in process.env is never overwritten by a file, so
# setting LOYALY_API_BASE in Dokploy still overrides this — nothing here locks
# the deployment in. It only removes "unset" as a possible state.
#
# ── Working on this locally? ────────────────────────────────────────────────
# Put your overrides in `.env.local` (gitignored, loaded ahead of this file).
# Without one, `npm run dev` will talk to the PRODUCTION platform, because that
# is what this file says. `.env.example` has the local values to copy.
# The one shared Loyaly platform API (Behavision). Server-side only and
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
# the BFF, which is what keeps the access token out of JavaScript.
#
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
# the variable there makes the BFF call its own origin, which fails in a way
# that looks like a broken login form rather than a misconfiguration.
# apiClient.ts rejects that hostname by name for exactly this reason.
#
# NOT REQUIRED in production any more. Production accepts exactly one origin, so
# an unset variable could never have meant another one, and platformApi resolves
# it to that origin on its own. It stays here so `docker run` is self-describing
# and so development has something to read.
#
# Why that change was needed: @next/env only fills a variable that is ABSENT.
# Verified against the installed copy — a real environment variable set to the
# EMPTY STRING stays empty and this file is NOT consulted. So one blank field in
# a dashboard silently defeated the value below and took production down with
# "LOYALY_API_BASE is required in production".
LOYALY_API_BASE=https://mcp.loyaly.ai
# Browser -> this app's own routes, same origin. Empty is correct.
# Browser → this app's own BFF routes, which are same-origin. Empty is correct
# and is what makes the console work on any hostname it is served from:
# requests go to /api/... on whatever origin loaded the page (localhost:3100 in
# dev, platform.loyaly.ai in production) and the server hop above reaches the
# platform. Setting this to the platform host would send the browser straight
# at the API with no session cookie and no token — do not.
#
# It is NEXT_PUBLIC, so it is inlined at BUILD time, not read at runtime.
# Changing it in Dokploy's environment panel would do nothing without a rebuild.
NEXT_PUBLIC_API_BASE=
# AUTH_SECRET is deliberately NOT here: it signs sessions, so a committed value
# is a session-forging key in git. Set it in the runtime environment (or point
# AUTH_SECRET_FILE at a mounted secret). Generate with: openssl rand -hex 32
# AUTH_SECRET is deliberately NOT in this file. It is the ONLY variable this
# deployment requires, and the only one that cannot ship.
#
# It signs the session cookie and encrypts the platform token bundle, so a
# value committed here is a session-forging key in git — anyone who can read
# the repo could mint a cookie for any user. It was already removed from the
# Dockerfile once for that reason; do not reintroduce it here.
#
# Set it as a Dokploy environment variable in the RUNTIME panel — a value set as
# a BUILD argument is not present when the server runs, which looks exactly like
# never having set it. Alternatively mount the value and set AUTH_SECRET_FILE to
# its path (the Docker/Swarm secret convention); AUTH_SECRET wins if both exist.
#
# Production refuses to sign sessions without it. Generate with:
#
# openssl rand -hex 32
#
# Hex, not base64: a base64 value ends in '=' and can contain '+' and '/', and
# an environment editor that splits a line on the first '=' can store that
# truncated or empty. A silently-empty AUTH_SECRET looks exactly like an unset
# one, which is a slow afternoon. Hex has nothing a parser can mangle.

View File

@@ -1,12 +1,42 @@
# Copy to .env.local for development. Only AUTH_SECRET is required.
# ---------------------------------------------------------------------------
# Template for `.env.local` — your LOCAL overrides. Copy it:
#
# cp .env.example .env.local
#
# Do not copy it to `.env`. `.env` is committed and already holds the
# production values; `.env.local` is loaded ahead of it and is gitignored.
# ---------------------------------------------------------------------------
# Signs the session cookie and encrypts the platform tokens inside it.
# Generate with: openssl rand -hex 32
# The one shared Loyaly platform API (Behavision). Server-side only and
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
# the BFF, which is what keeps the access token out of JavaScript.
#
# local dev http://127.0.0.1:8088 ← what belongs in .env.local
# production https://mcp.loyaly.ai ← already set in the committed .env
#
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing
# the variable there makes the BFF call its own origin, which fails in a way
# that looks like a broken login form rather than a misconfiguration.
#
# Production no longer requires this: it accepts exactly one origin, so an unset
# value can only have meant that one, and platformApi resolves it. Any OTHER
# host set explicitly is still rejected. Locally it is worth setting, because a
# dev machine legitimately means a different address.
LOYALY_API_BASE=http://127.0.0.1:8088
# Signs the session cookie and encrypts the platform token bundle.
#
# The ONLY variable production requires, the only real secret, and the only one
# taken solely from the environment — it is in no committed file, by design.
# Set it as a Dokploy environment variable in the RUNTIME panel (a build
# argument is not present at runtime), or mount it and set AUTH_SECRET_FILE to
# its path. Locally, any string works; leave it blank and a development key is
# used.
#
# Generate with: openssl rand -hex 32 (hex, not base64 — a trailing '=' can be
# mangled by a dashboard env editor that splits on the first '=')
AUTH_SECRET=
# The platform API. Defaults to https://mcp.loyaly.ai in EVERY environment;
# set this only if you are deliberately developing against another backend.
# LOYALY_API_BASE=
# Browser -> this app's own routes. Empty is correct.
# Browser → this app's own BFF routes. Same origin, so leave it empty. Inlined
# at BUILD time (NEXT_PUBLIC), so changing it at runtime does nothing.
NEXT_PUBLIC_API_BASE=

View File

@@ -0,0 +1,310 @@
# Platform admin monitoring: backend API plan
Status: **proposal for the platform team** · Written 2026-09-24 · Frontend ready on `main`
The platform console (`/admin`) now has the full drill-down built:
```
Overview → Merchants → Merchant → Shops → Shop → Cameras → Camera → Events / Alerts
```
(Console routes: `/admin/merchants/{clientId}/shops/{siteId}/cameras/{cameraId}`.
The platform's own names stay `clients` / `sites`; "merchant" and "shop" are
the console's words for them.)
Only the first rung has data. Everything below it shows **"Backend integration
required"** because the platform has no admin endpoint that can answer it. This
document lists what the console needs, the rules each endpoint must enforce, and
exactly what the frontend changes when each one ships (usually one line).
---
## 1. Where things stand
| Level | Endpoint | Status |
|---|---|---|
| Companies | `GET /api/admin/clients` | **Live** |
| Create / suspend / reinstate / reset owner password / delete | `/api/admin/clients…` | **Live** |
| Company detail | `GET /api/admin/clients/{clientId}` | Missing. The console reads the row from the list, which has every field there is today. |
| Company → stores | `GET /api/admin/clients/{clientId}/sites` | **Missing** |
| Store detail | `GET /api/admin/clients/{clientId}/sites/{siteId}` | **Missing** |
| Store → cameras | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras` | **Missing** |
| Camera detail | `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras/{cameraId}` | **Missing** |
| Events | `GET /api/admin/clients/{clientId}/sites/{siteId}/events` | **Missing** |
| Alerts | `GET /api/admin/clients/{clientId}/sites/{siteId}/alerts` | **Missing** |
| Platform totals | `GET /api/admin/monitoring/summary` | **Missing** |
| Platform-scope assistant | `POST /api/admin/assistant` | **Missing** |
### Why the tenant endpoints cannot be reused
`/api/sites`, cameras, visits and `/api/assistant` all take the company from the
**signed-in account's `client_id`**. A platform admin has `role = "admin"` and an
**empty** `client_id`, which is the very thing that makes `adminOnly` pass. So
those routes have no company to scope to, and the frontend proxy refuses them for
admin sessions (403).
The console must **not** get around this by:
- passing a company id to a tenant route,
- listing every site on the platform and filtering in the browser,
- signing in as the tenant's owner behind the scenes.
Each of those moves the tenancy check out of the server that holds the data.
The endpoints below keep it there.
---
## 2. Rules for every endpoint below
### 2.1 Authorisation
- Same gate as today: `adminOnly` (`role === "admin"` AND empty `client_id`).
Anyone else gets **404, not 403**, matching `/api/admin/clients`.
### 2.2 Ownership, checked on every nested request
The URL is a claim, not proof. For
`/api/admin/clients/A/sites/B/cameras/C` the server must verify **all** of:
```
client A exists
site B.client_id == A → otherwise 404
camera C.site_id == B → otherwise 404
```
Return **404** when any link fails, never the object found under a different
parent. A camera id that is valid, but under another company's store, has to
look exactly like one that does not exist.
Do this in a single query joined up to `client_id`, e.g.
`WHERE c.id = $cam AND c.site_id = $site AND s.client_id = $client`,
not as three lookups that each trust the previous id.
### 2.3 Reuse, don't fork
The tenant handlers already compute everything the console shows
(`SiteHealth`, camera liveness, `last_seen_at`). Take their query functions
and call them with an explicit `client_id` argument, rather than writing a
second copy for admin. The only difference between the two routes should be
where `client_id` comes from: the session for tenants, the path for admins.
### 2.4 Redaction
Admins see **operational** state, not credentials. From camera rows, drop
`username`, `host`, `port`, `path` and `has_password`. The console doesn't
need them, and a platform admin has no business collecting a merchant's
camera credentials in one place.
### 2.5 Suspended companies
Still readable. A suspended company is usually the one somebody is on the phone
about, so the admin needs to see it.
### 2.6 Audit
Log every admin read below the company level (`admin_id`, `client_id`, path).
Face and visit data is biometric-adjacent, and looking into a tenant's data is
a different act from administering the tenant.
### 2.7 Pagination
Lists that can grow take `?limit=` (default 50, max 200) and `?cursor=`, and
return `{items: [...], next_cursor: "…"|null}`. The console reads `items` and
handles either a bare array or this envelope in its mapper.
---
## 3. Endpoints
Shapes deliberately **match the tenant payloads the frontend already
understands** (`ApiSite`, `ApiCamera` in `src/services/api/types.ts`), so one
mapper serves both consoles. Field names below are the wire names.
### 3.1 `GET /api/admin/clients/{clientId}`
Company detail. Same `ClientRow` as the list. Optionally add `owner_email` and
`owner_name`: the console has a slot for "Owner" and leaves it out today
because nothing sends it.
### 3.2 `GET /api/admin/clients/{clientId}/sites`
Stores of one company. **Only** that company's sites.
```jsonc
[
{
"site_id": "uuid",
"slug": "…",
"name": "…",
"timezone": "Asia/Kolkata",
"online": true,
"cameras_up": 3,
"cameras_total": 4,
"created_at": "2026-…"
}
]
```
`?q=` (name/slug contains) is useful once a tenant has dozens of stores.
### 3.3 `GET /api/admin/clients/{clientId}/sites/{siteId}`
One store, the same row as 3.2. 404 unless `site.client_id = clientId`.
### 3.4 `GET /api/admin/clients/{clientId}/sites/{siteId}/cameras`
```jsonc
[
{
"id": "uuid",
"camera_id": "entrance-1",
"label": "Entrance",
"enabled": true,
"connected": true, // null = never reported
"last_seen_at": "2026-…",
"live_available": false // true only when an admin stream route exists (3.8)
}
]
```
The console derives status from `connected`: `true` is Online, `false` is
Offline, `null` is Unknown. It invents no "maintenance" or "warning" state.
If the platform has such a state, add it as a field and the console will show
it.
### 3.5 `GET …/cameras/{cameraId}`
One camera, same row. 404 unless the full chain in §2.2 holds.
### 3.6 `GET …/sites/{siteId}/events?camera=&since=&limit=&cursor=`
```jsonc
{ "items": [
{ "id": "…", "at": "2026-…", "type": "visit|face_match|…",
"camera_id": "…", "camera_label": "…", "severity": "info|warning|critical" }
], "next_cursor": null }
```
`camera` narrows to one camera **of this site**. A camera from another site
returns an empty list, never that camera's events.
### 3.7 `GET …/sites/{siteId}/alerts?camera=&status=open`
```jsonc
[ { "id": "…", "at": "…", "title": "Camera offline",
"severity": "critical|warning|info",
"status": "open|acknowledged|resolved",
"camera_id": "…", "camera_label": "…" } ]
```
If the platform has no alert model yet, this is the one endpoint that needs a
design decision first, not just plumbing. "Camera offline for more than N
minutes" is the obvious first alert, and it can be derived from data already
stored.
### 3.8 Live stream (later)
`GET …/cameras/{cameraId}/live`, which reuses the tenant live route with the
ownership chain from §2.2 and an audit entry. Until it exists the console shows
"Live feed unavailable" and never a placeholder that looks live.
### 3.9 `GET /api/admin/monitoring/summary`
Platform-wide totals for the Overview page. Today it shows cameras online,
open alerts and events today as "—".
```jsonc
{ "cameras_total": 0, "cameras_online": 0,
"alerts_open": 0, "events_today": 0,
"as_of": "2026-…" }
```
Aggregate counts only, with no per-tenant rows.
### 3.10 `POST /api/admin/assistant`
Platform-scope Loyaly AI. The body is the same as `/api/assistant` plus
`context: {level, company_id?, site_id?, camera_id?}`, which the console
already tracks per page. Its tools must go through 3.1–3.9, so the assistant
inherits the same ownership checks rather than having its own.
---
### 3.11 Merchant-level areas (added 2026-09-25)
The console now lists these on every merchant page with their state, and has
top-level **Footfall** and **Commerce** pages that pick Merchant → Shop before
asking for anything. Each is a flag in `features/admin/config/capabilities.ts`.
Same rules as §2: admin-only (404 otherwise), ownership checked on every nested
id, never answered from a tenant route.
| Area | Endpoint needed | Flag |
|---|---|---|
| Edit merchant | `PATCH /api/admin/clients/{clientId}` accepting `name` (today it takes `active` only) | `merchantEdit` |
| Sales persons | `GET/POST /api/admin/clients/{clientId}/salespersons`, `PATCH/DELETE …/salespersons/{id}` — they sign in on the mobile app | `salesPersons` |
| Customers | `GET /api/admin/clients/{clientId}/customers` | `customers` |
| Sales | `GET …/sites/{siteId}/sales` | `sales` |
| Analytics | `GET …/sites/{siteId}/analytics` | `analytics` |
| Footfall | `GET …/sites/{siteId}/visits?since=&until=` | `footfall` |
| Commerce | `GET …/sites/{siteId}/commerce` (or reuse `sales`) | `commerce` |
| Camera CRUD | `POST …/sites/{siteId}/cameras`, `PATCH/DELETE …/cameras/{cameraId}` (read is §3.4) | `storeCameras` |
| Camera heartbeat | `GET …/sites/{siteId}/cameras/heartbeat` — `last_seen_at` per camera | `cameraHeartbeat` |
| Device logs | `GET …/sites/{siteId}/device-logs?cursor=` | `deviceLogs` |
| Testing software | `GET …/sites/{siteId}/testing` — shop-PC test runs | `testingSoftware` |
| Create shop / access code at onboarding | `POST /api/admin/clients/{clientId}/sites`, plus whatever issues the shop-PC access code. Today `POST /api/admin/clients` creates the merchant + owner login only. | — |
#### Footfall and Commerce — response fields the console needs
**Why the frontend cannot do this itself:** the only admin data is the merchant
list. Tenant routes (`/api/visits`, `/api/purchases`, `/api/sites`) scope by the
signed-in account's `client_id`, which a platform admin does not have, and the
BFF refuses them for admin sessions. Calling them with a swapped id, or summing
every merchant in the browser, would move the tenancy check out of the server.
| Required backend endpoint | Required response fields |
|---|---|
| `GET /api/admin/clients/{clientId}/sites` | `site_id, slug, name, area` (or `location`), `online, cameras_total, cameras_up, created_at` — **`area` does not exist on any site today** and is needed for the Area filter and area comparison |
| `GET /api/admin/clients/{clientId}/footfall?from=&to=&area=&site=` | `[{date, site_id, area, visits}]` — one row per site per day, so area × date and day-by-day are derived without guessing |
| `GET /api/admin/footfall/summary?from=&to=` (optional, platform scope) | `[{client_id, visits}]` — server-side sum, so the browser never fetches every tenant's rows |
| `GET /api/admin/clients/{clientId}/sales?from=&to=&site=` | `[{date, site_id, sales_inr, transactions}]` |
| `GET /api/admin/sales/summary?from=&to=` | `[{client_id, sales_inr, transactions}]` for the all-merchants comparison |
| `GET /api/admin/clients/{clientId}/sites/{siteId}/transactions?from=&to=&cursor=` | `[{id, at, amount_inr, payment_method?, category?}]` — only what the platform records |
#### Footfall and Commerce — the exact contract the console calls (added 2026-09-25)
The Footfall and Commerce pages are fully built against these paths
(`features/admin/repositories/analyticsRepository.ts`, shapes in
`features/admin/types/analytics.ts`). Each panel renders "Backend integration
required" until the flag is on; turning it on is the only frontend change.
Common query parameters on every call: `from`, `to` (ISO `YYYY-MM-DD`,
inclusive), and optionally `merchant` (client id), `area`, `shop` (site id).
`area`/`shop` are only ever sent together with `merchant`; the server must
refuse a shop that is not that merchant's (§2.2) and answer 404 to non-admins.
| Endpoint | Returns | Flag |
|---|---|---|
| `GET /api/admin/footfall/overview` | `{totalFootfall, averageDaily, peakDay?: {date, footfall}, activeLocations, reportingShops}` | `footfall` |
| `GET /api/admin/footfall/by-area` | `[{area, footfall}]` | `footfall` |
| `GET /api/admin/footfall/daily` | `[{date, footfall}]` | `footfall` |
| `GET /api/admin/footfall/area-date` | `[{date, area, footfall}]` (long form; the console pivots) | `footfall` |
| `GET /api/admin/footfall/details` | `[{date, merchantId, merchantName, area?, shopId, shopName, footfall, entries?, exits?}]` | `footfall` |
| `GET /api/admin/commerce/overview` | `{salesInr, transactions, averageTransactionInr, activeMerchants, activeShops}` | `commerce` |
| `GET /api/admin/commerce/by-merchant` | `[{merchantId, merchantName, salesInr, transactions}]` | `commerce` |
| `GET /api/admin/commerce/daily` | `[{date, salesInr, transactions}]` | `commerce` |
| `GET /api/admin/commerce/details` | `[{date, merchantId, merchantName, shopId, shopName, salesInr, transactions, paymentMethod?, category?}]` | `commerce` |
The BFF routes (`src/app/api/admin/footfall/*`, `src/app/api/admin/commerce/*`)
must be added alongside, enveloping the response like `/api/admin/clients`.
Area also needs a real field on sites (`area` or `location`) — it exists
nowhere today, so the Area filter stays disabled until it does.
## 4. Frontend wiring per endpoint
Everything else is already built: the pages, breadcrumbs, empty, loading and
error states, and the tables that render the rows.
For each endpoint:
1. **Upstream call.** Add a method to `src/services/api/adminApi.ts`.
2. **BFF route.** Add `src/app/api/admin/clients/[id]/sites/…/route.ts`, the
same pattern as `clients/[id]/route.ts` (`serveUpstream` + a mapper).
3. **Mapper.** Wire → `AdminStore` / `AdminCamera` / `AdminEvent` / `AdminAlert`
(`src/features/admin/types/monitoring.ts`). `cameras_total` maps to
`cameras`, `cameras_up` to `camerasOnline`, `connected` to `status`.
4. **Flag.** Set the level to `true` in `src/features/admin/config/capabilities.ts`.
With the flag on, the repository (`features/admin/repositories/monitoringRepository.ts`)
returns its endpoint instead of `null`. The section then fetches and renders
the table in place of the integration-required state, and the Overview's
"Monitoring coverage" panel switches that row to **Live**. No component
changes.
---
## 5. Acceptance checks for the platform team
- [ ] A tenant token on any `/api/admin/*` route gets 404.
- [ ] `GET /clients/A/sites` never returns a site whose `client_id ≠ A`.
- [ ] `GET /clients/A/sites/B` where B belongs to company C gets 404.
- [ ] `GET /clients/A/sites/B/cameras/X` where X belongs to another site gets 404.
- [ ] `?camera=X` on events/alerts, with X from another site, returns an empty list.
- [ ] Camera rows contain no host, port, path, username or password flag.
- [ ] Every admin read below the company level writes an audit entry.

View File

@@ -1,6 +1,9 @@
# Behavision API ↔ Loyaly Merchant OS — gap analysis
Audit date: 2026-09-09 · API: `https://platform.loyaly.ai` · Frontend: this repo
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`.)
---

View File

@@ -10,6 +10,27 @@ const nextConfig: NextConfig = {
// local `npm run build` keeps its warm cache.
turbopackFileSystemCacheForBuild: process.env.CI_BUILD !== "1",
},
// The platform console renamed Companies → Merchants and Stores → Shops.
// Temporary (307) so bookmarks keep working without browsers caching the
// move forever while the admin IA is still settling.
async redirects() {
return [
{
source: "/admin/companies/:id/stores/:shopId/:rest*",
destination: "/admin/merchants/:id/shops/:shopId/:rest*",
permanent: false,
},
{
source: "/admin/companies/:rest*",
destination: "/admin/merchants/:rest*",
permanent: false,
},
{source: "/admin/stores", destination: "/admin/merchants", permanent: false},
// The overview IS /admin; these are the names people guess for it.
{source: "/admin/overview", destination: "/admin", permanent: false},
{source: "/admin/dashboard", destination: "/admin", permanent: false},
];
},
allowedDevOrigins: ["192.168.0.117", "192.168.0.*", "192.168.1.*", "localhost", "127.0.0.1"],
images: {
remotePatterns: [

View File

@@ -11,7 +11,8 @@
"bundle": "bash scripts/bundle.sh",
"theme:build": "astryx theme build src/theme/loyalyTheme.ts",
"typecheck": "tsc --noEmit",
"dev:preview": "next dev"
"dev:preview": "next dev",
"test:access": "node --test scripts/staff-access.test.mts"
},
"dependencies": {
"@astryxdesign/core": "^0.2.0",

View File

@@ -0,0 +1,95 @@
/**
* The staff access policy the proxy enforces (src/features/auth/services/
* staffAccess.ts). Run with `npm run test:access` — Node's built-in runner, no
* dependencies. Covers the role matrix only; tenant isolation is the
* platform's (the company comes from the upstream token, never the request).
*/
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {
STAFF_HOME,
isStaffPage,
isStaffRole,
staffApiDecision,
} from '../src/features/auth/services/staffAccess.ts';
const q = (o: Record<string, string> = {}) => new URLSearchParams(o);
const one = q({storeId: 'chennai', range: '30d'});
test('only role=staff is gated', () => {
assert.equal(isStaffRole('staff'), true);
for (const r of ['owner', 'manager', 'admin', undefined, '', 'STAFF']) {
assert.equal(isStaffRole(r), false, String(r));
}
});
test('staff home is a staff page', () => {
assert.equal(isStaffPage(STAFF_HOME), true);
});
test('staff pages: floor work allowed, merchant pages refused', () => {
for (const p of ['/floor', '/customers', '/customers/abc', '/activity', '/settings/profile', '/settings/security']) {
assert.equal(isStaffPage(p), true, p);
}
for (const p of ['/dashboard', '/commerce', '/stores', '/lyts', '/staff', '/settings', '/settings/team', '/settings/billing', '/settings/roles', '/settings/stores', '/admin', '/floorplan', '/customersX']) {
assert.equal(isStaffPage(p), false, p);
}
});
test('staff APIs: floor work allowed with one store', () => {
const allow: [string, string, URLSearchParams][] = [
['GET', '/api/sites', q()],
['GET', '/api/floor/visits', one],
['POST', '/api/visits/v1/attend', q()],
['POST', '/api/visits/v1/release', q()],
['POST', '/api/visits/v1/complete', q()],
['GET', '/api/visits', one],
['GET', '/api/visits/stream', q({storeId: 'chennai'})],
['POST', '/api/customers', q()],
['GET', '/api/visitors', q()],
['GET', '/api/visitors/x/history', q()],
['GET', '/api/visitors/x/image', q()],
['PUT', '/api/visitors/x/profile', q()],
['GET', '/api/faces', q({src: '/api/faces/a'})],
['POST', '/api/sales', q()],
['POST', '/api/purchases', q()],
['GET', '/api/health', q()],
];
for (const [m, p, s] of allow) assert.equal(staffApiDecision(m, p, s), 'allow', `${m} ${p}`);
});
test('staff APIs: "All stores" or no store is refused on scoped reads', () => {
for (const p of ['/api/floor/visits', '/api/visits', '/api/visits/stream']) {
assert.equal(staffApiDecision('GET', p, q({storeId: 'all'})), 'needs_store', p);
assert.equal(staffApiDecision('GET', p, q()), 'needs_store', p);
}
});
test('staff APIs: merchant-only surfaces are forbidden', () => {
const deny: [string, string][] = [
['GET', '/api/reports/footfall'],
['GET', '/api/reports/conversion'],
['GET', '/api/reports/journey'],
['GET', '/api/dashboard/summary'],
['GET', '/api/sales'],
['GET', '/api/sales/abc'],
['GET', '/api/team'],
['GET', '/api/team/invitations'],
['GET', '/api/cameras'],
['GET', '/api/cameras/c1/live'],
['GET', '/api/images'],
['GET', '/api/campaigns'],
['GET', '/api/activities'],
['POST', '/api/assistant'],
['POST', '/api/sites'],
['PATCH', '/api/sites/chennai'],
['DELETE', '/api/sites/chennai'],
['DELETE', '/api/visitors/x'],
['GET', '/api/admin/clients'],
['GET', '/api/unknown-new-route'],
// Method matters: an allowed path with the wrong verb is refused.
['DELETE', '/api/sales'],
['POST', '/api/floor/visits'],
];
for (const [m, p] of deny) assert.equal(staffApiDecision(m, p, one), 'forbidden', `${m} ${p}`);
});

View File

@@ -0,0 +1,10 @@
import type {Metadata} from 'next';
import {PlatformCommerce} from '@/features/admin/components/PlatformPages';
export const metadata: Metadata = {
title: 'Commerce',
};
export default function PlatformCommercePage() {
return <PlatformCommerce />;
}

View File

@@ -0,0 +1,10 @@
import type {Metadata} from 'next';
import {PlatformFootfall} from '@/features/admin/components/PlatformPages';
export const metadata: Metadata = {
title: 'Footfall',
};
export default function PlatformFootfallPage() {
return <PlatformFootfall />;
}

View File

@@ -0,0 +1,15 @@
import type {Metadata} from 'next';
import {CompanyDetail} from '@/features/admin/components/CompanyDetail';
export const metadata: Metadata = {
title: 'Merchant',
};
export default async function MerchantPage({
params,
}: {
params: Promise<{merchantId: string}>;
}) {
const {merchantId} = await params;
return <CompanyDetail companyId={merchantId} />;
}

View File

@@ -0,0 +1,17 @@
import type {Metadata} from 'next';
import {CameraDetail} from '@/features/admin/components/CameraDetail';
export const metadata: Metadata = {
title: 'Camera',
};
export default async function CameraPage({
params,
}: {
params: Promise<{merchantId: string; shopId: string; cameraId: string}>;
}) {
const {merchantId, shopId, cameraId} = await params;
return (
<CameraDetail companyId={merchantId} storeId={shopId} cameraId={cameraId} />
);
}

View File

@@ -0,0 +1,15 @@
import type {Metadata} from 'next';
import {StoreDetail} from '@/features/admin/components/StoreDetail';
export const metadata: Metadata = {
title: 'Shop',
};
export default async function ShopPage({
params,
}: {
params: Promise<{merchantId: string; shopId: string}>;
}) {
const {merchantId, shopId} = await params;
return <StoreDetail companyId={merchantId} storeId={shopId} />;
}

View File

@@ -0,0 +1,25 @@
import type {Metadata} from 'next';
import {VStack} from '@astryxdesign/core/Layout';
import {MerchantsPanel} from '@/features/admin/components/MerchantsPanel';
import {AdminPageHeader} from '@/features/admin/components/common/AdminPageHeader';
export const metadata: Metadata = {
title: 'Merchants',
};
/**
* Every merchant on the platform as a card: search, filter, sort, create,
* suspend, reinstate, reset the owner's password, delete — and the way into
* each one.
*/
export default function MerchantsPage() {
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Merchants"
subtitle="Create, suspend and remove the merchants on this platform."
/>
<MerchantsPanel />
</VStack>
);
}

View File

@@ -0,0 +1,19 @@
import type {Metadata} from 'next';
import {PlatformOverview} from '@/features/admin/components/PlatformOverview';
export const metadata: Metadata = {
title: 'Overview',
};
/**
* The platform console's landing page — where a platform admin arrives after
* sign-in (the proxy sends them to /admin).
*
* Merchant CRUD lives at /admin/merchants; this page is the overview above it.
* What the console can and cannot read today is recorded in
* features/admin/config/capabilities.ts, and the endpoints still needed in
* docs/ADMIN-MONITORING-API.md.
*/
export default function AdminOverviewPage() {
return <PlatformOverview />;
}

View File

@@ -0,0 +1,10 @@
import type {Metadata} from 'next';
import {AdminProfile} from '@/features/admin/components/AdminAccount';
export const metadata: Metadata = {
title: 'Profile',
};
export default function AdminProfilePage() {
return <AdminProfile />;
}

View File

@@ -0,0 +1,10 @@
import type {Metadata} from 'next';
import {AdminSettings} from '@/features/admin/components/AdminAccount';
export const metadata: Metadata = {
title: 'Settings',
};
export default function AdminSettingsPage() {
return <AdminSettings />;
}

View File

@@ -0,0 +1,23 @@
import {AdminLayout} from '@/features/admin/components/AdminLayout';
/**
* The route-group boundary for the platform console.
*
* Deliberately NOT `(workspace)`. That group wraps everything in
* `ProtectedLayout` → `WorkspaceShell`: a site switcher, tenant navigation and
* the Loyaly AI rail, every one of which is scoped to a company. A platform
* operator has no company, so that shell would render a store picker with
* nothing in it above a page about somebody else's stores.
*
* What the two groups DO share is the session: `AdminLayout` uses the same
* `AuthGuard` as the workspace, reading the same cookie minted by the same
* login route. There is one authentication system in this app, and this is not
* a second one.
*/
export default function AdminRouteLayout({
children,
}: {
children: React.ReactNode;
}) {
return <AdminLayout>{children}</AdminLayout>;
}

View File

@@ -0,0 +1,23 @@
import type {Metadata} from 'next';
import {LoginSplit} from '@/features/auth/components/LoginSplit';
import {JoinForm} from '@/features/auth/components/JoinForm';
export const metadata: Metadata = {
title: 'Join your team',
};
/**
* Where an invitation code is redeemed. Public — see PUBLIC_PATHS in proxy.ts —
* because the person here has no account yet; that is the point of the page.
* Sits in the (public) group with /login and shares its frame.
*/
export default function JoinPage() {
return (
<LoginSplit
title="Join your team"
description="Enter the invitation code your manager gave you, then choose your own password. Nobody else ever sees it."
>
<JoinForm />
</LoginSplit>
);
}

View File

@@ -6,12 +6,13 @@ export const metadata: Metadata = {
};
/**
* Sits in the (public) group on purpose: no shell, no nav, no store scope, and
* a GuestGuard instead of an AuthGuard — see PublicLayout.
* Sits in the (public) group on purpose: no shell, no nav, no store scope and
* no auth guard — see PublicLayout.
*
* Reaching this page with a live session is already impossible via a fresh
* request (src/proxy.ts redirects it to /dashboard); the guard covers the
* client-side navigation the proxy never sees.
* This page renders whenever it is asked for, with or without a live session in
* this browser. Sessions are per tab, so "somebody is signed in here" is not a
* reason to refuse the sign-in form to a tab that has no session of its own —
* and a page that always renders cannot take part in a redirect cycle.
*/
export default function LoginPage() {
return <LoginSplit />;

View File

@@ -5,6 +5,7 @@ import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
import {useRecentVisits} from '@/features/dashboard/hooks/useReports';
import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
/**
@@ -14,14 +15,23 @@ import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
* size — one feed implementation, two budgets. The cursor the platform returns
* is the supported way to page further; the dashboard never needs it, so it is
* wired here first when infinite scroll lands.
*
* New arrivals are pushed over GET /api/visits/stream and each one re-reads
* the feed; "Live" shows only while that stream is actually connected.
*/
export default function ActivityPage() {
const visits = useRecentVisits(50);
const {isLive} = useArrivalStream(
visits.refetch,
// Stale rows from the previous store are not a position to resume from.
visits.isRefreshing ? undefined : visits.data?.cursor,
);
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
<PageHeader
eyebrow={isLive ? 'Live' : undefined}
title="Activity"
description={`Every recognised arrival across ${scopeLabel}, newest first.`}
controls={<ScopeControls />}

View File

@@ -0,0 +1,122 @@
'use client';
import {useState} from 'react';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {List, ListItem} from '@astryxdesign/core/List';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {useSales} from '@/features/commerce/hooks/useSales';
import {SaleDetailDialog} from '@/features/commerce/components/SaleDetailDialog';
import {formatPaise} from '@/features/commerce/services/money';
import {CHART} from '@/shared/components/charts/palette';
import {useConversionReport} from '@/features/dashboard/hooks/useReports';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {formatInrCompact} from '@/shared/utils/format';
/**
* Sales.
*
* ── What changed and why ─────────────────────────────────────────────────
* This page previously rendered eight panels — product leaderboards, payment
* method splits, stock levels, refund rates, hourly targets — every number of
* which came from a hardcoded service imported synchronously by ten
* components. None of it had an API, a loading state, or a way to become real.
*
* The platform reports revenue and basket size through the conversion report,
* and nothing else on this page. So the page now shows the part that is real
* and names the resources the rest is waiting for, rather than presenting
* invented inventory as though a merchant could act on it.
*/
export default function CommercePage() {
const [openSale, setOpenSale] = useState<string | null>(null);
const conversion = useConversionReport({bucket: 'day'});
const sales = useSales();
const scopeLabel = useScopeLabel();
return (
<VStack gap={5}>
<PageHeader
eyebrow="Sales & revenue"
title="Sales"
description={`Revenue and conversion across ${scopeLabel}.`}
controls={<ScopeControls />}
/>
<ChartCard
title="Revenue"
subtitle="Daily takings, from the conversion report"
resource={conversion}
>
{(report) => (
<BarChartView
data={report.buckets}
xKey="label"
yFormat={formatInrCompact}
series={[
{key: 'revenue', label: 'Revenue', color: CHART.brand.warmBar},
]}
/>
)}
</ChartCard>
<PanelCard
title="Recent sales"
subtitle="Every sale recorded against a visit"
resource={sales}
loading={<SkeletonRows count={5} />}
empty={
<EmptyPanel
icon="commerce"
title="No sales recorded yet"
description="Sales appear here as staff record them in the merchant app."
/>
}
>
{/*
List/Item rather than Table: these rows open a detail view, and
Astryx's Table has no per-row action or custom cell renderer. Both
are approved dense-data patterns — this is the one that can be
clicked, so it is the one that fits.
*/}
{(rows) => (
<List density="balanced">
{rows.map((sale) => (
<ListItem
key={sale.id}
onClick={() => setOpenSale(sale.id)}
label={sale.customerLabel ?? sale.customerRef ?? 'Not identified'}
description={[
sale.invoiceNo,
sale.staffName ? `Served by ${sale.staffName}` : null,
`${sale.purchasedLines} purchased`,
// Only mentioned when there were any: "0 enquiries" on
// every row is noise that hides the ones that had some.
sale.enquiryLines > 0 ? `${sale.enquiryLines} enquiries` : null,
]
.filter(Boolean)
.join(' · ')}
endContent={formatPaise(sale.totalPaise)}
/>
))}
</List>
)}
</PanelCard>
{openSale ? (
<SaleDetailDialog saleId={openSale} onClose={() => setOpenSale(null)} />
) : null}
<FeatureUnavailable
title="Products, payments and refunds"
description="Individual sales are listed above. What is still not recorded anywhere is the product catalogue and stock, how customers paid, and refunds — so those sections stay empty rather than being filled with sample data."
/>
</VStack>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory';
/**
* The customer directory, from GET /api/visitors.
*
* Company-wide, not scoped by the store switcher: a customer belongs to the
* business, and somebody who first walked into one branch is the same person
* at another.
*/
export default function CustomersPage() {
return (
<VStack gap={5}>
<PageHeader
title="Customers"
description="Everyone the cameras have recognised — name them, see their visits, record a purchase."
/>
<CustomerDirectory />
</VStack>
);
}

View File

@@ -9,7 +9,7 @@ import {AreaChartView} from '@/shared/components/charts/AreaChartView';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {KpiRow} from '@/features/dashboard/components/KpiRow';
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {EngagementSection} from '@/features/engagement/components/EngagementSection';
import {CHART} from '@/shared/components/charts/palette';
import {
useConversionReport,
@@ -17,6 +17,7 @@ import {
useFootfallReport,
useRecentVisits,
} from '@/features/dashboard/hooks/useDashboard';
import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {greetingFor} from '@/features/dashboard/services/dashboardService';
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
@@ -30,23 +31,27 @@ import {formatCompact, formatInrCompact} from '@/shared/utils/format';
* endpoint, and no fixture: if the platform returns nothing, this page shows
* nothing rather than something plausible.
*
* ── What was removed and why ─────────────────────────────────────────────
* The engagement layer that used to sit here — ten activity types, impact
* chains, campaign funnels, a customer journey, generated insights — was built
* against a loyalty domain the platform does not expose. It rendered numbers
* with no source. Rather than keep them behind a demo flag where a merchant
* could mistake them for real, the panels are replaced by a statement of what
* they need. The layout, spacing and hierarchy are otherwise untouched.
* ── Engagement ───────────────────────────────────────────────────────────
* The engagement layer that used to sit here was generated locally and was
* removed. It is back, read from the platform's own endpoints — activities and
* their impact, campaigns, and the customer journey — so every figure has a
* source, and every attributed one says it is estimated.
*
* Bucket labels from the reports are rendered as STRINGS. They are local wall
* time with no offset; parsing one into a Date re-interprets it in the
* viewer's zone and shifts every label on the axis.
*/
export default function DashboardPage() {
const kpis = useDashboardKpis();
const footfall = useFootfallReport({bucket: 'day'});
const conversion = useConversionReport({bucket: 'day'});
// One fetch per report, shared by the KPI row and the charts.
const footfall = useFootfallReport({bucket: 'day', compare: true});
const conversion = useConversionReport({bucket: 'day', compare: true});
const kpis = useDashboardKpis(footfall, conversion);
const visits = useRecentVisits(6);
// Pushes new arrivals into the recent-arrivals panel as they happen.
useArrivalStream(
visits.refetch,
visits.isRefreshing ? undefined : visits.data?.cursor,
);
const scopeLabel = useScopeLabel();
return (
@@ -119,10 +124,7 @@ export default function DashboardPage() {
<ArrivalsFeed resource={visits} viewAllHref="/activity" />
<FeatureUnavailable
title="Customer activity and engagement"
description="Selfies, spins, scratch cards, challenges, referrals and events are not being recorded by any till or app yet, so there is nothing to measure their effect on repeat visits or revenue. As soon as they are, this section fills in on its own — no number here is estimated."
/>
<EngagementSection />
</VStack>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Card} from '@astryxdesign/core/Card';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {NameCustomerDialog} from '@/features/floor/components/NameCustomerDialog';
import {SaleEntryDialog} from '@/features/commerce/components/SaleEntryDialog';
import {useFloor, type FloorAction} from '@/features/floor/hooks/useFloor';
import {useTodaySummary} from '@/features/floor/hooks/useTodaySummary';
import {TodaySummaryCard} from '@/features/floor/components/TodaySummaryCard';
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {isStaffRole} from '@/features/auth/services/staffAccess';
import type {FloorVisit} from '@/features/floor/types/floor';
/**
* The shop floor — who is here, and who is serving them. LOYALY.md §6/§7/§20.
*
* Every row is a visit the CAMERA created. This screen never invents an
* arrival, and it never decides ownership: a Take that loses a race comes back
* 409 from the platform and the list is re-read, because who holds a customer
* is a fact only the server has.
*/
function whenSeen(iso: string): string {
const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`;
return `${Math.floor(mins / 60)} h ago`;
}
export default function FloorPage() {
const {resource, act, pending, conflict, failure} = useFloor();
// Today's summary carries revenue — merchant-only (staffAccess.ts).
const isStaff = isStaffRole(useSession().user?.role);
const today = useTodaySummary(!isStaff);
// Taking, releasing or completing a customer changes "on the floor now".
const run = (visitId: string, action: FloorAction) =>
void act(visitId, action).then(today.refetch);
const scopeLabel = useScopeLabel();
const [naming, setNaming] = useState<FloorVisit | null>(null);
const [selling, setSelling] = useState<FloorVisit | null>(null);
return (
<VStack gap={5}>
<PageHeader
eyebrow="Live"
title="Floor"
description={`Customers in ${scopeLabel} right now.`}
controls={<ScopeControls />}
/>
{isStaff ? null : <TodaySummaryCard resource={today} />}
{/* The platform's own refusal, shown verbatim — it names who holds the
customer, which is the part staff need. */}
{conflict ? <Banner status="warning" title={conflict.message} /> : null}
{failure ? <Banner status="error" title={failure.message} /> : null}
<AsyncBoundary
resource={resource}
loading={<SkeletonCardGrid count={3} height={190} />}
empty={
<EmptyPanel
icon="visitors"
title="Nobody on the floor"
description="Customers appear here the moment a camera sees them."
/>
}
>
{(rows) => (
<Grid columns={{minWidth: 300, repeat: 'fit'}} gap={4}>
{rows.map((v) => {
const unknown = v.visitorId === null;
const heldByOther = v.attendedBy !== null && !v.attendedByMe;
return (
<Card key={v.visitId}>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>
{v.label ?? 'Unrecognised customer'}
</Heading>
{v.customerRef ? (
<Text size="xsm" color="secondary" className="font-mono">
{v.customerRef}
</Text>
) : null}
</HStack>
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={v.status === 'attending' ? 'warning' : 'success'}
label={v.status}
/>
<Text size="xsm" color="secondary">
{v.status === 'attending' && v.attendedByName
? `With ${v.attendedByName}`
: 'Waiting'}
{' · '}
{whenSeen(v.detectedAt)}
</Text>
</HStack>
{/* Real profile data only. An unrecognised arrival says so
and offers the form; it never shows a placeholder name. */}
{unknown ? (
<Text size="sm" color="secondary">
The cameras have not seen this person before.
</Text>
) : (
<Text size="sm" color="secondary">
{v.previousVisits === 0
? 'First visit'
: `${v.previousVisits} previous ${
v.previousVisits === 1 ? 'visit' : 'visits'
}`}
{v.phone ? ` · ${v.phone}` : ''}
</Text>
)}
<HStack gap={2}>
{v.attendedByMe ? (
<>
<Button
variant="secondary"
isDisabled={pending === v.visitId}
onClick={() => run(v.visitId, 'release')}
label="Release"
/>
<Button
isDisabled={pending === v.visitId}
onClick={() => run(v.visitId, 'complete')}
label="Complete"
/>
</>
) : (
<Button
// Not hidden when somebody else holds them: pressing
// it returns the platform's own refusal naming who,
// which is more useful than a control that vanishes.
variant={heldByOther ? 'secondary' : 'primary'}
isDisabled={pending === v.visitId}
onClick={() => run(v.visitId, 'attend')}
label={heldByOther ? 'Taken' : 'Take'}
/>
)}
{/* Sale entry is offered only to whoever holds the
customer — recording a sale against somebody else's
customer would attribute it to the wrong person. */}
{v.attendedByMe ? (
<Button
variant="ghost"
onClick={() => setSelling(v)}
label="Record sale"
/>
) : null}
{unknown ? (
<Button
variant="ghost"
onClick={() => setNaming(v)}
label="Add customer"
/>
) : null}
</HStack>
</VStack>
</Card>
);
})}
</Grid>
)}
</AsyncBoundary>
{/* The visit is NOT completed automatically after a sale. LOYALY.md §9
says a visit is completed when the merchant finishes the interaction,
which is not the same moment as recording a sale — a customer often
buys and then keeps browsing. Completing here would clear them off
the floor while they are still standing in the shop. */}
{selling ? (
<SaleEntryDialog
visit={selling}
onClose={() => setSelling(null)}
onSaved={() => {
setSelling(null);
resource.refetch();
today.refetch();
}}
/>
) : null}
{naming ? (
<NameCustomerDialog
visit={naming}
onClose={() => setNaming(null)}
onSaved={() => {
setNaming(null);
resource.refetch();
}}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
/**
* LYTs.
*
* The merchant-app specification (§2.1) states the product direction does NOT
* use loyalty points, LYT balances, redemption or tier calculation — so this
* is not a panel waiting on an endpoint, it is a feature the product dropped.
* The copy says that, rather than implying a reward catalogue is on its way.
*
* Every figure this page used to show was generated locally, including an
* "outstanding liability" in rupees that a merchant would reasonably read as
* money they owe. The route is kept so an existing bookmark still lands
* somewhere that explains itself. Nothing is simulated.
*/
export default function LytsPage() {
return (
<VStack gap={5}>
<PageHeader
title="Lyts"
description="Loyalty rewards are not part of the current product."
/>
<FeatureUnavailable
title="The LYT programme"
description="Loyalty points are not part of the current product. The merchant app records visits and sales, not point balances, redemptions or tiers — so there is no LYT liability to report here. This page previously showed generated figures, including an outstanding balance in rupees that a merchant could not tell from real money."
/>
</VStack>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ApiWebhooksManager} from '@/features/settings/components/ApiWebhooksManager';
export default function ApiSettingsPage() {
return (
<SettingsPage
title="API & Webhooks"
description="Developer credentials, secret signing tokens, webhook subscriptions and dispatch audit logs."
>
<ApiWebhooksManager />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {BillingOverview} from '@/features/settings/components/BillingOverview';
export default function SettingsBillingPage() {
return (
<SettingsPage
title="Billing & LYT Settlement"
description="Subscription plans, quota consumption, settlement bank accounts and invoice history."
>
<BillingOverview />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {IntegrationsGrid} from '@/features/settings/components/IntegrationsGrid';
export default function IntegrationsSettingsPage() {
return (
<SettingsPage
title="Integrations & Connectors"
description="E-commerce POS sync, payment gateways, WhatsApp marketing and ad channels."
>
<IntegrationsGrid />
</SettingsPage>
);
}

View File

@@ -13,6 +13,7 @@ import {Icon} from '@astryxdesign/core/Icon';
import {useRouter} from 'next/navigation';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/config/settingsNav';
import {useRoleFilter} from '@/shared/layouts/workspace/useRoleNav';
/**
* Settings gets its own sub-navigation.
@@ -36,13 +37,14 @@ export default function SettingsLayout({
}) {
const pathname = usePathname();
const router = useRouter();
const settingsNav = useRoleFilter(SETTINGS_NAV);
const bp = useBreakpoint();
const isNarrow = bp === 'mobile' || bp === 'tablet';
if (isNarrow) {
const active =
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href)) ??
SETTINGS_NAV[0];
settingsNav.find((s) => isSettingsActive(pathname, s.href)) ??
settingsNav[0];
// A dropdown, not a TabList.
//
@@ -64,7 +66,7 @@ export default function SettingsLayout({
icon: <Icon icon={active.icon} size="sm" />,
}}
menuWidth={260}
items={SETTINGS_NAV.map((s) => ({
items={settingsNav.map((s) => ({
label: s.label,
icon: s.icon,
onClick: () => router.push(s.href),
@@ -98,7 +100,7 @@ export default function SettingsLayout({
*/}
<SideNav className="w-full">
<SideNavSection title="Settings">
{SETTINGS_NAV.map((s) => (
{settingsNav.map((s) => (
<SideNavItem
key={s.href}
label={s.label}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {NotificationsForm} from '@/features/settings/components/NotificationsForm';
export default function NotificationsSettingsPage() {
return (
<SettingsPage
title="Notifications"
description="Delivery channels, instant alerts, weekly digest dispatches and trigger criteria."
>
<NotificationsForm />
</SettingsPage>
);
}

View File

@@ -1,15 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {AccountCard} from '@/features/settings/components/AccountCard';
import {SecurityManager} from '@/features/settings/components/SecurityManager';
import {VStack} from '@astryxdesign/core/Layout';
import {BusinessForm} from '@/features/settings/components/BusinessForm';
export default function AccountSettingsPage() {
export default function BusinessSettingsPage() {
return (
<SettingsPage title="Account" description="Who you are signed in as, and where.">
<VStack gap={5}>
<AccountCard />
<SecurityManager />
</VStack>
<SettingsPage
title="Business Settings"
description="Company profile, GSTIN registration, registered address and LYT earn defaults."
>
<BusinessForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {PreferencesForm} from '@/features/settings/components/PreferencesForm';
export default function PreferencesSettingsPage() {
return (
<SettingsPage
title="Workspace Preferences"
description="Theme customization, reporting currency, localized language and default landing views."
>
<PreferencesForm />
</SettingsPage>
);
}

View File

@@ -0,0 +1,33 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {ProfileForm} from '@/features/settings/components/ProfileForm';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
/**
* Server Component: the record is read on the server and handed to the form as
* its initial state, so the inputs paint filled rather than flashing empty.
* The form then saves through the client repository over HTTP.
*
* The read goes through a repository rather than the fixture module the page
* used to import — a page that knows the shape of a mock is a page that breaks
* the day the mock is deleted.
*/
export default async function MerchantProfilePage() {
const profile = await settingsServerRepository.getProfile();
return (
<SettingsPage
title="Personal Profile"
description="Your user credentials, contact details, account email and timezone preference."
>
{profile ? (
<ProfileForm initialData={profile} />
) : (
<FeatureUnavailable
title="Business profile"
description="Your sign-in details are shown above. The wider company record — business name, GSTIN, registered address, timezone and currency — is not editable here yet, so this section is left out rather than offering a form that would not save."
/>
)}
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {RoleMatrix} from '@/features/settings/components/RoleMatrix';
export default function RolesSettingsPage() {
return (
<SettingsPage
title="Roles & Permissions"
description="Enterprise role definition, module permission matrix and access control boundaries."
>
<RoleMatrix />
</SettingsPage>
);
}

View File

@@ -0,0 +1,17 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {AccountCard} from '@/features/settings/components/AccountCard';
import {SecurityManager} from '@/features/settings/components/SecurityManager';
export default function SecuritySettingsPage() {
return (
<SettingsPage
title="Security & Audit Logs"
description="Two-Factor authentication, password management, active login sessions and security audit history."
>
{/* Who you are, then the devices signed in as you — both read from the
platform. The panels below them are still local-only. */}
<AccountCard />
<SecurityManager />
</SettingsPage>
);
}

View File

@@ -0,0 +1,13 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
import {StoreManagement} from '@/features/settings/components/StoreManagement';
export default function StoreSettingsPage() {
return (
<SettingsPage
title="Store Locations"
description="Open, rename and remove the shops in your company."
>
<StoreManagement />
</SettingsPage>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
import {TeamTable} from '@/features/team/components/TeamTable';
import {useTeam} from '@/features/team/hooks/useTeam';
/**
* Leaderboard.
*
* ── An important distinction ─────────────────────────────────────────────
* The platform's `/api/team` is who can SIGN IN to the console, at what
* privilege. It is not shop-floor rostering: there is no attendance, no shift,
* no sales-per-head and no performance score anywhere in the contract.
*
* The page used to show all of those from a fixture. The real team list is
* shown instead, and the ranking metrics are named as the gap they are —
* because a leaderboard built from invented performance scores is the single
* most damaging fake number in this product.
*/
export default function LeaderboardPage() {
const team = useTeam();
return (
<VStack gap={5}>
<PageHeader
title="Leaderboard"
description="People with access to this console."
/>
<TeamTable resource={team} />
<FeatureUnavailable
title="Attendance and performance ranking"
description="Ranking staff needs shift and attendance records, and sales credited to the person who made them. The platform records who can sign in to this console — not who was on the shop floor, or which sale was theirs. Until the app records that, any ranking here would be guesswork."
/>
</VStack>
);
}

View File

@@ -1,41 +1,46 @@
'use client';
import {VStack} from '@astryxdesign/core/Layout';
import {Divider} from '@astryxdesign/core/Divider';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {useSites} from '@/features/stores/hooks/useSites';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {ShopSection} from '@/features/stores/components/ShopSection';
/**
* The screen somebody opens to find out whether their shops are WORKING.
* Each shop: its health in one line, then its cameras as pictures, with the
* actions that make a shop real - add a camera, set up the PC, prove the
* camera can see a face.
* The estate, from GET /api/sites.
*
* Health fields are nullable and rendered as "—" when the platform does not
* report them. A deployment that sends no camera health is not a deployment
* with zero cameras up, and printing "0/0" for "not reported" makes a working
* estate look broken.
*/
export default function StoresPage() {
const sites = useSites();
const {user} = useSession();
const canManage = user?.role === 'owner' || user?.role === 'manager';
return (
<VStack gap={6}>
<PageHeader title="Stores" description="Every shop, whether it is working, and its cameras." />
<VStack gap={5}>
<PageHeader
title="Store"
description="Every shop, its cameras, and the shop PC that watches them."
/>
<AsyncBoundary
resource={sites}
loading={<SkeletonCardGrid count={2} height={170} />}
empty={<EmptyPanel icon="stores" title="No stores yet" description="Loyaly registers your shops. Contact support to add one." />}
loading={<SkeletonCardGrid count={4} height={170} />}
empty={
<EmptyPanel
icon="stores"
title="No stores yet"
description="Shops appear here once they are registered on the platform."
/>
}
>
{(rows) => (
<VStack gap={8}>
{rows.map((site, i) => (
<VStack key={site.uuid} gap={6}>
{i > 0 && <Divider />}
<ShopSection site={site} canManage={canManage} />
</VStack>
<VStack gap={6}>
{rows.map((site) => (
<ShopSection key={site.id} site={site} />
))}
</VStack>
)}

View File

@@ -0,0 +1,33 @@
import type {NextRequest} from 'next/server';
import {engagementApi} from '@/services/api/engagementApi';
import {resolveVisitorId} from '@/services/api/refs';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
function text(v: unknown): string | undefined {
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
}
/**
* POST /api/activities/events — record that a customer took part. Staff and above.
*
* `sourceEventId` is passed through, never minted here: an id generated per
* REQUEST would make every retry a new event. The platform answers 200
* `{duplicate: true}` for one it already has, which is success — the caller's
* intent is satisfied.
*
* The customer may be given by number ("V-42"); this endpoint upstream takes a
* uuid only, so it is resolved first — see refs.ts.
*/
export async function POST(req: NextRequest) {
return proxyUpstream(req, async (token, body) => {
const customer = text(body.visitorId);
return engagementApi.recordEvent(token, {
kind: text(body.kind) ?? '',
source_event_id: text(body.sourceEventId) ?? '',
site: text(body.site),
visitor_id: customer ? await resolveVisitorId(token, customer) : undefined,
});
});
}

View File

@@ -0,0 +1,18 @@
import type {NextRequest} from 'next/server';
import {engagementApi} from '@/services/api/engagementApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/activities/impact — the impact chain on its own, in the platform's
* shape. The dashboard reads it already joined through GET /api/activities;
* this stays for any caller that wants the chain alone.
*/
export async function GET(req: NextRequest) {
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
return (await engagementApi.impact(token, window, toSiteParam(query.storeId))) ?? [];
});
}

View File

@@ -0,0 +1,34 @@
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {engagementApi} from '@/services/api/engagementApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
import {toActivityRows} from '@/features/engagement/services/mapEngagement';
export const dynamic = 'force-dynamic';
/**
* GET /api/activities — the activity catalogue, each row joined to its impact
* chain (GET /api/activities/impact upstream) for the same window and shop.
*
* Both reads go out together: they are independent, and running them in
* sequence would double the latency of the dashboard panel for no reason.
*/
export async function GET(req: NextRequest) {
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
const site = toSiteParam(query.storeId);
try {
const [activities, impact] = await Promise.all([
engagementApi.activities(token, window, site),
engagementApi.impact(token, window, site),
]);
return toActivityRows(activities, impact);
} catch (err) {
// The deployed platform predates this route: an empty panel, not a red
// error. Any other failure, a real 404 included, still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) return [];
throw err;
}
});
}

View File

@@ -0,0 +1,36 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* POST /api/admin/clients/{id}/owner-password — reset an owner's password.
*
* The support case: the owner has locked themselves out and there is nobody
* above them in the company to reset it. The new password is GENERATED, never
* chosen, and every session that owner held is revoked.
*
* `email` picks the owner when the company has more than one; with exactly one
* it may be omitted, and the UI omits it first. With several owners and no
* address the platform answers 400 listing them — that message travels through
* `failResponse` intact, which is what lets the dialog ask "which owner?"
* without this console needing its own endpoint to enumerate them.
*
* ── The response body is a credential ────────────────────────────────────
* It is shown once and cannot be fetched again. Nothing on this path may cache
* it: `proxyUpstream` sets `cache-control: no-store` on every response it
* writes, which is what keeps it out of a CDN, a browser disk cache and the
* back button. It is never logged here, and never reaches a URL — it travels in
* a POST response body and nowhere else.
*/
export async function POST(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token, body) => {
const email = typeof body.email === 'string' ? body.email.trim() : '';
return adminApi.resetOwnerPassword(token, id, email || undefined);
});
}

View File

@@ -0,0 +1,91 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import {toCompany, toCompanyDetail} from '@/features/admin/services/mapCompany';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id} — one company, with its owner.
*
* Suspended companies still resolve: suspension is exactly when an operator
* opens the page. A non-uuid or unknown id is the platform's 404.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return serveUpstream(req, (token) => adminApi.getClient(token, id), toCompanyDetail);
}
/**
* PATCH /api/admin/clients/{id} — suspend or reinstate a company.
*
* Suspension is complete the moment this returns: the company's users cannot
* sign in, every session they hold is revoked in the same transaction, and
* visits from its shop PCs are dropped at ingest. Reinstating does not restore
* sessions — people sign in again.
*
* The count of revoked sessions is surfaced rather than swallowed. "Suspended"
* alone leaves an operator wondering whether somebody is still signed in on a
* shop PC; "suspended, 3 sessions ended" answers it.
*
* `active` is read strictly as a boolean. A missing or non-boolean value is
* forwarded as-is so the platform's own 400 (`"active" is required: true to
* reinstate, false to suspend`) is what the operator reads, rather than a
* second, differently-worded validation invented here.
*/
export async function PATCH(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(
req,
(token, body) => adminApi.setClientActive(token, id, body.active as boolean),
{
map: (res) => ({
company: toCompany(res.client),
sessionsRevoked: res.sessions_revoked,
}),
},
);
}
/**
* DELETE /api/admin/clients/{id} — permanent, and the data is biometric.
*
* Two conditions, both the PLATFORM's and neither enforced here: the company
* must already be suspended (`409 still_active` otherwise) and the body must
* repeat its slug. The dialog mirrors them so nobody is surprised, but this
* route forwards whatever it is given — an active company is sent and the real
* 409 comes back. A console that pre-empted the check would eventually disagree
* with the server about what is deletable, and the disagreement would surface
* as a delete that "worked" in the UI and did not happen.
*
* Upstream order matters if this fails: face images go from object storage
* first (`502 storage_error` leaves everything else untouched), then the shop
* PCs' broker logins, then every row by cascade.
*
* ── Why `confirm` arrives as a query parameter ───────────────────────────
* The platform wants it in the body, and this route puts it there. It cannot
* arrive that way, though: `proxyUpstream` does not read a body on DELETE (a
* DELETE legitimately has none), and the browser-side `deleteJson` cannot send
* one either. So it travels as a query parameter on THIS origin's request and
* is moved into the body on the way upstream.
*
* Safe to put in a URL, unlike anything else on this screen: a slug is the
* company's public identifier, already visible in the list and in every broker
* topic. It is a confirmation, not a credential — it proves the operator typed
* the right name, and it protects nothing on its own.
*/
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token, _body, query) =>
adminApi.deleteClient(token, id, query.get('confirm') ?? ''),
);
}

View File

@@ -0,0 +1,22 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminCamera} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites/{site}/cameras/{camera} — one camera. The
* platform checks company → shop → camera in one query.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string; site: string; camera: string}>},
) {
const {id, site, camera} = await params;
return serveUpstream(
req,
(token) => adminApi.getSiteCamera(token, id, site, camera),
toAdminCamera,
);
}

View File

@@ -0,0 +1,22 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminCamera} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites/{site}/cameras — the shop's cameras,
* redacted upstream: no host, port, path or credentials reach this console.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string; site: string}>},
) {
const {id, site} = await params;
return serveUpstream(
req,
(token) => adminApi.listSiteCameras(token, id, site),
(rows) => rows.map(toAdminCamera),
);
}

View File

@@ -0,0 +1,24 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminStore} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites/{site} — one shop of one company.
*
* The platform checks the pair: a shop that belongs to another company is a
* 404, never that company's row.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string; site: string}>},
) {
const {id, site} = await params;
return serveUpstream(
req,
(token) => adminApi.getClientSite(token, id, site),
toAdminStore,
);
}

View File

@@ -0,0 +1,22 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAdminStore} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/**
* GET /api/admin/clients/{id}/sites — the company's active shops, with the
* shop PC's liveness and camera counts. Read-only.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return serveUpstream(
req,
(token) => adminApi.listClientSites(token, id),
(rows) => rows.map(toAdminStore),
);
}

View File

@@ -0,0 +1,62 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import {toCompany} from '@/features/admin/services/mapCompany';
import type {ApiNewClientInput} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* The companies on this platform.
*
* ── Why this proxy exists at all ─────────────────────────────────────────
* The browser could not call `mcp.loyaly.ai/api/admin/clients` directly even if
* we wanted it to: the platform sends no CORS headers, so a cross-origin fetch
* from this console is blocked before it leaves. Routing through the BFF is not
* a workaround for that — it is the reason the platform can afford to send no
* CORS headers. The access token stays in an httpOnly cookie this page's
* JavaScript cannot read, so an XSS on this origin cannot steal a platform
* session.
*
* Authorisation is NOT re-implemented here. `withUpstream` attaches whatever
* token the session holds and the platform decides: `adminOnly` answers 404 to
* anyone who is not a platform operator. A merchant who reached this route
* would get that 404 translated into `not_found`, not a list of tenants.
*/
export async function GET(req: NextRequest) {
return serveUpstream(
req,
(token) => adminApi.listClients(token),
(rows) => rows.map(toCompany),
);
}
/**
* POST /api/admin/clients — create a company and its owner, in one transaction.
*
* Answers **201**, and the body carries the owner's generated password. That is
* the only time it exists in readable form: it is bcrypt-hashed on the way in
* and cannot be fetched again.
*
* `password` is never forwarded from the client, even if one were sent. Empty
* means "generate one", which is the better default — an operator typing a
* password for somebody else invents a weak one and then sends it over chat.
* `slug` is forwarded only when non-empty; the platform derives it from the
* name otherwise, and it can never be changed afterwards.
*/
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) => {
const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
const input: ApiNewClientInput = {
company_name: String(body.company_name ?? '').trim(),
owner_email: String(body.owner_email ?? '').trim(),
owner_name: String(body.owner_name ?? '').trim(),
};
if (slug) input.slug = slug;
return adminApi.createClient(token, input);
},
{status: 201},
);
}

View File

@@ -0,0 +1,15 @@
import type {NextRequest} from 'next/server';
import {adminApi} from '@/services/api/adminApi';
import {serveUpstream} from '@/shared/services/bff';
import {toPlatformMonitoring} from '@/features/admin/services/mapMonitoring';
export const dynamic = 'force-dynamic';
/** GET /api/admin/monitoring/summary — estate-wide counts for the Overview. */
export async function GET(req: NextRequest) {
return serveUpstream(
req,
(token) => adminApi.monitoringSummary(token),
toPlatformMonitoring,
);
}

View File

@@ -0,0 +1,42 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {failResponse} from '@/shared/services/bff';
import {fail} from '@/shared/services/apiRoute';
import type {ApiSuccess} from '@/shared/types/api';
import type {InvitationPreview} from '@/features/auth/types/join';
export const dynamic = 'force-dynamic';
/**
* GET /api/auth/invitation?code=… — what an invitation is for. NO session.
*
* Asked before anybody chooses a password, so the join screen can say "Join
* TeNext Retail as Priya R" and a mistyped code is caught before it costs a
* password. Outside the proxy's session gate by its matcher (`api/auth` is
* excluded), which is what lets somebody with no account call it.
*
* Unknown, expired, spent and withdrawn codes are all one 404 upstream, with
* one message, on purpose; it is passed through as it is.
*/
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get('code')?.trim() ?? '';
if (!code) {
return fail('bad_request', 'Enter the invitation code you were given.', 400);
}
try {
const p = await authApi.invitationPreview(code);
const data: InvitationPreview = {
companyName: p.client_name,
email: p.email,
fullName: p.full_name ?? '',
role: p.role,
};
return Response.json(
{data, meta: {generatedAt: new Date().toISOString()}} satisfies ApiSuccess<InvitationPreview>,
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
return failResponse(err);
}
}

View File

@@ -5,19 +5,28 @@ import {UpstreamError} from '@/services/api/apiClient';
import {ConfigError} from '@/shared/errors/configError';
import {
LOGIN_ERROR_PARAM,
NOT_PLATFORM_ADMIN_MESSAGE,
type LoginErrorCode,
} from '@/features/auth/services/loginErrorCodes';
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
import {
REMEMBERED_MAX_AGE_SECONDS,
SESSION_COOKIE,
SESSION_MAX_AGE_SECONDS,
createSessionToken,
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
import {
TAB_POINTER_COOKIE,
sessionCookieFor,
tabPointerOptions,
} from '@/features/auth/services/tabScope';
import {
newTabId,
resolveTabId,
} from '@/features/auth/services/tabScopeRequest';
import {storeTokens} from '@/features/auth/services/upstreamSession';
import {isPlatformAdmin, toAuthUser} from '@/features/auth/services/userMapper';
import {destinationForRole} from '@/features/auth/services/roleDestination';
import {toAuthUser} from '@/features/auth/services/userMapper';
import {destinationForUser} from '@/features/auth/services/roleDestination';
import type {AuthSession} from '@/features/auth/types/auth';
import type {ApiSuccess} from '@/shared/types/api';
@@ -171,50 +180,40 @@ export async function POST(req: NextRequest) {
}
/**
* A platform admin authenticates correctly and still gets no session HERE.
* A platform admin gets a session here, exactly like a merchant does.
*
* `isPlatformAdmin` is role AND empty client_id together, which is the
* pairing the platform documents — checking the role alone would misread a
* tenant-scoped account that happens to carry an admin-shaped role.
* ── What this used to do, and why it no longer does ──────────────────────
* This route used to detect a platform admin, revoke the upstream session it
* had just created, and answer 403 `platform_account`. The reasoning was
* sound at the time: every screen in this console was tenant-scoped, an admin
* has no tenant, and a cookie would have bought that person a dashboard of
* 500s. Refusing the session was the honest answer.
*
* Every endpoint behind this console is tenant-scoped, and an admin has no
* tenant. Measured on the live local platform with a real admin token:
* /api/sites 500, /api/visits 500, /api/visitors 500, /api/team 403 "This
* account does not belong to a company." Minting a cookie here would buy
* that person nothing but a dashboard of server errors, so the session is
* refused at the only place that can refuse it — before the cookie is set.
* There is now somewhere for them to go — /admin, reading the platform's own
* `/api/admin/*` surface, which is the one part of the platform that is NOT
* tenant-scoped. So the refusal is gone, and the ONLY thing that differs for
* an admin is the destination. Nothing about how the session is minted
* changes: same `storeTokens`, same `createSessionToken`, same cookies, same
* lifetimes. There is no second authentication path in this app.
*
* This is not a client-side authorisation check standing in for a server
* one. It runs on the server, it mirrors the platform's own rule rather
* than inventing a second one, and the platform still enforces its own on
* every request regardless of what this route decides.
* ── What is emphatically NOT delegated to the client ─────────────────────
* `isPlatformAdmin` is role AND empty `client_id` together — the pairing the
* platform documents. Checking the role alone would promote a tenant-scoped
* account that happens to carry an admin-shaped role, and that account is an
* ordinary merchant user. The answer is computed here, from a field the
* browser never receives, and signed into the cookie (see userMapper and
* sessionToken), so the client cannot assert it.
*
* The upstream session created moments ago by `authApi.login` is revoked
* rather than abandoned: it is a live refresh token nobody will ever use,
* and leaving it to expire on its own is a credential left lying around.
* Best-effort — a failure to revoke must not turn into a 500 on a sign-in
* that this console was going to decline anyway.
* And it decides ROUTING, never authority. Every admin read this console
* makes is authorised by the platform's own `adminOnly`, which answers 404 to
* anyone who is not a platform operator regardless of what this cookie says.
*
* ── Note what is absent: no `authApi.logout` call ────────────────────────
* Revoking was correct while no session followed — an unused refresh token is
* a credential left lying around. Now the session DOES follow, and that same
* token is what `storeTokens` seals for every subsequent request. Revoking it
* here would sign the admin straight back out.
*/
if (isPlatformAdmin(bundle.user)) {
try {
await authApi.logout(bundle.access_token);
} catch {
/* deliberately ignored — see above */
}
const code: LoginErrorCode = 'platform_account';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(
code,
'This console is for merchant accounts. Platform administrators sign in on the Loyaly platform console.',
403,
);
}
/**
* Minting the local session, which is where AUTH_SECRET is first read.
@@ -236,11 +235,72 @@ export async function POST(req: NextRequest) {
* a session it is unable to verify on the next request.
*/
const user = toAuthUser(bundle.user);
const maxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;
/**
* This is the Platform Admin console: it admits platform admins and nobody else.
*
* Correct merchant or staff credentials are still not Platform Admin
* credentials. Refused HERE, before `storeTokens` and before any cookie, so
* no session of any kind exists on this domain for them — not a merchant
* session that the proxy then has to route away from. The platform session
* `authApi.login` just minted is revoked for the same reason the
* misconfigured branch below revokes it: an unused refresh token is a
* credential left lying around. No redirect to the merchant app either; the
* form stays put and says why.
*/
if (!user.isPlatformAdmin) {
try {
await authApi.logout(bundle.access_token);
} catch {
/* best-effort: the refusal must not depend on the revoke succeeding */
}
const code: LoginErrorCode = 'not_platform_admin';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(code, NOT_PLATFORM_ADMIN_MESSAGE, 403);
}
/**
* Two different lifetimes, and conflating them was the bug.
*
* `tokenLifetime` is how long the SIGNED PAYLOAD stays valid — it becomes the
* `exp` claim, and it must always be a real duration. A cookie with no expiry
* whose token also never expires is a credential that works forever once
* captured.
*
* `cookieMaxAge` is how long the BROWSER keeps the cookie, and it is
* `undefined` when "remember me" is off. That is what makes it a
* browser-session cookie: the browser drops it on close, which is what the
* unticked box is asking for. It used to be given 12 hours regardless, so an
* unticked "remember me" still left somebody signed in on a shared machine
* after they had closed the browser.
*
* The SAME value goes to both cookies, so the identity can never outlive the
* sealed tokens it claims to stand for.
*/
const tokenLifetime = rememberMe
? REMEMBERED_MAX_AGE_SECONDS
: SESSION_MAX_AGE_SECONDS;
const cookieMaxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : undefined;
/**
* Which tab this session belongs to.
*
* The tab sends its own id in `X-Tab-Id`; signing in again in the same tab
* REPLACES that tab's session and leaves every other tab alone. When there is
* no id — the no-JavaScript form POST, which cannot set a header — one is
* minted here and handed back in the pointer cookie, so that path ends up
* with a properly scoped session too rather than a special unscoped one.
*/
const tabId = (await resolveTabId()) ?? newTabId();
let sessionCookie: string;
try {
await storeTokens(bundle);
await storeTokens(bundle, cookieMaxAge, tabId);
sessionCookie = createSessionToken(
{
sub: user.id,
@@ -248,8 +308,14 @@ export async function POST(req: NextRequest) {
name: user.name,
role: user.role,
organisation: user.organisation,
// Signed into the cookie so `proxy.ts` can decide which console to
// serve without a round trip, and so the browser cannot edit the
// answer: a tampered payload fails verifySessionToken and reads as no
// session at all. Still routing, never authority — the platform
// re-checks on every /api/admin/* call.
isPlatformAdmin: user.isPlatformAdmin,
},
maxAge,
tokenLifetime,
);
} catch (err) {
if (!(err instanceof ConfigError)) throw err;
@@ -278,12 +344,15 @@ export async function POST(req: NextRequest) {
const session: AuthSession = {user, expiresAt: bundle.expires_at};
// The no-JavaScript path lands in the SAME place the hydrated one does: an
// explicit `next` wins, otherwise the role the platform just returned decides.
// Both paths read one map, so a browser with JS disabled cannot end up
// somewhere else.
const landing = next
? resolveRedirectTarget(next)
: destinationForRole(user.role);
// explicit `next` wins, otherwise what the platform just returned decides —
// /admin for a platform operator, the role's route for a merchant. Both paths
// read one function, so a browser with JS disabled cannot end up somewhere
// else, and neither can walk into the wrong console.
const landing = resolveRedirectTargetFor(
next,
user.isPlatformAdmin,
destinationForUser(user),
);
const res = isForm
? NextResponse.redirect(new URL(landing, req.url), 303)
@@ -292,6 +361,14 @@ export async function POST(req: NextRequest) {
{headers: {'cache-control': 'no-store'}},
);
res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(maxAge));
res.cookies.set(
sessionCookieFor(tabId),
sessionCookie,
sessionCookieOptions(cookieMaxAge),
);
// Points server rendering and the proxy at the tab that just signed in. The
// tab rewrites this on focus, so it follows whichever tab is in use; it is a
// hint for the first paint, never the authority on who anyone is.
res.cookies.set(TAB_POINTER_COOKIE, tabId, tabPointerOptions());
return res;
}

View File

@@ -1,23 +1,39 @@
import {NextResponse} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
import {sessionCookieOptions} from '@/features/auth/services/sessionToken';
import {
sessionCookieFor,
tokenCookieFor,
} from '@/features/auth/services/tabScope';
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
import {peekAccessToken} from '@/features/auth/services/upstreamSession';
export const dynamic = 'force-dynamic';
/**
* POST /api/auth/logout
* POST /api/auth/logout — sign THIS TAB out.
*
* Revokes the session upstream first, then clears both cookies. The order is
* deliberate, and so is the fact that an upstream failure does NOT abort the
* local clear: if the platform is unreachable, the least bad outcome is that
* this browser is signed out immediately and the server-side session lapses on
* its own expiry. Leaving the user apparently signed in because a revoke call
* failed is the one outcome nobody expects from pressing Sign out.
* Revokes the session upstream first, then clears that tab's two cookies. The
* order is deliberate, and so is the fact that an upstream failure does NOT
* abort the local clear: if the platform is unreachable, the least bad outcome
* is that this tab is signed out immediately and the server-side session lapses
* on its own expiry. Leaving somebody apparently signed in because a revoke
* call failed is the one outcome nobody expects from pressing Sign out.
*
* No refresh attempt: the token is about to be thrown away, so spending a
* refresh token to revoke it is pure waste.
*
* ── Only this tab, and the upstream revoke is still real ─────────────────
* `peekAccessToken` resolves through the tab scope, so the token revoked
* upstream is THIS tab's session and no other. Signing out of the manager tab
* ends the manager's platform session — genuinely, server-side, as before — and
* leaves the admin and staff tabs holding their own untouched sessions in their
* own cookies. Nothing here weakens server-side invalidation; it narrows what
* gets invalidated to what the person actually asked to sign out of.
*
* A request with no resolvable tab clears nothing and still answers 200. There
* is no session to end, and guessing at one would sign out a tab that never
* asked.
*/
export async function POST() {
const accessToken = await peekAccessToken();
@@ -27,7 +43,7 @@ export async function POST() {
await authApi.logout(accessToken);
} catch {
// Already-expired, revoked, or unreachable — all fine. The cookies below
// are what actually ends this browser's session.
// are what actually ends this tab's session.
}
}
@@ -36,15 +52,23 @@ export async function POST() {
{headers: {'cache-control': 'no-store'}},
);
const tabId = await resolveTabId();
if (tabId) {
// Overwrite with an expired cookie rather than only deleting: a delete that
// misses on `path` leaves a live session behind.
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
res.cookies.set(TOKEN_COOKIE, '', {
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
res.cookies.set(tokenCookieFor(tabId), '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
}
// The pointer is deliberately left alone. It names a tab, not a session, and
// the signed-out tab rewrites it on its next load anyway — clearing it here
// would only blank the server-rendered first paint of whichever OTHER tab the
// person switches to next.
return res;
}

View File

@@ -0,0 +1,109 @@
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {ConfigError} from '@/shared/errors/configError';
import {failResponse} from '@/shared/services/bff';
import {fail} from '@/shared/services/apiRoute';
import {
SESSION_MAX_AGE_SECONDS,
createSessionToken,
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
import {
TAB_POINTER_COOKIE,
sessionCookieFor,
tabPointerOptions,
} from '@/features/auth/services/tabScope';
import {newTabId, resolveTabId} from '@/features/auth/services/tabScopeRequest';
import {storeTokens} from '@/features/auth/services/upstreamSession';
import {toAuthUser} from '@/features/auth/services/userMapper';
import type {AuthSession} from '@/features/auth/types/auth';
import type {ApiSuccess} from '@/shared/types/api';
export const dynamic = 'force-dynamic';
/**
* POST /api/auth/register — redeem an invitation and sign straight in. NO session.
*
* The platform answers with a full session, exactly like login, so this sets
* the same two cookies login does and the person lands in the console without
* ever seeing a sign-in form.
*
* Only the code, a name and a password are forwarded. `email` and `role` come
* from the INVITATION upstream and a body naming either is refused there —
* which is what stops a forwarded code becoming somebody else's account.
*
* Not "remember me": a first sign-in on a device nobody has vouched for gets
* the ordinary browser-session lifetime, and the next sign-in can opt in.
*
* Errors pass through with the platform's wording: 400 (password under 8
* characters), 404 `invalid_code`, 409 `conflict` (that address already has an
* account — sign in instead). A rejected attempt does not spend the code.
*/
export async function POST(req: NextRequest) {
let body: Record<string, unknown> = {};
try {
const parsed: unknown = await req.json();
if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
} catch {
/* an empty body is refused just below with a readable message */
}
const code = typeof body.code === 'string' ? body.code.trim() : '';
const fullName = typeof body.fullName === 'string' ? body.fullName.trim() : '';
const password = typeof body.password === 'string' ? body.password : '';
if (!code || !password) {
return fail('bad_request', 'Enter your invitation code and choose a password.', 400);
}
let bundle;
try {
bundle = await authApi.register(code, fullName, password);
} catch (err) {
return failResponse(err);
}
const user = toAuthUser(bundle.user);
const tabId = (await resolveTabId()) ?? newTabId();
let sessionCookie: string;
try {
await storeTokens(bundle, undefined, tabId);
sessionCookie = createSessionToken(
{
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
organisation: user.organisation,
isPlatformAdmin: user.isPlatformAdmin,
},
SESSION_MAX_AGE_SECONDS,
);
} catch (err) {
if (!(err instanceof ConfigError)) throw err;
console.error('[loyaly] configuration error:', err.message);
// The account now exists upstream; only this console's session could not
// be written. Release the platform session rather than leave it orphaned,
// and tell them to sign in — their password already works.
try {
await authApi.logout(bundle.access_token);
} catch {
/* best-effort */
}
return fail(
'internal',
'Your account was created, but signing in failed. Sign in with your email and new password.',
500,
);
}
const session: AuthSession = {user, expiresAt: bundle.expires_at};
const res = NextResponse.json<ApiSuccess<AuthSession>>(
{data: session, meta: {generatedAt: new Date().toISOString()}},
{status: 201, headers: {'cache-control': 'no-store'}},
);
res.cookies.set(sessionCookieFor(tabId), sessionCookie, sessionCookieOptions());
res.cookies.set(TAB_POINTER_COOKIE, tabId, tabPointerOptions());
return res;
}

View File

@@ -1,8 +1,12 @@
import {NextResponse} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {UpstreamError} from '@/services/api/apiClient';
import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
import {sessionCookieOptions} from '@/features/auth/services/sessionToken';
import {
sessionCookieFor,
tokenCookieFor,
} from '@/features/auth/services/tabScope';
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
import {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession';
import {toAuthUser} from '@/features/auth/services/userMapper';
import type {AuthSession} from '@/features/auth/types/auth';
@@ -30,6 +34,13 @@ export async function GET() {
try {
const user = await withUpstream((token) => authApi.me(token));
// This console knows only platform admins: a merchant or staff session is
// answered as nobody signed in, and its cookies are cleared, exactly like a
// session the platform rejected.
if (!toAuthUser(user).isPlatformAdmin) {
return anonymous();
}
const session: AuthSession = {
user: toAuthUser(user),
// The cookie's own expiry is the browser-side lifetime; the platform's
@@ -60,18 +71,46 @@ export async function GET() {
);
}
return anonymous();
}
}
/** Nobody signed in: `data: null`, and this tab's session cookies cleared. */
async function anonymous() {
const res = NextResponse.json(
{data: null, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
res.cookies.set(TOKEN_COOKIE, '', {
/**
* Clear THIS TAB's cookies, by their tab-scoped names.
*
* This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped
* names from before sessions were per-tab. Those cookies do not exist any
* more, so the clear silently did nothing and a confirmed 401 left the
* tab's real `loyaly_session_<tabId>` in place. The result was the
* half-authenticated state upstreamSession warns about, with a twist: the
* client set itself unauthenticated and went to /login, the proxy saw a
* still-valid session cookie and sent it straight back, and the two flapped.
*
* Same two helpers the logout route uses, so there is one naming scheme and
* the two paths cannot drift. Scoped to the resolved tab and no other: a
* dead session in one tab says nothing about the others, and clearing more
* than asked would sign out a tab that is working fine.
*
* A request with no resolvable tab clears nothing. There is no cookie to
* name, and guessing would reach into somebody else's session.
*/
const tabId = await resolveTabId();
if (tabId) {
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
res.cookies.set(tokenCookieFor(tabId), '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
}
return res;
}

View File

@@ -1,10 +1,23 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {refuseOffConsole} from '@/features/auth/services/serverSession';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
/**
* DELETE /api/auth/sessions/{id} — sign one device out.
*
* Revoking the CURRENT session is allowed and signs this browser out — which is
* a legitimate thing to want and a surprising thing to do by accident, so the
* screen warns before calling it rather than this route refusing.
*/
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const refused = await refuseOffConsole();
if (refused) return refused;
const {id} = await params;
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
}

View File

@@ -1,10 +1,19 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {refuseOffConsole} from '@/features/auth/services/serverSession';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** Keeps the calling device signed in; everything else is out immediately. */
/**
* POST /api/auth/sessions/revoke-others — sign out everywhere else.
*
* Keeps the caller's own session alive by design, so somebody who suspects a
* leak can clear every other device without locking themselves out of the
* screen they are doing it from.
*/
export async function POST(req: NextRequest) {
const refused = await refuseOffConsole();
if (refused) return refused;
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
}

View File

@@ -1,10 +1,23 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {proxyUpstream} from '@/shared/services/bff';
import {refuseOffConsole} from '@/features/auth/services/serverSession';
import {serveUpstream} from '@/shared/services/bff';
import {toDeviceSession} from '@/features/settings/services/mapSession';
export const dynamic = 'force-dynamic';
/** GET /api/auth/sessions - every device signed in as this person. */
/**
* GET /api/auth/sessions — every device currently signed in as this person.
*
* `current: true` marks the one making this request. It is the reason this list
* is worth showing at all: a session the user does not recognise is how they
* find out a password has leaked, and they need to be able to tell it apart
* from the browser they are reading the page in.
*/
export async function GET(req: NextRequest) {
return proxyUpstream(req, (token) => authApi.sessions(token));
const refused = await refuseOffConsole();
if (refused) return refused;
return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
list.map(toDeviceSession),
);
}

View File

@@ -5,13 +5,30 @@ import {toCamera} from '@/features/stores/services/mapCamera';
export const dynamic = 'force-dynamic';
/** POST /api/cameras/{id}/check {kind: connection|placement}. The shop PC
* claims the job on its next sync; poll the camera list for the result. */
export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
/**
* POST /api/cameras/{id}/check — ask the shop PC to prove this camera works.
*
* Two kinds: `connection` (can it be reached at all) and `placement` (is the
* view usable for recognition). Anything else the platform rejects, so the
* union is narrowed here rather than passed through as a free string.
*
* The platform answers 202 and the camera it returns still carries the PREVIOUS
* check — the edge has not run the new one yet. The caller re-reads; it must
* not render this response as the verdict.
*/
export async function POST(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(
req,
(token, body) => sitesApi.checkCamera(token, id, body.kind === 'placement' ? 'placement' : 'connection'),
(token, body) =>
sitesApi.checkCamera(
token,
id,
body.kind === 'placement' ? 'placement' : 'connection',
),
{map: toCamera, status: 202},
);
}

View File

@@ -0,0 +1,21 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {streamUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/cameras/{id}/live — live view, relayed through the shop PC.
*
* `event: waiting` arrives at once; `event: frame` follows with a base64 JPEG
* once the shop PC answers. Nothing is uploaded while nobody is watching, and
* the platform caps one view at five minutes. Closing the viewer cancels this
* request, which cancels the upstream one — see streamUpstream.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return streamUpstream(req, (token, signal) => sitesApi.live(token, id, signal));
}

View File

@@ -1,21 +1,36 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import type {ApiCameraInput} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
import type {ApiCameraInput} from '@/services/api/types';
export const dynamic = 'force-dynamic';
type Ctx = {params: Promise<{id: string}>};
export async function PATCH(req: NextRequest, ctx: Ctx) {
const {id} = await ctx.params;
return proxyUpstream(req, (token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput), {
map: toCamera,
});
/**
* PATCH /api/cameras/{id} — edit one camera.
* DELETE /api/cameras/{id} — remove it.
*
* PATCH rather than PUT, matching the platform: a form that leaves the password
* blank means "keep the stored one", and a PUT would read that as "clear it".
*/
export async function PATCH(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(
req,
(token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput),
{map: toCamera},
);
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
const {id} = await ctx.params;
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
// The platform answers 204 with no body; proxyUpstream sends `data: null`
// rather than an empty object, so the client can tell "done" from "malformed".
return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id));
}

View File

@@ -1,26 +1,39 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import type {ApiCameraInput} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
import type {ApiCameraInput} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/** GET /api/cameras?site=<slug> - every camera, or one shop's. */
/**
* GET /api/cameras?site=<slug> — the cameras on one shop, or all of them.
* POST /api/cameras?site=<slug> — add one to that shop.
*
* The POST carries the shop in the QUERY rather than the path because the
* platform creates under /api/sites/{site}/cameras while it reads from
* /api/cameras — two different shapes for one resource. Collapsing them here
* keeps that asymmetry out of every component.
*/
export async function GET(req: NextRequest) {
return proxyUpstream(req, (token, _body, params) => sitesApi.cameras(token, params.get('site') || undefined), {
map: (cams) => cams.map(toCamera),
});
const site = req.nextUrl.searchParams.get('site') ?? undefined;
return serveUpstream(req, (token) => sitesApi.cameras(token, site), (cams) =>
cams.map(toCamera),
);
}
/** POST /api/cameras {site, ...camera} - add a camera to a shop. */
export async function POST(req: NextRequest) {
const site = req.nextUrl.searchParams.get('site') ?? '';
if (!site) {
return Response.json(
{error: {code: 'bad_request', message: 'Which shop is this camera in?'}},
{status: 400},
);
}
return proxyUpstream(
req,
(token, body) => {
const {site, ...input} = body as {site?: string} & ApiCameraInput;
return sitesApi.addCamera(token, String(site ?? ''), input);
},
(token, body) => sitesApi.addCamera(token, site, body as ApiCameraInput),
{map: toCamera, status: 201},
);
}

View File

@@ -0,0 +1,24 @@
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {engagementApi} from '@/services/api/engagementApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
import {toCampaign} from '@/features/engagement/services/mapEngagement';
export const dynamic = 'force-dynamic';
/** GET /api/campaigns — each campaign's funnel over the workspace window. */
export async function GET(req: NextRequest) {
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
try {
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
return (list ?? []).map(toCampaign);
} catch (err) {
// The deployed platform predates this route: an empty panel, not a red
// error. Any other failure, a real 404 included, still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) return [];
throw err;
}
});
}

View File

@@ -0,0 +1,46 @@
import type {NextRequest} from 'next/server';
import {floorApi} from '@/services/api/floorApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* POST /api/customers — name somebody the cameras could not identify.
*
* `visit_id` is what makes this the first-visit flow rather than a directory
* entry: it links the new customer to the arrival that prompted the form, so
* the face on the floor stops being anonymous.
*/
export async function POST(req: NextRequest) {
let body: {name?: unknown; phone?: unknown; notes?: unknown; visitId?: unknown};
try {
body = (await req.json()) as typeof body;
} catch {
return Response.json(
{error: {code: 'bad_request', message: 'Malformed request body.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
try {
const created = await withUpstream((token) =>
floorApi.createCustomer(token, {
name: typeof body.name === 'string' ? body.name : '',
phone: typeof body.phone === 'string' ? body.phone : '',
notes: typeof body.notes === 'string' ? body.notes : undefined,
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
}),
);
return Response.json(
{data: {id: created.id, ref: created.ref, label: created.label}},
{status: 201, headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -0,0 +1,23 @@
import type {NextRequest} from 'next/server';
import {reportsApi} from '@/services/api/reportsApi';
import {DEFAULT_TZ, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
import {toTodaySummary} from '@/features/floor/services/mapSummary';
export const dynamic = 'force-dynamic';
/**
* GET /api/dashboard/summary — today, for the selected shop.
*
* Deliberately NOT scoped by the range picker: "today" is the business day in
* the shop's zone, which is the whole value of this read. The platform
* computes that window itself when none is sent.
*/
export async function GET(req: NextRequest) {
return serveUpstream(
req,
(token, query) =>
reportsApi.todaySummary(token, DEFAULT_TZ, toSiteParam(query.storeId)),
toTodaySummary,
);
}

View File

@@ -0,0 +1,57 @@
import type {NextRequest} from 'next/server';
import {upstreamRaw} from '@/services/api/apiClient';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failResponse} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/faces?src=/api/faces/<uuid>.jpg — an authenticated photo, proxied.
*
* A browser `<img>` cannot send an Authorization header, and the platform's
* own image URLs require one. The alternatives were fetch + createObjectURL +
* revoke-on-unmount at every avatar — which leaks hundreds of copies of one
* photograph on a screen left open all afternoon — or this: one hop through
* the origin that already holds the token.
*
* ── Why `src` is validated rather than trusted ───────────────────────────
* An unchecked pass-through would be an open proxy that attaches the
* merchant's bearer token to any URL an attacker can get into a page. Only
* same-origin platform paths under /api/faces/ are forwarded.
*
* Every hand-out of a photo is written to the platform's audit log, so this
* must be requested once per screen rather than once per component: two
* components asking for the same face puts two rows in "who looked at my
* customers" for one glance at one person.
*/
export async function GET(req: NextRequest) {
const src = new URL(req.url).searchParams.get('src') ?? '';
// Relative, no traversal, and inside the faces namespace. Anything else is
// refused rather than sanitised — a "cleaned" attacker-supplied URL is still
// attacker-supplied.
if (!src.startsWith('/api/faces/') || src.includes('..')) {
return Response.json(
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
{status: 400},
);
}
try {
const upstream = await withUpstream((token) =>
upstreamRaw({path: src, accessToken: token}),
);
return new Response(upstream.body, {
status: 200,
headers: {
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
// Private: this is one merchant's customer, and a shared cache holding
// it would serve it across tenants.
'cache-control': 'private, max-age=300',
},
});
} catch (err) {
return failResponse(err);
}
}

View File

@@ -0,0 +1,61 @@
import type {NextRequest} from 'next/server';
import {floorApi} from '@/services/api/floorApi';
import {toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
import {UpstreamError} from '@/services/api/apiClient';
import type {ApiFloorVisit} from '@/services/api/types';
import type {FloorVisit} from '@/features/floor/types/floor';
export const dynamic = 'force-dynamic';
/**
* GET /api/floor/visits — who is in the shop now.
*
* Absent fields become NULL rather than empty strings, because the screen
* branches on "is there a customer at all" and `''` would read as a customer
* with a blank name.
*/
export function toFloorVisit(v: ApiFloorVisit): FloorVisit {
return {
visitId: v.visit_id,
siteId: v.site_slug || v.site_id,
detectedAt: v.detected_at,
status: v.status,
visitorId: v.visitor_id || null,
customerRef: v.visitor_ref || null,
label: v.label || null,
phone: v.phone || null,
previousVisits: v.previous_visits ?? 0,
attendedBy: v.attended_by || null,
attendedByName: v.attended_by_name || null,
attendedByMe: v.attended_by_me ?? false,
// Proxied so an <img> works without the Authorization header it cannot send.
imageUrl:
v.image?.available && v.image.url
? v.image.url.startsWith('http')
? v.image.url
: `/api/faces?src=${encodeURIComponent(v.image.url)}`
: null,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(
req,
async (token, query) => {
try {
return await floorApi.list(token, {site: toSiteParam(query.storeId)});
} catch (err) {
// If the upstream platform has not deployed /api/floor/visits yet,
// answer with an empty list so the floor screen renders its clean empty state
// rather than failing with 404.
if (err instanceof UpstreamError && err.isRouteMissing) {
return {items: []};
}
throw err;
}
},
(page) => (page.items ?? []).map(toFloorVisit),
);
}

View File

@@ -6,30 +6,59 @@ import {failResponse} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* The browser cannot put a bearer on an <img>, so pictures the platform
* serves with the session - faces, camera snapshots - come through here.
* Presigned bucket links are absolute and load directly; they never come here.
* GET /api/images?src=<platform image path> — any authenticated picture, proxied.
*
* The same hop as /api/faces and for the same reason: a browser `<img>` cannot
* send an Authorization header, and every platform image URL requires one.
*
* This exists alongside /api/faces rather than replacing it. That route accepts
* exactly one namespace, which was right while faces were the only pictures in
* the product; camera snapshots are not under /api/faces/, so they could not be
* displayed through it at all. /api/faces is left untouched so nothing that
* works today changes, and new callers use this.
*
* ── Why an allowlist of shapes, not a prefix test ────────────────────────
* An unchecked pass-through is an open proxy that attaches the merchant's
* bearer token to whatever URL an attacker can get into a page. Each pattern
* below is anchored at both ends and permits no slash inside the id segment, so
* `/api/faces/../../admin/clients` cannot masquerade as a face. The `..` test is
* belt and braces on top of that.
*
* Adding a fourth kind of image means adding a line here, deliberately.
*/
const ALLOWED = [/^\/api\/faces\/[^/?]+$/, /^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/, /^\/api\/visitors\/[^/?]+\/image$/];
const ALLOWED = [
/^\/api\/faces\/[^/?]+$/,
/^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/,
/^\/api\/visitors\/[^/?]+\/image$/,
];
/** Exported so a caller can decide whether to render an <img> at all. */
export function isProxyableImage(src: string): boolean {
return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0]));
}
export async function GET(req: NextRequest) {
const src = req.nextUrl.searchParams.get('src') ?? '';
if (!isProxyableImage(src)) {
return Response.json({error: {code: 'bad_request', message: 'Not a valid image reference.'}}, {status: 400});
return Response.json(
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
{status: 400},
);
}
try {
const upstream = await withUpstream((token) => upstreamRaw({path: src, accessToken: token}));
const upstream = await withUpstream((token) =>
upstreamRaw({path: src, accessToken: token}),
);
return new Response(upstream.body, {
status: 200,
headers: {
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
// Private: one merchant's customer or shop floor. A shared cache
// Private: this is one merchant's shop floor, and a shared cache
// holding it would serve it across tenants.
'cache-control': 'private, max-age=60',
'cache-control': 'private, max-age=300',
},
});
} catch (err) {

View File

@@ -0,0 +1,29 @@
import type {NextRequest} from 'next/server';
import {purchasesApi} from '@/services/api/purchasesApi';
import {resolveSiteId} from '@/services/api/refs';
import {proxyUpstream} from '@/shared/services/bff';
import {toPurchaseInput} from '@/features/customers/services/mapCustomer';
export const dynamic = 'force-dynamic';
/**
* POST /api/purchases — link a sale to a customer. Staff and above.
*
* This is what lets the conversion report say WHO bought. It is distinct from
* /api/sales, the till's itemised record; a purchase here is the lighter
* "this customer spent this much" link. The platform answers 204.
*
* The shop is sent as a uuid: this write upstream does not resolve a slug and
* answers one with a 500 — see refs.ts.
*/
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
async (token, body) => {
const input = toPurchaseInput(body);
if (input.site_id) input.site_id = await resolveSiteId(token, input.site_id);
return purchasesApi.create(token, input);
},
{status: 201},
);
}

View File

@@ -0,0 +1,31 @@
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {engagementApi} from '@/services/api/engagementApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff';
import {toJourney} from '@/features/engagement/services/mapEngagement';
export const dynamic = 'force-dynamic';
/**
* GET /api/reports/journey — visit → take part → buy → come back → refer.
*
* Distinct people per stage. Not a strict funnel, and the panel says so.
*/
export async function GET(req: NextRequest) {
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
try {
return toJourney(
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
);
} catch (err) {
// The deployed platform predates this route: an empty panel, not a red
// error. Any other failure, a real 404 included, still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) {
return {stages: [], attribution: 'observed'};
}
throw err;
}
});
}

View File

@@ -0,0 +1,33 @@
import type {NextRequest} from 'next/server';
import {salesApi} from '@/services/api/salesApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import {toSale} from '@/app/api/sales/route';
export const dynamic = 'force-dynamic';
/**
* GET /api/sales/{id} — one sale with its lines. §24.
*
* The list endpoint carries counts; this carries the lines themselves, so a
* screen showing a whole day of sales does not pull every line of every one.
*/
export async function GET(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const sale = await withUpstream((token) => salesApi.byId(token, id));
return Response.json(
{data: toSale(sale)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

185
src/app/api/sales/route.ts Normal file
View File

@@ -0,0 +1,185 @@
import type {NextRequest} from 'next/server';
import {salesApi} from '@/services/api/salesApi';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream, failureFrom} from '@/shared/services/bff';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {UpstreamError} from '@/services/api/apiClient';
import type {ApiSale} from '@/services/api/types';
import type {Sale} from '@/features/commerce/types/sale';
export const dynamic = 'force-dynamic';
/**
* GET /api/sales — the sale history the Sales screen reads.
*
* Money crosses this boundary as integer PAISE and is NOT converted. The
* console formats paise for display and never holds rupees, so there is no
* float round-trip and no component can disagree about the decimal point.
*/
export function toSale(s: ApiSale): Sale {
return {
id: s.id,
invoiceNo: s.invoice_no ?? null,
siteId: s.site_slug || s.site_id,
customerRef: s.visitor_ref ?? null,
customerLabel: s.customer_label || null,
staffName: s.staff_name || null,
totalPaise: s.total_paise ?? 0,
currency: s.currency ?? 'INR',
status: s.status,
at: s.server_created_at,
purchasedLines: s.purchased_lines ?? 0,
enquiryLines: s.enquiry_lines ?? 0,
lines: (s.lines ?? []).map((l) => ({
productName: l.product_name,
pricePaise: l.price_paise ?? 0,
intent: l.intent,
// Straight from the server. Re-deriving it here would put the
// enquiry-is-not-revenue rule in a second place, which is how the two
// start disagreeing.
billablePaise: l.billable_paise ?? 0,
})),
};
}
export async function GET(req: NextRequest) {
return serveUpstream(
req,
async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs));
try {
return await salesApi.list(token, {
site: toSiteParam(query.storeId),
from: window.from,
to: window.to,
limit: 50,
});
} catch (err) {
// If the upstream platform has not deployed /api/sales yet,
// answer with an empty list so the sales screen renders cleanly
// rather than failing. Only a missing ROUTE — a 404 for a shop that
// does not exist is a real answer and still surfaces.
if (err instanceof UpstreamError && err.isRouteMissing) {
return {items: []};
}
throw err;
}
},
(page) => (page.items ?? []).map(toSale),
);
}
/**
* POST /api/sales — record a sale.
*
* ── What this route does NOT do ──────────────────────────────────────────
* It does not compute a total. §12 says the backend calculates it from the
* lines and must not trust a client-supplied one, and the platform's own
* request shape has no total field to send. It also does not carry a staff id:
* the platform derives that from the session, so a request structurally cannot
* attribute a sale to somebody else.
*
* Prices arrive as integer PAISE from the form and are forwarded unchanged.
* This is the only place the console converts money at all, and it converts in
* one direction: paise out of the platform become rupees for display (toSale
* above). Nothing multiplies by 100 on the way in, because the form never held
* rupees to begin with.
*/
export async function POST(req: NextRequest) {
let body: {
idempotencyKey?: unknown;
clientCreatedAt?: unknown;
visitId?: unknown;
visitorId?: unknown;
invoiceNo?: unknown;
site?: unknown;
lines?: unknown;
};
try {
body = (await req.json()) as typeof body;
} catch {
return Response.json(
{error: {code: 'bad_request', message: 'Malformed request body.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
const lines = Array.isArray(body.lines)
? body.lines
.map((l) => l as {productName?: unknown; pricePaise?: unknown; intent?: unknown})
.filter((l) => typeof l.productName === 'string' && l.productName.trim() !== '')
.map((l) => ({
product_name: String(l.productName).trim(),
// Already an integer. Rounded rather than trusted blindly so a
// fractional paise from a hand-written request cannot reach a bigint
// column and be rejected three layers down.
price_paise: Math.max(0, Math.round(Number(l.pricePaise) || 0)),
intent: l.intent === 'enquired' ? 'enquired' : 'purchased',
}))
: [];
try {
const result = await withUpstream((token) =>
salesApi.create(token, {
idempotency_key:
typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '',
invoice_no: typeof body.invoiceNo === 'string' ? body.invoiceNo : '',
site: typeof body.site === 'string' ? body.site : undefined,
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
client_created_at: draftTime(body.clientCreatedAt),
lines,
}),
);
return Response.json(
{
data: {
// "already_processed" is a SUCCESS carrying the original sale — a
// replay after a double tap or a retry has done nothing wrong.
status: result.status,
saleId: result.sale_id,
sale: result.sale ? toSale(result.sale) : null,
},
},
// The platform's own status: 201 for a new sale, 200 for a replay.
{
status: result.status === 'already_processed' ? 200 : 201,
headers: {'cache-control': 'no-store'},
},
);
} catch (err) {
// 404 "No such endpoint." or 405 — the deployed platform has no sale
// writer yet. Any other 404 (a visit that is not this shop's) is real.
if (err instanceof UpstreamError && err.isRouteMissing) {
return Response.json(
{
error: {
code: 'bad_request',
message: 'Sale recording is not available on this server version yet.',
},
reason: 'not_implemented',
},
{status: 501, headers: {'cache-control': 'no-store'}},
);
}
const f = failureFrom(err);
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}
/**
* When the sale was drafted on the device, as the dialog stamped it. Falls
* back to now for a missing or unparseable value, and for one in the future —
* a device clock ahead of the server must not date a sale tomorrow.
*/
function draftTime(value: unknown): string {
const now = Date.now();
if (typeof value !== 'string') return new Date(now).toISOString();
const t = Date.parse(value);
return Number.isFinite(t) && t <= now
? new Date(t).toISOString()
: new Date(now).toISOString();
}

View File

@@ -0,0 +1,23 @@
import {fail} from '@/shared/services/apiRoute';
export const dynamic = 'force-dynamic';
/**
* GET / PATCH /api/settings/profile — the merchant's business profile.
*
* The platform has no profile endpoint yet (docs/API-STATUS.md), so there is
* nothing to forward to. Without this file Next.js answered its own HTML 404
* page, which the settings screen could only report as "Unexpected response".
* This says what is actually true, in the envelope the screen reads. Once the
* platform ships the route, replace both handlers with `serveUpstream` /
* `proxyUpstream` calls against it.
*/
const NOT_YET = 'Business profile is not available on the platform yet.';
export function GET() {
return fail('not_deployed', NOT_YET, 501);
}
export function PATCH() {
return fail('not_deployed', NOT_YET, 501);
}

View File

@@ -0,0 +1,35 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {serveUpstream} from '@/shared/services/bff';
import type {ApiSiteCheck} from '@/services/api/types';
import type {SiteCheck} from '@/features/stores/types/siteCheck';
export const dynamic = 'force-dynamic';
function toSiteCheck(c: ApiSiteCheck): SiteCheck {
return {
siteName: c.site,
ok: c.ok,
steps: (c.steps ?? []).map((s) => ({
name: s.name,
status: s.status,
detail: s.detail,
advice: s.advice || null,
})),
};
}
/**
* GET /api/sites/{site}/check — is this shop working, in five ordered steps.
*
* Answered from what head office already knows, so it works when the shop PC
* is off — which is itself one of the answers. Read-only and cheap, so it is a
* GET the screen can repeat as often as somebody taps it.
*/
export async function GET(
req: NextRequest,
{params}: {params: Promise<{site: string}>},
) {
const {site} = await params;
return serveUpstream(req, (token) => sitesApi.check(token, site), toSiteCheck);
}

View File

@@ -4,16 +4,27 @@ import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** POST /api/sites/{site}/enrolment-code - the code a new shop PC types. */
export async function POST(req: NextRequest, ctx: {params: Promise<{site: string}>}) {
const {site} = await ctx.params;
/**
* POST /api/sites/{site}/enrolment-code — a one-time code that enrols a shop PC.
*
* The code comes back ONCE and is not recoverable: the platform stores a hash,
* exactly as it does for a team invitation. So this is a POST even though it
* reads like a fetch — asking twice mints two codes rather than showing the
* same one, and a GET would invite a browser or a prefetch to do that silently.
*/
export async function POST(
req: NextRequest,
{params}: {params: Promise<{site: string}>},
) {
const {site} = await params;
return proxyUpstream(
req,
(token, body) =>
sitesApi.enrolmentCode(token, site, {
label: typeof body.label === 'string' ? body.label : undefined,
ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined,
}),
sitesApi.enrolmentCode(
token,
site,
typeof body.label === 'string' ? body.label : undefined,
),
{status: 201},
);
}

View File

@@ -0,0 +1,40 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {proxyUpstream} from '@/shared/services/bff';
import type {ApiSiteUpdate} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* PATCH /api/sites/{site} — rename a shop or change its timezone. Manager or owner.
* DELETE /api/sites/{site} — remove a shop opened by mistake. Owner only.
*
* `{site}` is the slug, which never changes: renaming edits the display name
* only, so every saved URL and scheduled report keeps working.
*
* DELETE succeeds only for an EMPTY shop. One with cameras or visit history
* answers 409 `in_use` with the platform's own explanation, which is passed
* through verbatim — removing footfall and faces is an erasure decision, not a
* tidy-up this console should make easy.
*/
export async function PATCH(
req: NextRequest,
{params}: {params: Promise<{site: string}>},
) {
const {site} = await params;
return proxyUpstream(req, (token, body) => {
// An omitted field is left alone upstream, so only what was sent is sent.
const patch: ApiSiteUpdate = {};
if (typeof body.name === 'string') patch.name = body.name.trim();
if (typeof body.timezone === 'string') patch.timezone = body.timezone.trim();
return sitesApi.update(token, site, patch);
});
}
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{site: string}>},
) {
const {site} = await params;
return proxyUpstream(req, (token) => sitesApi.remove(token, site));
}

View File

@@ -1,29 +1,62 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {serveUpstream} from '@/shared/services/bff';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import type {ApiSite} from '@/services/api/types';
import type {Site} from '@/features/stores/types/site';
export const dynamic = 'force-dynamic';
/**
* GET /api/sites — the estate.
* POST /api/sites — open a shop (owner only; the platform enforces it).
*
* This is the most load-bearing read in the console: the site switcher scopes
* every other request in the app, so a hardcoded list here meant every screen
* was filtered by a store that might not exist.
*
* `slug` is carried through as the identifier the UI keys on because it is
* IMMUTABLE upstream and safe to persist in a URL or a saved report, while the
* display name is expected to change.
*/
function toSite(s: ApiSite): Site {
return {
id: s.slug,
// Slug first: it is immutable and is what every scoped request sends as
// `?site=`. `site_id` is the uuid — the server does not send a bare `id`.
id: s.slug || s.site_id,
uuid: s.site_id,
name: s.name,
timezone: s.timezone,
isOnline: s.online,
lastHeartbeatAt: s.last_heartbeat_at ?? null,
lastEventAt: s.last_event_at ?? null,
recognitionModel: s.recognition_model ?? null,
camerasTotal: s.cameras_total,
camerasUp: s.cameras_up,
fractionBelowGate: s.fraction_below_gate,
queued: s.queued,
dropped: s.dropped,
isOnline: s.online ?? null,
camerasTotal: s.cameras_total ?? null,
camerasUp: s.cameras_up ?? null,
fractionBelowGate: s.fraction_below_gate ?? null,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => sitesApi.list(token), (sites) => sites.map(toSite));
return serveUpstream(req, (token) => sitesApi.list(token), (sites) =>
sites.map(toSite),
);
}
/**
* The slug is optional and derived from the name upstream. It becomes the shop
* PC's identity and can never be changed, so an empty one is sent as absent
* rather than as "" — the platform then derives a good one itself.
*/
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) => {
const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
const timezone =
typeof body.timezone === 'string' ? body.timezone.trim() : '';
return sitesApi.create(token, {
name: typeof body.name === 'string' ? body.name.trim() : '',
slug: slug || undefined,
timezone: timezone || undefined,
});
},
{status: 201},
);
}

View File

@@ -4,10 +4,29 @@ import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/** POST /api/team/{id}/password - new password shown ONCE; signs them out everywhere. */
export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
/**
* POST /api/team/{id}/password — set a new password for somebody.
*
* The response carries the password ONCE. It is bcrypt-hashed on the way in and
* is not recoverable afterwards, so the screen must show it immediately and
* must not stash it anywhere it could be read back.
*
* Omitting `password` has the platform generate a strong one, which is the
* better default — a password an operator invents for somebody else is weak and
* ends up in a chat message.
*/
export async function POST(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token, body) =>
teamApi.resetPassword(token, id, typeof body.password === 'string' && body.password ? body.password : undefined),
teamApi.resetPassword(
token,
id,
typeof body.password === 'string' && body.password !== ''
? body.password
: undefined,
),
);
}

View File

@@ -1,18 +1,34 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import type {ApiRole} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
import {toMember} from '@/features/team/services/mapTeam';
import type {ApiRole} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/** PATCH /api/team/{id} {role?, active?}. Deactivating revokes every session
* that person holds, in the same transaction, server-side. */
export async function PATCH(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
return proxyUpstream(req, (token, body) =>
/**
* PATCH /api/team/{id} — change somebody's role, or switch their access off.
*
* Deactivating revokes every session that person holds IMMEDIATELY; it is not a
* soft flag that takes effect at next sign-in. The UI is expected to confirm
* before calling this.
*
* The platform answers 409 `last_owner` when this would leave the company with
* no active owner. That travels through `failResponse` with its reason intact,
* so the screen can say which rule was hit rather than "something went wrong".
*/
export async function PATCH(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(
req,
(token, body) =>
teamApi.update(token, id, {
role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined,
active: typeof body.active === 'boolean' ? body.active : undefined,
}),
{map: toMember},
);
}

View File

@@ -4,7 +4,16 @@ import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
const {id} = await ctx.params;
/**
* DELETE /api/team/invitations/{id} — withdraw an invitation.
*
* The code stops working immediately. There is no way to un-withdraw it; a
* change of mind means minting a new one.
*/
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id));
}

View File

@@ -1,25 +1,39 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import {toInvitation} from '@/features/team/services/mapTeam';
import type {ApiRole} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/team/invitations — who has been invited and not yet joined.
* POST /api/team/invitations — invite somebody.
*
* The invitation is the PREFERRED way to add a person: they redeem the code and
* choose their own password, so the merchant never handles it. The code comes
* back once on the POST and never again.
*/
export async function GET(req: NextRequest) {
return proxyUpstream(req, (token) => teamApi.invitations(token));
return serveUpstream(req, (token) => teamApi.invitations(token), (list) =>
list.map(toInvitation),
);
}
/** POST /api/team/invitations - the code is in the response ONCE. */
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) =>
teamApi.invite(token, {
email: String(body.email ?? ''),
full_name: typeof body.full_name === 'string' ? body.full_name : undefined,
role: String(body.role ?? 'staff') as ApiRole,
ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined,
email: String(body.email ?? '').trim(),
full_name:
typeof body.full_name === 'string' ? body.full_name : undefined,
role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
expires_in_days:
typeof body.expires_in_days === 'number'
? body.expires_in_days
: undefined,
}),
{status: 201},
{map: toInvitation, status: 201},
);
}

View File

@@ -1,21 +1,33 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import type {ApiRole} from '@/services/api/types';
import {proxyUpstream} from '@/shared/services/bff';
import type {ApiRole} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/** POST /api/team/members - create an account. The password is in the
* response ONCE and nowhere else. */
/**
* POST /api/team/members — create a login directly and hand the password over.
*
* The other way in is an invitation, where the person chooses their own
* password and the merchant never sees it. That is the better path and the UI
* offers it first; this exists for somebody standing at the counter with no
* phone to redeem a code on.
*
* Answers 201 with the member AND the generated password, shown once.
*/
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) =>
teamApi.createMember(token, {
email: String(body.email ?? ''),
full_name: String(body.full_name ?? ''),
role: String(body.role ?? 'staff') as ApiRole,
password: typeof body.password === 'string' && body.password ? body.password : undefined,
email: String(body.email ?? '').trim(),
full_name:
typeof body.full_name === 'string' ? body.full_name : undefined,
role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
password:
typeof body.password === 'string' && body.password !== ''
? body.password
: undefined,
}),
{status: 201},
);

View File

@@ -1,9 +1,7 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {serveUpstream} from '@/shared/services/bff';
import type {ApiTeamMember} from '@/services/api/types';
import type {UserRole} from '@/features/auth/types/auth';
import type {TeamMember} from '@/features/team/types/team';
import {toMember} from '@/features/team/services/mapTeam';
export const dynamic = 'force-dynamic';
@@ -20,22 +18,6 @@ export const dynamic = 'force-dynamic';
* row, while `active`, `last_login_at` and `created_at` were discarded. The
* one field the team screen needs — who still has access — never arrived.
*/
function toMember(m: ApiTeamMember): TeamMember {
return {
id: m.id,
// Falls back to the address rather than rendering a blank cell: somebody
// invited but not yet named still has to be identifiable.
name: m.full_name || m.email,
email: m.email,
role: m.role as UserRole,
active: m.active,
// Null rather than '' — "has never signed in" and "signed in at an unknown
// time" are different facts, and the screen says so.
lastLoginAt: m.last_login_at || null,
createdAt: m.created_at,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => teamApi.list(token), (members) =>
members.map(toMember),

View File

@@ -0,0 +1,19 @@
import type {NextRequest} from 'next/server';
import {visitorsApi} from '@/services/api/visitorsApi';
import {serveUpstream} from '@/shared/services/bff';
import {toCustomerVisit} from '@/features/customers/services/mapCustomer';
export const dynamic = 'force-dynamic';
/** GET /api/visitors/{id}/history — this customer's visits, newest first. */
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return serveUpstream(
req,
(token) => visitorsApi.history(token, id, 50),
(rows) => (rows ?? []).map(toCustomerVisit),
);
}

View File

@@ -0,0 +1,37 @@
import type {NextRequest} from 'next/server';
import {visitorsApi} from '@/services/api/visitorsApi';
import {UpstreamError} from '@/services/api/apiClient';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failResponse} from '@/shared/services/bff';
import {ok, parseQuery} from '@/shared/services/apiRoute';
import {toCustomerPhoto} from '@/features/customers/services/mapCustomer';
export const dynamic = 'force-dynamic';
/**
* GET /api/visitors/{id}/image — the customer's latest photo, described.
*
* The platform answers "no photo" with a 404 carrying one of two codes —
* `no_image` (nothing captured) and `images_disabled` (this deployment stores
* none). Both are normal states, not faults, so they are answered here as
* `available: false` with the platform's own reason, and the screen shows a
* placeholder instead of a red error for a system working as configured.
*/
const ABSENT = new Set(['no_image', 'images_disabled']);
export async function GET(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
const query = parseQuery(req);
try {
const img = await withUpstream((token) => visitorsApi.image(token, id));
return ok(toCustomerPhoto(img), query);
} catch (err) {
if (err instanceof UpstreamError && err.status === 404 && ABSENT.has(err.code)) {
return ok({available: false, url: null, reason: err.message}, query);
}
return failResponse(err);
}
}

View File

@@ -0,0 +1,23 @@
import type {NextRequest} from 'next/server';
import {visitorsApi} from '@/services/api/visitorsApi';
import {proxyUpstream} from '@/shared/services/bff';
import {toProfileInput} from '@/features/customers/services/mapCustomer';
export const dynamic = 'force-dynamic';
/**
* PUT /api/visitors/{id}/profile — give a customer a name. Staff and above.
*
* PUT because the platform's save is a whole-object replace; see
* toProfileInput for what that means for the fields this console cannot read.
* The platform answers 204, so the caller re-reads the list for the new label.
*/
export async function PUT(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token, body) =>
visitorsApi.updateProfile(token, id, toProfileInput(body)),
);
}

View File

@@ -0,0 +1,23 @@
import type {NextRequest} from 'next/server';
import {visitorsApi} from '@/services/api/visitorsApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* DELETE /api/visitors/{id} — erasure. Manager and above; the platform
* enforces that, and a staff account gets its 403 with a message saying who can.
*
* Irreversible: the face template and photo are destroyed, the visit rows are
* kept unlinked, the consent record is kept revoked. A 502 means the photo
* could not be deleted and NOTHING was erased — it is passed through as a
* failure, never softened, because the data the merchant believes is gone is
* still there.
*/
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token) => visitorsApi.erase(token, id));
}

View File

@@ -0,0 +1,28 @@
import type {NextRequest} from 'next/server';
import {visitorsApi} from '@/services/api/visitorsApi';
import {serveUpstream} from '@/shared/services/bff';
import {toCustomer} from '@/features/customers/services/mapCustomer';
export const dynamic = 'force-dynamic';
/**
* GET /api/visitors?q=… — find a customer.
*
* `q` matches name, phone, email or customer number (`42` or `V-42`); without
* it the platform returns the most recently seen. Erased customers never
* appear. Unscoped by shop: a customer belongs to the company, not to the
* branch they happened to walk into first.
*/
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q')?.trim() || undefined;
const limitRaw = Number(req.nextUrl.searchParams.get('limit') ?? 50);
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(Math.trunc(limitRaw), 1), 500)
: 50;
return serveUpstream(
req,
(token) => visitorsApi.search(token, q, limit),
(list) => (list ?? []).map(toCustomer),
);
}

View File

@@ -0,0 +1,37 @@
import type {NextRequest} from 'next/server';
import {floorApi} from '@/services/api/floorApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import {toFloorVisit} from '@/app/api/floor/visits/route';
export const dynamic = 'force-dynamic';
/**
* POST /api/visits/{id}/attend
*
* The failure this route exists to pass through faithfully is 409: another
* member of staff holds this customer. The screen must show that rather than
* a generic error, and it must NOT be simulated client-side — only the
* platform knows who actually won.
*/
export async function POST(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const visit = await withUpstream((token) => floorApi.attend(token, id));
return Response.json(
{data: toFloorVisit(visit)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
// so the screen branches on that rather than on prose.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -0,0 +1,37 @@
import type {NextRequest} from 'next/server';
import {floorApi} from '@/services/api/floorApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import {toFloorVisit} from '@/app/api/floor/visits/route';
export const dynamic = 'force-dynamic';
/**
* POST /api/visits/{id}/complete
*
* The failure this route exists to pass through faithfully is 409: another
* member of staff holds this customer. The screen must show that rather than
* a generic error, and it must NOT be simulated client-side — only the
* platform knows who actually won.
*/
export async function POST(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const visit = await withUpstream((token) => floorApi.complete(token, id));
return Response.json(
{data: toFloorVisit(visit)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
// so the screen branches on that rather than on prose.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -0,0 +1,37 @@
import type {NextRequest} from 'next/server';
import {floorApi} from '@/services/api/floorApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import {toFloorVisit} from '@/app/api/floor/visits/route';
export const dynamic = 'force-dynamic';
/**
* POST /api/visits/{id}/release
*
* The failure this route exists to pass through faithfully is 409: another
* member of staff holds this customer. The screen must show that rather than
* a generic error, and it must NOT be simulated client-side — only the
* platform knows who actually won.
*/
export async function POST(
_req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
try {
const visit = await withUpstream((token) => floorApi.release(token, id));
return Response.json(
{data: toFloorVisit(visit)},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — CUSTOMER_ALREADY_TAKEN —
// so the screen branches on that rather than on prose.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -1,10 +1,12 @@
import type {NextRequest} from 'next/server';
import {visitsApi} from '@/services/api/visitsApi';
import {purchasesApi} from '@/services/api/purchasesApi';
import {resolveSiteId} from '@/services/api/refs';
import {toSiteParam} from '@/services/api/range';
import {failResponse, serveUpstream} from '@/shared/services/bff';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {parseQuery, ok} from '@/shared/services/apiRoute';
import {toPurchaseInput} from '@/features/customers/services/mapCustomer';
import type {ApiArrival, ApiVisitsPage} from '@/services/api/types';
import type {Arrival, VisitsPage} from '@/features/dashboard/types/visits';
@@ -27,7 +29,10 @@ function toArrival(a: ApiArrival): Arrival {
cameraId: a.camera_id,
visitorId: a.visitor_id,
visitorRef: a.visitor_ref,
label: a.label,
// The typed name wins; `label` ("Visitor 12") is the fallback. The
// platform sends both precisely so a client does not show a named regular
// as a number.
label: a.name || a.label,
isNewVisitor: a.is_new_visitor,
similarity: a.similarity,
image: a.image
@@ -38,7 +43,7 @@ function toArrival(a: ApiArrival): Arrival {
url: a.image.url
? a.image.url.startsWith('http')
? a.image.url
: `/api/images?src=${encodeURIComponent(a.image.url)}`
: `/api/faces?src=${encodeURIComponent(a.image.url)}`
: null,
reason: a.image.reason ?? null,
}
@@ -88,19 +93,14 @@ export async function POST(req: NextRequest) {
const body = (await req.json()) as Record<string, unknown>;
// Marshalled before the first attempt so the retry after a token refresh
// can send it again — a request stream is spent once it has been read.
const created = await withUpstream((token) =>
purchasesApi.create(token, {
visit_id: typeof body.visitId === 'string' ? body.visitId : undefined,
visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined,
site: typeof body.site === 'string' ? body.site : undefined,
amount: Number(body.amount),
currency: typeof body.currency === 'string' ? body.currency : 'INR',
items: typeof body.items === 'number' ? body.items : undefined,
occurred_at:
typeof body.occurredAt === 'string' ? body.occurredAt : undefined,
}),
);
return ok(created, query);
const input = toPurchaseInput(body);
await withUpstream(async (token) => {
// Resolved inside the retry, so a refreshed token resolves it too.
if (input.site_id) input.site_id = await resolveSiteId(token, input.site_id);
return purchasesApi.create(token, input);
});
// The platform answers 204: recorded, with nothing to echo back.
return ok(null, query);
} catch (err) {
return failResponse(err);
}

View File

@@ -0,0 +1,27 @@
import type {NextRequest} from 'next/server';
import {visitsApi} from '@/services/api/visitsApi';
import {toSiteParam} from '@/services/api/range';
import {streamUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/visits/stream — arrivals, pushed as they happen.
*
* The same rows as GET /api/visits, delivered as `event: arrivals`. The
* console uses each event as a signal to re-read the feed it already renders
* rather than parsing rows out of the stream, so there is one mapping of an
* arrival in this app, not two — and polling remains the fallback, so a
* dropped stream costs latency, never data.
*
* `storeId` is the workspace scope, as on every other scoped read.
*/
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const cursor = p.get('cursor') ?? undefined;
const site = toSiteParam(p.get('storeId') ?? 'all');
return streamUpstream(req, (token, signal) =>
visitsApi.stream(token, {cursor, site}, signal),
);
}

View File

@@ -78,6 +78,52 @@
* Astryx's <Theme>. It is absent in system mode — that is what leaves
* `light dark` in force so the OS preference decides.
*/
/*
* The ambient canvas, taken from krow-demo: cool blue on the left, neutral
* through the middle, the faintest warm cream on the right. The stops sit
* close in lightness so it reads as "softer", not as a visible band.
*
* Every dark branch is #000000, the dark body colour, so in dark mode the
* gradient flattens to exactly what was there before.
*
* It is painted with `background-attachment: fixed` on every layer that fills
* with the body colour, not once behind a transparent shell. Fixed attachment
* sizes the image to the viewport, so stacked layers line up pixel for pixel
* and the seams disappear. The shell layers keep their opaque background-color
* underneath, and the text inputs and primary button that read
* --color-background-body are not touched.
*
* `.x1eiddq6` is Astryx 0.2.0's atomic class for
* `background-color: var(--color-background-body)` (AppShell wash, nav areas,
* layout content). It is a build hash: re-check it after any Astryx upgrade
* (`grep -o '.x[a-z0-9]*[^{]*{background-color:var(--color-background-body)'
* node_modules/@astryxdesign/core/dist/astryx.css`).
*/
:root {
--loyaly-canvas: radial-gradient(
ellipse 85% 55% at 20% -10%,
light-dark(rgba(124, 58, 237, 0.06), rgba(124, 58, 237, 0.18)) 0%,
transparent 70%
),
radial-gradient(
ellipse 75% 50% at 85% 20%,
light-dark(rgba(244, 196, 48, 0.05), rgba(244, 196, 48, 0.1)) 0%,
transparent 70%
),
linear-gradient(
180deg,
light-dark(#f8fafc, #0b0f17) 0%,
light-dark(#f1f5f9, #080c13) 100%
);
}
html,
.x1eiddq6,
.astryx-side-nav {
background-image: var(--loyaly-canvas);
background-attachment: fixed;
}
html {
background-color: var(--color-background-body);
color-scheme: light dark;
@@ -322,3 +368,12 @@ html[data-theme='light'] {
}
}
}
/*
* The server rendered this document as another tab's user (see
* FOREIGN_SEED_ATTR in features/auth/services/tabSession.ts). Keep it unseen
* until SessionProvider has replaced it with this tab's own state.
*/
html[data-tab-seed-foreign] body {
visibility: hidden;
}

View File

@@ -3,6 +3,8 @@ import {cookies} from 'next/headers';
import {Sora, Inter} from 'next/font/google';
import './globals.css';
import {getServerSession} from '@/features/auth/services/serverSession';
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
import {tabSessionScript} from '@/features/auth/services/tabSession';
import {
htmlThemeAttr,
parseThemeMode,
@@ -87,6 +89,22 @@ export default async function RootLayout({
* already answered in this very request.
*/
const session = await getServerSession();
/**
* WHICH tab that session was resolved from, handed to the client alongside it.
*
* A document navigation carries no `X-Tab-Id`, so `getServerSession` resolves
* through the `loyaly_tab` pointer — the tab that was last FOCUSED, which for
* a newly opened tab is somebody else. The seed is still worth sending (it is
* what saves a guard spinner on every load), but the client has to be able to
* tell whether the seed is its own. Without this it could not: it received an
* identity with nothing attached saying who it belonged to, so a second tab
* rendered the first tab's user in the shell and only found out when a data
* call answered 401.
*
* Not a credential and not a decision — just the label that lets the client
* recognise a seed that is not about it. See SessionProvider.
*/
const tabId = await resolveTabId();
const themeMode = await readThemeMode();
return (
@@ -105,7 +123,22 @@ export default async function RootLayout({
suppressHydrationWarning
>
<body>
<Providers initialSession={session} initialThemeMode={themeMode}>
{/*
FIRST child of <body>, and that position is the whole point: it gives
this tab its id and points the cookie at it before the markup below is
parsed, so the very first navigation is rendered as the right user. An
effect inside Providers would run after the first paint instead.
It no longer takes the session: it does not decide anything about
being signed in, and nothing in it signs anybody out. See
services/tabSession.ts for what it replaced and why.
*/}
<script dangerouslySetInnerHTML={{__html: tabSessionScript(tabId)}} />
<Providers
initialSession={session}
initialTabId={tabId}
initialThemeMode={themeMode}
>
{children}
</Providers>
</body>

View File

@@ -32,11 +32,14 @@ import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider'
export function Providers({
children,
initialSession,
initialTabId,
initialThemeMode,
}: {
children: React.ReactNode;
/** Resolved from the signed cookie in the root layout — see SessionProvider. */
initialSession: AuthSession | null;
/** Which tab that session was read from — see SessionProvider. */
initialTabId: string | null;
/** Resolved from the theme-mode cookie in the root layout. */
initialThemeMode: ThemeMode;
}) {
@@ -44,7 +47,10 @@ export function Providers({
// Outside <Theme>, because <Theme> takes the mode as a prop — a context
// rendered inside it could never reach it.
<ThemeModeProvider initialMode={initialThemeMode}>
<ThemedProviders initialSession={initialSession}>
<ThemedProviders
initialSession={initialSession}
initialTabId={initialTabId}
>
{children}
</ThemedProviders>
</ThemeModeProvider>
@@ -54,9 +60,11 @@ export function Providers({
function ThemedProviders({
children,
initialSession,
initialTabId,
}: {
children: React.ReactNode;
initialSession: AuthSession | null;
initialTabId: string | null;
}) {
const {mode} = useThemeMode();
@@ -67,7 +75,10 @@ function ThemedProviders({
<MotionConfig reducedMotion="user">
{/* Above the route tree AND outside (workspace), because /login
establishes the session that the workspace then reads. */}
<SessionProvider initialSession={initialSession}>
<SessionProvider
initialSession={initialSession}
initialTabId={initialTabId}
>
<WorkspaceProvider>
<LoyalyAiProvider>{children}</LoyalyAiProvider>
</WorkspaceProvider>

View File

@@ -0,0 +1,213 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Avatar} from '@astryxdesign/core/Avatar';
import {Token} from '@astryxdesign/core/Token';
import {Link} from '@astryxdesign/core/Link';
import {List, ListItem} from '@astryxdesign/core/List';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
import type {ThemeMode} from '@/shared/theme/themeMode';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Icon} from '@astryxdesign/core/Icon';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {ICONS} from '@/shared/utils/icons';
import {useCompanies} from '@/features/admin/hooks/useCompanies';
import {summarise} from '@/features/admin/hooks/useMonitoring';
import {adminHref} from './MonitoringTables';
import {AdminPageHeader} from './common/AdminPageHeader';
import {PendingIntegration} from './common/PendingIntegration';
/**
* The platform admin's own account — Profile and Settings.
*
* ── Only what the platform can answer for an admin ───────────────────────
* The profile is the signed-in session: id, name, email, role. Nothing is
* editable, because the only profile write (`PATCH /api/settings/profile`) is
* tenant-scoped and an admin has no tenant.
*
* Settings has two live parts: appearance (a cookie this app owns) and the
* merchant activity status list (`GET /api/admin/clients`). Password
* change and two-factor have no platform endpoint, so they say so rather than
* render a form that saves nowhere — the merchant Security page's password
* form and 2FA switch are exactly that, and are not reused here.
*/
export function AdminProfile() {
const {user} = useSession();
if (!user) return null;
const name = user.name || user.email;
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Profile"
subtitle="Your platform admin account."
actions={<Link href="/admin/settings">Settings</Link>}
/>
<Card padding={6}>
<HStack gap={4} vAlign="center" className="min-w-0">
<Avatar name={name} size="xl" tooltip={false} />
<VStack gap={1} className="min-w-0">
<Text size="lg" weight="semibold" className="truncate">
{name}
</Text>
<Text size="sm" color="secondary" className="truncate">
{user.email}
</Text>
<HStack>
<Token size="sm" label="Platform admin" />
</HStack>
</VStack>
</HStack>
</Card>
<StaticPanel
title="Account details"
subtitle="As the platform returned them at sign-in."
>
<List>
<ListItem label="Name" endContent={<Text size="sm">{user.name || '—'}</Text>} />
<ListItem label="Email" endContent={<Text size="sm">{user.email}</Text>} />
<ListItem label="Role" endContent={<Text size="sm">Platform admin</Text>} />
<ListItem
label="Scope"
description="A platform admin belongs to no merchant and administers all of them."
endContent={<Text size="sm">All merchants</Text>}
/>
<ListItem
label="Account ID"
endContent={
<Text size="sm" color="secondary">
{user.id}
</Text>
}
/>
</List>
</StaticPanel>
<StaticPanel title="Edit profile">
<PendingIntegration
icon="settings"
title="Name and email cannot be changed here yet"
description="The platform's only profile update is scoped to a merchant, and a platform admin has none. An admin endpoint for the signed-in account is needed."
/>
</StaticPanel>
</VStack>
);
}
export function AdminSettings() {
const {mode, setMode} = useThemeMode();
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Settings"
subtitle="Appearance, merchant activity and sign-in security for your platform admin account."
actions={<Link href="/admin/profile">Profile</Link>}
/>
<StaticPanel
title="Appearance"
subtitle="Saved in this browser. Applies to both consoles."
>
<SegmentedControl
value={mode}
onChange={(v) => setMode(v as ThemeMode)}
label="Theme"
>
<SegmentedControlItem value="light" label="Light" />
<SegmentedControlItem value="dark" label="Dark" />
<SegmentedControlItem value="system" label="System" />
</SegmentedControl>
</StaticPanel>
<MerchantActivityStatus />
<StaticPanel title="Password and two-factor">
<PendingIntegration
icon="security"
title="Password change and two-factor are not available yet"
description="The platform has no endpoint to change your own password or enrol two-factor. Until it does, a platform admin password can only be changed on the platform itself."
/>
</StaticPanel>
</VStack>
);
}
/**
* Every merchant and whether it is active or suspended.
*
* Only what `GET /api/admin/clients` returns: `isActive`, `sites`, `users`,
* `createdAt`. The platform has no last-seen or last-activity field, so none
* is shown. Suspended merchants sort first — they are the rows an admin opens
* this list to find.
*/
function MerchantActivityStatus() {
const companies = useCompanies();
return (
<AsyncBoundary resource={companies} loading={<SkeletonRows count={3} />}>
{(rows) => {
const s = summarise(rows);
const sorted = [...rows].sort(
(a, b) =>
Number(a.isActive) - Number(b.isActive) ||
a.name.localeCompare(b.name),
);
return (
<StaticPanel
title="Merchant activity status"
subtitle={`${s.active} active · ${s.suspended} suspended`}
actions={<Link href={adminHref.merchants}>View all</Link>}
>
<List>
{sorted.map((c) => (
<ListItem
key={c.id}
href={adminHref.company(c.id)}
label={c.name}
description={`${c.sites} ${c.sites === 1 ? 'store' : 'stores'} · ${c.users} ${c.users === 1 ? 'account' : 'accounts'} · Added ${formatDate(c.createdAt)}`}
startContent={<Avatar name={c.name} size="sm" tooltip={false} />}
endContent={
<HStack gap={3} vAlign="center">
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={c.isActive ? 'success' : 'error'}
label={c.isActive ? 'Active' : 'Suspended'}
/>
<Text size="sm">{c.isActive ? 'Active' : 'Suspended'}</Text>
</HStack>
<Icon icon={ICONS.arrowRight} size="sm" color="secondary" />
</HStack>
}
/>
))}
</List>
</StaticPanel>
);
}}
</AsyncBoundary>
);
}
function formatDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}

View File

@@ -0,0 +1,68 @@
'use client';
import {useEffect} from 'react';
import {Center} from '@astryxdesign/core/Center';
import {Spinner} from '@astryxdesign/core/Spinner';
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {destinationForUser} from '@/features/auth/services/roleDestination';
import {AdminShell} from './shell/AdminShell';
/**
* The platform console's frame.
*
* ── Same guard, different shell ──────────────────────────────────────────
* `AuthGuard` is the one the workspace uses, reading the session minted by the
* same login route. The shell is NOT WorkspaceShell: that one carries a store
* switcher, tenant navigation and a tenant account menu, every one of which is
* scoped to a company a platform operator does not have. AdminShell keeps the
* shape — rail, top bar, content, Loyaly AI — and drops the tenancy.
*/
export function AdminLayout({children}: {children: React.ReactNode}) {
return (
<AuthGuard>
<OperatorsOnly>
<AdminShell>{children}</AdminShell>
</OperatorsOnly>
</AuthGuard>
);
}
/**
* Platform admins only. A merchant or staff session never sees this console.
*
* The proxy redirects them before this renders, but it resolves a DOCUMENT
* request from the tab POINTER cookie, while this tab's client session comes
* from its own tab id (see tabSession.ts). With an operator signed in in one
* tab and a merchant in another, the server can render /admin for the
* operator's pointer while this tab holds the merchant.
*
* This used to show an explanation with a "Go to dashboard" link. Now it sends
* the tab straight to its own home — /dashboard for a merchant, /floor for
* staff — and renders nothing of the admin console in between.
*
* A full navigation rather than `router.replace`: the tab session script claims
* the pointer on `beforeunload`, so the next document request is gated as THIS
* tab. A client-side RSC fetch could be bounced back to /admin by the
* operator's pointer — the loop that made the old version give up and explain.
*
* Routing only, like the proxy. Every admin read carries this tab's id, and the
* proxy and platform answer 404 to a non-admin either way.
*/
function OperatorsOnly({children}: {children: React.ReactNode}) {
const {user} = useSession();
const isOperator = user?.isPlatformAdmin === true;
const home = user && !isOperator ? destinationForUser(user) : null;
useEffect(() => {
if (home) window.location.replace(home);
}, [home]);
if (isOperator) return <>{children}</>;
return (
<Center height="100vh" role="status" aria-label="Leaving the admin console">
<Spinner size="lg" />
</Center>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
import {Card} from '@astryxdesign/core/Card';
import {Grid} from '@astryxdesign/core/Grid';
import {VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {List, ListItem} from '@astryxdesign/core/List';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
useAlerts,
useCamera,
useCompany,
useEvents,
useStore,
} from '@/features/admin/hooks/useMonitoring';
import {useAiContext} from '@/features/admin/hooks/useAiContext';
import type {Company} from '@/features/admin/types/company';
import {AdminPageHeader} from './common/AdminPageHeader';
import {AdminSection} from './common/AdminSection';
import {PendingIntegration} from './common/PendingIntegration';
import {CompanyNotFound} from './CompanyDetail';
import {
AlertTable,
CameraStatus,
EventTable,
adminHref,
} from './MonitoringTables';
/**
* One camera, four levels deep: company / store / camera.
*
* ── No fake "LIVE" ───────────────────────────────────────────────────────
* The live feed panel is always the integration-required state today, even
* once camera details arrive. The tenant console streams a camera through its
* own authenticated route; there is no admin equivalent, and a looping
* placeholder with a red LIVE badge would be the single most misleading thing
* this console could draw.
*/
export function CameraDetail({
companyId,
storeId,
cameraId,
}: {
companyId: string;
storeId: string;
cameraId: string;
}) {
const {resource, company} = useCompany(companyId);
return (
<AsyncBoundary
resource={resource}
loading={<SkeletonRows count={4} />}
empty={<CompanyNotFound />}
>
{() =>
company ? (
<CameraBody company={company} storeId={storeId} cameraId={cameraId} />
) : (
<CompanyNotFound />
)
}
</AsyncBoundary>
);
}
function CameraBody({
company,
storeId,
cameraId,
}: {
company: Company;
storeId: string;
cameraId: string;
}) {
const bp = useBreakpoint();
const store = useStore(company.id, storeId);
const camera = useCamera(company.id, storeId, cameraId);
const events = useEvents(company.id, storeId, cameraId);
const alerts = useAlerts(company.id, storeId, cameraId);
const storeName =
store.isAvailable && store.resource.status === 'success'
? store.resource.data.name
: 'Shop';
const cameraName =
camera.isAvailable && camera.resource.status === 'success'
? camera.resource.data.name
: 'Camera';
useAiContext({
level: 'camera',
label: `${cameraName} · ${storeName} · ${company.name}`,
companyId: company.id,
storeId,
cameraId,
});
const twoUp = bp === 'desktop' || bp === 'ultrawide';
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title={cameraName}
subtitle={`${storeName} · ${company.name}`}
back={{label: storeName, href: adminHref.store(company.id, storeId)}}
crumbs={[
{label: 'Merchants', href: adminHref.merchants},
{label: company.name, href: adminHref.shops(company.id)},
{label: storeName, href: adminHref.store(company.id, storeId)},
{label: cameraName},
]}
/>
<Grid columns={twoUp ? 2 : 1} gap={4}>
<AdminSection
title="Camera"
data={camera}
pending={{
icon: 'camera',
title: 'Camera data unavailable',
description:
'Status, last-seen and snapshot time need the platform admin camera-detail API.',
}}
loading={<SkeletonRows count={3} />}
>
{(c) => (
<List>
<ListItem label="Status" endContent={<CameraStatus status={c.status} />} />
<ListItem
label="Camera ID"
description="What the engine knows it by."
endContent={<Text size="sm">{c.cameraId}</Text>}
/>
<ListItem
label="Enabled"
endContent={<Text size="sm">{c.isEnabled ? 'Yes' : 'No'}</Text>}
/>
<ListItem
label="Last seen"
endContent={
<Text size="sm">
{c.lastSeenAt ? new Date(c.lastSeenAt).toLocaleString() : '—'}
</Text>
}
/>
<ListItem
label="Last snapshot"
endContent={
<Text size="sm">
{c.snapshotAt ? new Date(c.snapshotAt).toLocaleString() : '—'}
</Text>
}
/>
</List>
)}
</AdminSection>
<Card>
<VStack gap={4}>
<SectionHeader title="Live feed" />
<PendingIntegration
icon="camera"
title="Live feed unavailable"
description="There is no platform admin stream endpoint. No feed is shown rather than a placeholder that looks live."
/>
</VStack>
</Card>
</Grid>
<Grid columns={twoUp ? 2 : 1} gap={4}>
<AdminSection
title="Alerts"
data={alerts}
pending={{
icon: 'notifications',
title: 'No alerts available',
description:
'Alert monitoring is not currently available for Platform Admin.',
}}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="notifications"
title="No alerts"
description="This camera has raised no alerts."
/>
}
>
{(rows) => <AlertTable alerts={rows} />}
</AdminSection>
<AdminSection
title="Events and AI detections"
data={events}
pending={{
icon: 'events',
title: 'No event data available',
description: 'Platform Admin event API integration is required.',
}}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="events"
title="No events"
description="This camera has not reported any events yet."
/>
}
>
{(rows) => <EventTable events={rows} />}
</AdminSection>
</Grid>
</VStack>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import {useState} from 'react';
import {useRouter} from 'next/navigation';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Icon} from '@astryxdesign/core/Icon';
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
import type {Company} from '@/features/admin/types/company';
export type CompanyDialog = 'suspend' | 'reset' | 'delete';
/**
* The things an operator can do to an existing merchant, in one menu.
*
* Shared by the merchant cards and the merchant page so the two can never
* offer different actions for the same merchant.
*
* There is no "Edit": `PATCH /api/admin/clients/{id}` accepts `active` and
* nothing else, so a rename would be a form that saves nowhere
* (ADMIN_CAPABILITIES.merchantEdit).
*
* Delete is offered only on a suspended merchant, because the platform refuses
* it otherwise. The dialog still sends the request and shows the real 409 if
* it is ever reached another way — the guard here is a courtesy, never the
* rule.
*
* The menu and its dialogs are split so a card can hold the menu while the
* dialogs render outside it — a dialog nested in a clickable card would route
* its clicks through the card.
*/
export function CompanyActionsMenu({
company,
onSelect,
viewHref,
isIconOnly = false,
}: {
company: Company;
onSelect: (dialog: CompanyDialog) => void;
/** Adds a "View details" row — for surfaces that are not the detail page. */
viewHref?: string;
isIconOnly?: boolean;
}) {
const router = useRouter();
return (
<DropdownMenu
button={
isIconOnly
? {
variant: 'ghost',
size: 'sm',
label: `Actions for ${company.name}`,
isIconOnly: true,
icon: <Icon icon="moreHorizontal" size="sm" />,
}
: {
variant: 'secondary',
size: 'sm',
label: 'Manage',
icon: <Icon icon="moreHorizontal" size="sm" />,
}
}
hasChevron={!isIconOnly}
menuWidth={220}
items={[
...(viewHref
? [
{label: 'View details', onClick: () => router.push(viewHref)},
{type: 'divider' as const},
]
: []),
{label: 'Reset owner password', onClick: () => onSelect('reset')},
{
label: company.isActive ? 'Deactivate merchant' : 'Reinstate merchant',
onClick: () => onSelect('suspend'),
},
...(company.isActive
? []
: [
{type: 'divider' as const},
{label: 'Remove merchant', onClick: () => onSelect('delete')},
]),
]}
/>
);
}
export function CompanyActionDialog({
company,
dialog,
onClose,
onChanged,
onDeleted,
}: {
company: Company;
dialog: CompanyDialog;
onClose: () => void;
/** After a suspend/reinstate — refetch, never patch optimistically. */
onChanged: () => void;
onDeleted: () => void;
}) {
if (dialog === 'suspend') {
return (
<SuspendCompanyDialog company={company} onClose={onClose} onDone={onChanged} />
);
}
if (dialog === 'reset') {
return <ResetOwnerPasswordDialog company={company} onClose={onClose} />;
}
return (
<DeleteCompanyDialog company={company} onClose={onClose} onDeleted={onDeleted} />
);
}
/** Menu and dialogs together, for a surface with no clickable container. */
export function CompanyActions({
company,
onChanged,
onDeleted,
isIconOnly = false,
}: {
company: Company;
onChanged: () => void;
onDeleted: () => void;
isIconOnly?: boolean;
}) {
const [dialog, setDialog] = useState<CompanyDialog | null>(null);
return (
<>
<CompanyActionsMenu
company={company}
onSelect={setDialog}
isIconOnly={isIconOnly}
/>
{dialog ? (
<CompanyActionDialog
company={company}
dialog={dialog}
onClose={() => setDialog(null)}
onChanged={onChanged}
onDeleted={onDeleted}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,511 @@
'use client';
import {useState} from 'react';
import {useRouter, useSearchParams} from 'next/navigation';
import {Card} from '@astryxdesign/core/Card';
import {ClickableCard} from '@astryxdesign/core/ClickableCard';
import {Grid} from '@astryxdesign/core/Grid';
import {HStack, VStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Badge} from '@astryxdesign/core/Badge';
import {Icon} from '@astryxdesign/core/Icon';
import {TabList, Tab} from '@astryxdesign/core/TabList';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {ICONS} from '@/shared/utils/icons';
import type {IconKey} from '@/shared/utils/icons';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {
SkeletonMetricGrid,
SkeletonRows,
} from '@/shared/components/patterns/LoadingState';
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
import {
useCompany,
useCompanyOwner,
useCompanyStores,
} from '@/features/admin/hooks/useMonitoring';
import {useAiContext} from '@/features/admin/hooks/useAiContext';
import type {Company} from '@/features/admin/types/company';
import {AdminPageHeader, BackButton} from './common/AdminPageHeader';
import {CompanyActions} from './CompanyActions';
import {adminHref} from './MonitoringTables';
import {ShopCard, ShopList} from './ShopCards';
import {PendingIntegration} from './common/PendingIntegration';
import {
ADMIN_CAPABILITIES,
MERCHANT_AREAS,
} from '@/features/admin/config/capabilities';
import type {AdminData} from '@/features/admin/hooks/useMonitoring';
import type {AdminStore} from '@/features/admin/types/monitoring';
/**
* One merchant: what the platform knows about it, and the way down to its
* shops.
*
* ── Only the fields the platform returns ─────────────────────────────────
* Name, slug, status, shop count, account count, created — from the merchant
* row — plus the owner's name and email from the detail read, when the
* platform serves it. There is no plan or contact beyond that. If the shops
* cannot be listed, the Shops tab says so rather than rendering an empty
* table that would read as "this merchant has no shops".
*
* ── Tabs only for what can be answered ───────────────────────────────────
* General and Shops. Sales persons, cameras, customers, sales, analytics,
* footfall and device logs are listed on General with their backend state
* (MERCHANT_AREAS) instead of as tabs that could only say "unavailable".
*/
export function CompanyDetail({companyId}: {companyId: string}) {
const router = useRouter();
const {resource, company} = useCompany(companyId);
useAiContext(
company
? {level: 'company', label: company.name, companyId: company.id}
: null,
);
return (
<AsyncBoundary
resource={resource}
loading={
<VStack gap={6} width="100%">
<SkeletonRows count={2} />
<SkeletonMetricGrid columns={4} />
</VStack>
}
empty={<CompanyNotFound />}
>
{() =>
company ? (
<CompanyBody
company={company}
onChanged={resource.refetch}
onDeleted={() => router.replace(adminHref.merchants)}
/>
) : (
<CompanyNotFound />
)
}
</AsyncBoundary>
);
}
export function CompanyNotFound() {
return (
<VStack gap={6} width="100%">
<AdminPageHeader
title="Merchant not found"
crumbs={[
{label: 'Merchants', href: adminHref.merchants},
{label: 'Not found'},
]}
/>
<Card>
<EmptyPanel
icon="companies"
title="No merchant with this id"
description="It may have been deleted, or the link is wrong. The merchant list shows every merchant on the platform."
actions={
<Button
size="sm"
variant="secondary"
href={adminHref.merchants}
label="Back to merchants"
/>
}
/>
</Card>
</VStack>
);
}
function CompanyBody({
company,
onChanged,
onDeleted,
}: {
company: Company;
onChanged: () => void;
onDeleted: () => void;
}) {
const router = useRouter();
const searchParams = useSearchParams();
// The tab lives in the URL, so "back" from a shop lands on Shops, and a
// Shops link can be shared.
const tab: MerchantTab =
searchParams.get('tab') === 'shops' ? 'shops' : 'general';
const setTab = (t: MerchantTab) =>
router.replace(
t === 'shops'
? adminHref.shops(company.id)
: adminHref.company(company.id),
{scroll: false},
);
const status = company.isActive ? 'Active' : 'Suspended';
// One request feeds the tab badge, the General preview and the Shops tab,
// so the count shown and the shops listed can never disagree. Until the
// list exists, the merchant row's own `sites` is the only count there is.
const stores = useCompanyStores(company.id);
const listed =
stores.isAvailable &&
(stores.resource.status === 'success' || stores.resource.status === 'empty')
? stores.resource.data
: undefined;
const shopCount = listed ? listed.length : company.sites;
return (
<VStack gap={6} width="100%">
<HStack>
<BackButton label="Merchants" href={adminHref.merchants} />
</HStack>
{/* Identity: who this is, whether they can sign in, and what can be
done to them — in one place, above everything that describes them. */}
<Card padding={6}>
<HStack
gap={4}
vAlign="center"
hAlign="between"
width="100%"
className="flex-wrap gap-y-4"
>
<HStack gap={4} vAlign="center" className="min-w-0">
<HStack
hAlign="center"
vAlign="center"
className="size-14 rounded-xl bg-muted shrink-0"
>
<Icon icon={ICONS.companies} size="lg" color="secondary" />
</HStack>
<VStack gap={1} className="min-w-0">
<Text type="display-3" weight="medium" className="break-words">
{company.name}
</Text>
<HStack gap={3} vAlign="center" className="flex-wrap gap-y-1">
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={company.isActive ? 'success' : 'error'}
label={status}
/>
<Text size="sm" color="secondary">
{status}
</Text>
</HStack>
<Text size="sm" color="secondary">
@{company.slug}
</Text>
<Text size="sm" color="secondary">
Created {fmtDate(company.createdAt)}
</Text>
</HStack>
</VStack>
</HStack>
<CompanyActions
company={company}
onChanged={onChanged}
onDeleted={onDeleted}
/>
</HStack>
</Card>
<TabList
value={tab}
onChange={(v) => setTab(v as MerchantTab)}
hasDivider
>
<Tab value="general" label="General" />
<Tab
value="shops"
label="Shops"
endContent={<Badge label={shopCount.toLocaleString()} />}
/>
</TabList>
{tab === 'general' ? (
<MerchantGeneral
company={company}
stores={stores}
shopCount={shopCount}
onOpenShops={() => setTab('shops')}
/>
) : (
<MerchantShops
company={company}
stores={stores}
shopCount={shopCount}
/>
)}
</VStack>
);
}
type MerchantTab = 'general' | 'shops';
const fmtDate = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
const DAY_MS = 24 * 60 * 60 * 1000;
function MerchantGeneral({
company,
stores,
shopCount,
onOpenShops,
}: {
company: Company;
stores: AdminData<AdminStore[]>;
shopCount: number;
onOpenShops: () => void;
}) {
const bp = useBreakpoint();
const owner = useCompanyOwner(company.id);
// Read once per mount: rendering must stay pure, and a day count does not
// need to tick while the page is open.
const [now] = useState(() => Date.now());
// Derived from created_at, which the row carries — not an estimate.
const days = Math.max(
0,
Math.floor((now - new Date(company.createdAt).getTime()) / DAY_MS),
);
// Merchant-level areas the platform cannot answer yet. Cameras and their
// heartbeat live inside a shop, so they are not listed here.
const upcoming = MERCHANT_AREAS.filter(
(a) =>
!['shops', 'cameras', 'heartbeat'].includes(a.key) &&
!ADMIN_CAPABILITIES[a.capability],
).map((a) => a.label);
return (
<VStack gap={6} width="100%">
<Grid columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 4} gap={4}>
<Fact
icon="stores"
label="Shops"
value={shopCount.toLocaleString()}
caption="Registered to this merchant"
action={{label: 'View shops →', onClick: onOpenShops}}
/>
<Fact
icon="staff"
label="Merchant accounts"
value={company.users.toLocaleString()}
caption={
owner
? `Owner: ${[owner.name, owner.email].filter(Boolean).join(' · ')}`
: 'Logins on this merchant'
}
/>
<Fact
icon="expiry"
label="Created"
value={fmtDate(company.createdAt)}
caption={`${days.toLocaleString()} ${days === 1 ? 'day' : 'days'} on the platform`}
/>
<Fact
icon={company.isActive ? 'present' : 'absent'}
label="Status"
value={company.isActive ? 'Active' : 'Suspended'}
caption={
company.isActive ? 'Can sign in' : 'Sign-in blocked for everyone'
}
/>
</Grid>
<Card padding={6}>
<VStack gap={5} width="100%">
<SectionHeader
title="Shops"
subtitle={`The shops and businesses ${company.name} runs. Open one to see its cameras, footfall and sales.`}
actions={
shopCount > 0 ? (
<Button
size="sm"
variant="secondary"
label="View all shops"
onClick={onOpenShops}
/>
) : undefined
}
/>
{stores.isAvailable ? (
<AsyncBoundary
resource={stores.resource}
loading={<SkeletonRows count={2} />}
empty={
<Text size="sm" color="secondary">
{company.name} has not opened a shop yet.
</Text>
}
>
{(rows) => (
<Grid
columns={bp === 'mobile' ? 1 : bp === 'tablet' ? 2 : 3}
gap={4}
>
{[...rows]
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, PREVIEW)
.map((s) => (
<ShopCard key={s.id} companyId={company.id} shop={s} />
))}
</Grid>
)}
</AsyncBoundary>
) : (
<ShopsPending company={company} />
)}
</VStack>
</Card>
{upcoming.length > 0 ? (
<Text size="xsm" color="secondary">
Also coming to this merchant as the platform exposes them:{' '}
{upcoming.join(' · ')}.
</Text>
) : null}
</VStack>
);
}
const PREVIEW = 3;
/** Why the shops cannot be opened yet — said once, in engineering terms. */
function ShopsPending({company}: {company: Company}) {
const n = company.sites;
return (
<PendingIntegration
icon="stores"
title="Shops cannot be listed yet"
description={
n === 0
? `${company.name} has not opened a shop.`
: `The platform reports ${n} ${n === 1 ? 'shop' : 'shops'} for ${company.name} but has no platform-admin endpoint to list them. Once GET /api/admin/clients/{id}/sites ships, every shop opens from here.`
}
/>
);
}
function MerchantShops({
company,
stores,
shopCount,
}: {
company: Company;
stores: AdminData<AdminStore[]>;
shopCount: number;
}) {
return (
<Card padding={6}>
<VStack gap={5} width="100%">
<SectionHeader
title="Shops"
subtitle={`${shopCount.toLocaleString()} ${
shopCount === 1 ? 'shop' : 'shops'
} registered to ${company.name}.`}
/>
{stores.isAvailable ? (
<AsyncBoundary
resource={stores.resource}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="stores"
title="No shops"
description={`${company.name} has not opened a shop yet.`}
/>
}
>
{(rows) => <ShopList companyId={company.id} shops={rows} />}
</AsyncBoundary>
) : (
<ShopsPending company={company} />
)}
</VStack>
</Card>
);
}
/** A labelled fact with its icon. Not a KPI card: these do not trend. */
function Fact({
icon,
label,
value,
caption,
action,
}: {
icon: IconKey;
label: string;
value: string;
caption: string;
/** Makes the whole card a way in — e.g. Shops → the Shops tab. */
action?: {label: string; onClick: () => void};
}) {
const body = (
<FactBody icon={icon} label={label} value={value} caption={caption}>
{action ? (
<Text size="sm" weight="medium">
{action.label}
</Text>
) : null}
</FactBody>
);
return action ? (
<ClickableCard
label={`${label}: ${action.label}`}
onClick={action.onClick}
padding={4}
className="h-full"
>
{body}
</ClickableCard>
) : (
<Card padding={4} className="h-full">
{body}
</Card>
);
}
function FactBody({
icon,
label,
value,
caption,
children,
}: {
icon: IconKey;
label: string;
value: string;
caption: string;
children?: React.ReactNode;
}) {
return (
<VStack gap={3} width="100%">
<HStack hAlign="between" vAlign="center" width="100%">
<Text size="sm" color="secondary" weight="medium">
{label}
</Text>
<HStack
hAlign="center"
vAlign="center"
className="size-7 rounded-md bg-muted shrink-0"
>
<Icon icon={ICONS[icon]} size="sm" color="secondary" />
</HStack>
</HStack>
<Text size="xl" weight="semibold" className="tracking-tight">
{value}
</Text>
<Text size="xsm" color="secondary">
{caption}
</Text>
{children}
</VStack>
);
}

Some files were not shown because too many files have changed in this diff Show More