update the adminpage ui
This commit is contained in:
33
src/app/api/activities/events/route.ts
Normal file
33
src/app/api/activities/events/route.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
18
src/app/api/activities/impact/route.ts
Normal file
18
src/app/api/activities/impact/route.ts
Normal 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))) ?? [];
|
||||
});
|
||||
}
|
||||
26
src/app/api/activities/route.ts
Normal file
26
src/app/api/activities/route.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
42
src/app/api/auth/invitation/route.ts
Normal file
42
src/app/api/auth/invitation/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
109
src/app/api/auth/register/route.ts
Normal file
109
src/app/api/auth/register/route.ts
Normal 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;
|
||||
}
|
||||
21
src/app/api/cameras/[id]/live/route.ts
Normal file
21
src/app/api/cameras/[id]/live/route.ts
Normal 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));
|
||||
}
|
||||
16
src/app/api/campaigns/route.ts
Normal file
16
src/app/api/campaigns/route.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
23
src/app/api/dashboard/summary/route.ts
Normal file
23
src/app/api/dashboard/summary/route.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
29
src/app/api/purchases/route.ts
Normal file
29
src/app/api/purchases/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
21
src/app/api/reports/journey/route.ts
Normal file
21
src/app/api/reports/journey/route.ts
Normal 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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
35
src/app/api/sites/[site]/check/route.ts
Normal file
35
src/app/api/sites/[site]/check/route.ts
Normal 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);
|
||||
}
|
||||
40
src/app/api/sites/[site]/route.ts
Normal file
40
src/app/api/sites/[site]/route.ts
Normal 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));
|
||||
}
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
|
||||
19
src/app/api/visitors/[id]/history/route.ts
Normal file
19
src/app/api/visitors/[id]/history/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
37
src/app/api/visitors/[id]/image/route.ts
Normal file
37
src/app/api/visitors/[id]/image/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
23
src/app/api/visitors/[id]/profile/route.ts
Normal file
23
src/app/api/visitors/[id]/profile/route.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
23
src/app/api/visitors/[id]/route.ts
Normal file
23
src/app/api/visitors/[id]/route.ts
Normal 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));
|
||||
}
|
||||
28
src/app/api/visitors/route.ts
Normal file
28
src/app/api/visitors/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
27
src/app/api/visits/stream/route.ts
Normal file
27
src/app/api/visits/stream/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user