update the adminpage ui

This commit is contained in:
2026-09-25 16:29:41 +05:30
parent 91e4db8217
commit b36a30385c
168 changed files with 10535 additions and 1008 deletions

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,26 @@
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';
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);
const [activities, impact] = await Promise.all([
engagementApi.activities(token, window, site),
engagementApi.impact(token, window, site),
]);
return toActivityRows(activities, impact);
});
}

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

@@ -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

@@ -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

@@ -0,0 +1,16 @@
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';
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));
const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId));
return (list ?? []).map(toCampaign);
});
}

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,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,21 @@
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';
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));
return toJourney(
await engagementApi.journey(token, window, toSiteParam(query.storeId)),
);
});
}

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

@@ -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,13 +1,14 @@
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.
* 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
@@ -24,6 +25,7 @@ function toSite(s: ApiSite): Site {
id: s.slug || s.site_id,
uuid: s.site_id,
name: s.name,
timezone: s.timezone,
isOnline: s.online ?? null,
camerasTotal: s.cameras_total ?? null,
camerasUp: s.cameras_up ?? null,
@@ -36,3 +38,25 @@ export async function GET(req: NextRequest) {
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

@@ -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

@@ -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
@@ -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),
);
}