update ui and remove mock data

This commit is contained in:
2026-09-09 19:50:52 +05:30
parent 5ce580dced
commit bfe4d3d2f2
172 changed files with 4280 additions and 8775 deletions

View File

@@ -0,0 +1,78 @@
import type {NextRequest} from 'next/server';
import {assistantApi} from '@/services/api/assistantApi';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failureFrom} from '@/shared/services/bff';
import type {ApiAssistantTurn} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* POST /api/assistant — Loyaly AI, against the platform's own assistant.
*
* ── What this replaced ───────────────────────────────────────────────────
* This route did not exist. The chat panel called
* `services/ai/mockAi.ts`, which classified the prompt by keyword and returned
* a hardcoded template — "Indiranagar Flagship · 12,400 visitors · ₹9.1L",
* "98% confidence", "Conduct staff training on checkout upsell workflows" —
* with no network call anywhere in the feature. Four shops that do not exist,
* revenue nobody earned, and a confidence score for a number that was a string
* literal. A merchant cannot tell that from a real answer, which is the whole
* reason it had to go.
*
* Every figure the assistant now quotes comes back from a platform tool that
* computed it, scoped to the signed-in user's own tenant.
*
* ── Errors are answers here, not failures ────────────────────────────────
* `501 assistant_off` means this deployment has no assistant configured. It is
* a supported state and the panel says so plainly. The one thing this route
* must never do is invent a reply to fill the gap.
*/
export async function POST(req: NextRequest) {
let history: ApiAssistantTurn[];
try {
const body = (await req.json()) as {history?: unknown};
if (!Array.isArray(body.history)) {
return Response.json(
{error: {code: 'bad_request', message: 'Ask a question.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
// Normalised here rather than trusted: the platform coerces anything that
// is not "assistant" to "user" anyway, and sending it a shape it has to
// repair is how a client starts depending on that repair.
history = body.history
.map((t) => t as {role?: unknown; text?: unknown})
.filter((t) => typeof t.text === 'string' && t.text.trim() !== '')
.map((t) => ({
role: t.role === 'assistant' ? ('assistant' as const) : ('user' as const),
text: String(t.text),
}));
} catch {
return Response.json(
{error: {code: 'bad_request', message: 'Malformed request body.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
if (history.length === 0) {
return Response.json(
{error: {code: 'bad_request', message: 'Ask a question.'}},
{status: 400, headers: {'cache-control': 'no-store'}},
);
}
try {
const answer = await withUpstream((token) => assistantApi.ask(token, history));
return Response.json(answer, {headers: {'cache-control': 'no-store'}});
} catch (err) {
const f = failureFrom(err);
// `reason` carries the platform's own code — `assistant_off`,
// `assistant_misconfigured` — so the panel can distinguish "switched off
// here" from "broken" without matching on prose that is rewritten freely.
return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason},
{status: f.status, headers: {'cache-control': 'no-store'}},
);
}
}

View File

@@ -1,6 +1,7 @@
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {verifyCredentials} from '@/features/auth/mock/users.mock';
import {authApi} from '@/services/api/authApi';
import {UpstreamError} from '@/services/api/apiClient';
import {
LOGIN_ERROR_PARAM,
type LoginErrorCode,
@@ -13,232 +14,145 @@ import {
createSessionToken,
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
import type {AuthSession, LoginError} from '@/features/auth/types/auth';
import type {ApiFailure, ApiSuccess} from '@/shared/types/api';
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/login
* POST /api/auth/login — the credential exchange, now against the platform.
*
* The seam a real backend replaces. Everything above it — the repository, the
* service, the form — speaks in {credentials} → {session | error} and does not
* care whether the check happened against a fixture or an identity provider.
*
* Responsibilities that deliberately live HERE and not in the client:
* • deciding whether the credentials are valid
* • deciding how long the session lasts (rememberMe is a request, not an
* instruction — the server sets the cookie lifetime)
* • issuing the httpOnly cookie the client can never read or forge
* This route is the BFF's front door. It swaps an email and password for a
* platform token pair, seals that pair into an httpOnly cookie the browser
* cannot read, and hands back only the user object. The access token never
* reaches JavaScript, so an XSS on this origin cannot steal a session.
*
* ── Two content types, one endpoint ──────────────────────────────────────
* It answers both `application/json` (the hydrated form, via authRepository)
* and `application/x-www-form-urlencoded` (the browser posting the form
* natively, before React has hydrated or when its bundle never arrived).
*
* That second path is not a nicety, it is the fix for a real leak. The sign-in
* form has named inputs; a <form> with no method and no action submits GET to
* its own URL, so a submit landing in the pre-hydration window rewrote the
* address bar to `/login?email=…&password=…` — putting the password in the
* URL bar, in browser history, in the referrer and in every access log between
* here and the user. Declaring method="post" action="/api/auth/login" means
* the worst case is now a normal POST with the credentials in the body.
*
* The two paths differ ONLY in how the answer is shaped: JSON gets an
* envelope, a native post gets a 303 redirect, because a browser that just
* submitted a form needs somewhere to land, not a document full of braces.
* It answers both `application/json` (the hydrated form) and
* `application/x-www-form-urlencoded` (the browser posting natively, before
* React has hydrated). That second path is not a nicety: the sign-in form has
* named inputs, and a <form> with no method submits GET to its own URL — which
* put the password in the address bar, in history, and in every access log
* between here and the user.
*/
interface LoginRequestBody {
email?: unknown;
password?: unknown;
rememberMe?: unknown;
}
/** What the request asked for, normalised across both content types. */
interface ParsedLogin {
email: string;
password: string;
rememberMe: boolean;
/** Only meaningful on the native path — where to land after success. */
isForm: boolean;
next: string | null;
/** True when the browser posted the form itself, so answer in redirects. */
isFormPost: boolean;
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
async function parse(req: NextRequest): Promise<ParsedLogin> {
const type = req.headers.get('content-type') ?? '';
async function parseRequest(req: NextRequest): Promise<ParsedLogin | null> {
const contentType = req.headers.get('content-type') ?? '';
if (
contentType.includes('application/x-www-form-urlencoded') ||
contentType.includes('multipart/form-data')
) {
const form = await req.formData();
if (type.includes('application/json')) {
const body = (await req.json()) as Record<string, unknown>;
return {
email: asString(form.get('email')).trim(),
password: asString(form.get('password')),
// An unchecked checkbox is absent from the payload entirely; any present
// value means checked. Never parsed as a boolean-ish string.
rememberMe: form.get('rememberMe') !== null,
next: asString(form.get('next')) || null,
isFormPost: true,
email: String(body.email ?? '').trim(),
password: String(body.password ?? ''),
rememberMe: body.rememberMe === true,
isForm: false,
next: typeof body.next === 'string' ? body.next : null,
};
}
let body: LoginRequestBody;
try {
body = (await req.json()) as LoginRequestBody;
} catch {
return null;
}
const form = await req.formData();
return {
email: asString(body.email).trim(),
password: asString(body.password),
rememberMe: body.rememberMe === true,
next: null,
isFormPost: false,
email: String(form.get('email') ?? '').trim(),
password: String(form.get('password') ?? ''),
rememberMe: form.get('rememberMe') === 'on' || form.get('rememberMe') === 'true',
isForm: true,
next: typeof form.get('next') === 'string' ? String(form.get('next')) : null,
};
}
/**
* A failure, in whichever dialect the caller speaks.
*
* The redirect carries a code, never the submitted values — bouncing the email
* back through the URL to repopulate the field would reintroduce exactly the
* leak this endpoint exists to close.
*/
function failure(
req: NextRequest,
parsed: Pick<ParsedLogin, 'isFormPost' | 'next'> | null,
code: LoginErrorCode,
error: LoginError,
status: number,
): NextResponse {
if (parsed?.isFormPost) {
const target = new URL('/login', req.url);
target.searchParams.set(LOGIN_ERROR_PARAM, code);
// Preserve the deep link so a failed attempt does not cost the user the
// page they were originally trying to reach. Validated, not echoed raw.
const next = resolveRedirectTarget(parsed.next);
if (next !== '/dashboard') target.searchParams.set('next', next);
// 303: the browser must follow with GET, not repeat the POST.
return NextResponse.redirect(target, 303);
}
// Shaped as the app's standard envelope so the client's error path is the
// same one every other endpoint uses; `field` rides alongside for the form.
const body: ApiFailure & {field: LoginError['field']} = {
error: {
code: status === 401 ? 'unauthorized' : 'bad_request',
message: error.message,
},
field: error.field,
};
return NextResponse.json(body, {
status,
headers: {'cache-control': 'no-store'},
});
function failJson(code: LoginErrorCode, message: string, status: number) {
return Response.json(
{error: {code: status === 429 ? 'bad_request' : 'unauthorized', message}, field: 'form', reason: code},
{status, headers: {'cache-control': 'no-store'}},
);
}
export async function POST(req: NextRequest): Promise<NextResponse> {
const parsed = await parseRequest(req);
export async function POST(req: NextRequest) {
const {email, password, rememberMe, isForm, next} = await parse(req);
if (!parsed) {
return failure(
req,
null,
'malformed',
{field: 'form', message: 'Malformed request body.'},
400,
);
if (!email || !password) {
const code: LoginErrorCode = !email ? 'email_required' : 'password_required';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(code, 'Enter your email address and password.', 400);
}
// Server-side validation, repeated rather than trusted from the client. The
// form validates too, for latency; this validates because the form is not a
// security boundary and a POST can arrive without it.
if (!parsed.email) {
return failure(req, parsed, 'email_required', {
field: 'email',
message: 'Enter your email address.',
}, 400);
}
if (!/^\S+@\S+\.\S+$/.test(parsed.email)) {
return failure(req, parsed, 'email_invalid', {
field: 'email',
message: 'Enter a valid email address.',
}, 400);
}
if (!parsed.password) {
return failure(req, parsed, 'password_required', {
field: 'password',
message: 'Enter your password.',
}, 400);
let bundle;
try {
bundle = await authApi.login(email, password);
} catch (err) {
const up = err instanceof UpstreamError ? err : null;
// The platform answers wrong-password and no-such-account identically, on
// purpose: telling them apart turns this form into a way to find out who
// works at a customer. Pass its message through rather than writing our own.
/*
* A failure to REACH the platform is not a failure to authenticate.
*
* `UpstreamError.status === 0` means the request never arrived — offline,
* DNS, TLS — and a contract mismatch (502) means it arrived somewhere that
* is not the Loyaly platform. Reporting either as "invalid email or
* password" sends somebody to reset a password that was never the problem,
* so those keep their own message and their own status.
*/
const isUnreachable = up ? up.status === 0 || up.status >= 500 : true;
const code: LoginErrorCode = isUnreachable
? 'platform_unreachable'
: up?.code === 'too_many_attempts'
? 'too_many_attempts'
: 'invalid_credentials';
const status = isUnreachable ? 502 : up?.status === 429 ? 429 : 401;
const message = up?.message ?? 'Invalid email or password.';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(code, message, status);
}
const check = verifyCredentials(parsed.email, parsed.password);
await storeTokens(bundle);
if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') {
return failure(req, parsed, 'invalid_credentials', {
field: 'form',
message: 'Invalid email or password.',
}, 401);
}
const maxAge = parsed.rememberMe
? REMEMBERED_MAX_AGE_SECONDS
: SESSION_MAX_AGE_SECONDS;
const token = createSessionToken(
const user = toAuthUser(bundle.user);
const maxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;
const sessionCookie = createSessionToken(
{
sub: check.user.id,
email: check.user.email,
name: check.user.name,
role: check.user.role,
organisation: check.user.organisation,
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
organisation: user.organisation,
},
maxAge,
);
const session: AuthSession = {
user: check.user,
expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
};
const session: AuthSession = {user, expiresAt: bundle.expires_at};
const response = parsed.isFormPost
? // 303 so the browser re-issues as GET. Without it, a refresh on the
// landing page would re-POST the credentials.
NextResponse.redirect(
new URL(resolveRedirectTarget(parsed.next), req.url),
303,
)
: NextResponse.json(
{
data: session,
meta: {generatedAt: new Date().toISOString()},
} satisfies ApiSuccess<AuthSession>,
const res = isForm
? NextResponse.redirect(new URL(resolveRedirectTarget(next), req.url), 303)
: NextResponse.json<ApiSuccess<AuthSession>>(
{data: session, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
/**
* Set on the response rather than through the `cookies()` store, because
* this response may be a redirect: the header has to ride along with the 303
* itself or the browser follows it to the dashboard still signed out, gets
* bounced back to /login by the proxy, and the sign-in appears to have
* silently failed.
*
* A browser-session cookie still needs a server-side expiry, or a tab left
* open for a week would hold a valid token indefinitely — hence the token's
* own `exp` regardless of whether maxAge is sent.
*/
response.cookies.set(
SESSION_COOKIE,
token,
sessionCookieOptions(parsed.rememberMe ? maxAge : undefined),
);
return response;
res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(maxAge));
return res;
}

View File

@@ -1,29 +1,50 @@
import {cookies} from 'next/headers';
import {
SESSION_COOKIE,
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
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 {peekAccessToken} from '@/features/auth/services/upstreamSession';
export const dynamic = 'force-dynamic';
/**
* POST /api/auth/logout
*
* Ending a session is a server action, not a client one: only the server can
* invalidate an httpOnly cookie. The client clearing its own state would leave
* the credential intact and the next request still authenticated.
* 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.
*
* Overwritten with an expired value rather than only `.delete()`-ed — some
* proxies drop a bare deletion, and an empty value fails signature
* verification anyway, so the session is dead by two independent routes.
* No refresh attempt: the token is about to be thrown away, so spending a
* refresh token to revoke it is pure waste.
*/
export async function POST(): Promise<Response> {
const store = await cookies();
store.set(SESSION_COOKIE, '', sessionCookieOptions(0));
store.delete(SESSION_COOKIE);
export async function POST() {
const accessToken = await peekAccessToken();
return Response.json(
if (accessToken) {
try {
await authApi.logout(accessToken);
} catch {
// Already-expired, revoked, or unreachable — all fine. The cookies below
// are what actually ends this browser's session.
}
}
const res = NextResponse.json(
{data: {ok: true}, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
// 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, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
}

View File

@@ -1,42 +1,77 @@
import {cookies} from 'next/headers';
import {findUserById} from '@/features/auth/mock/users.mock';
import {
SESSION_COOKIE,
verifySessionToken,
} from '@/features/auth/services/sessionToken';
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 {NoSessionError, withUpstream} 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';
/**
* GET /api/auth/session
* GET /api/auth/session — is this browser really signed in?
*
* The client's only source of truth about who it is. It cannot read the
* httpOnly cookie, so it asks — and the answer comes from verifying a
* signature, not from believing something the browser stored.
* This asks the PLATFORM, every time, rather than trusting the cookie. That is
* the whole point: a signed cookie proves only that this server minted it, so
* a user who was deactivated, whose role changed, or whose session was revoked
* from another device would otherwise keep a working console until the cookie
* happened to expire.
*
* Returns 200 with `data: null` for "no session" rather than 401. A signed-out
* visitor is a normal state for this endpoint, not an error, and modelling it
* as one means every caller has to special-case a failure that is not one.
* The cost is one upstream call per page load, and it buys the property the
* brief asks for: refreshing the browser preserves a session only when the
* auth provider confirms it is valid.
*
* The user is re-resolved from the directory rather than read straight off the
* token: a role change or a deactivation must take effect on the next request,
* not whenever the cookie happens to expire.
* On a confirmed 401 the cookies are cleared in the response, so the very next
* navigation is redirected to /login by the proxy rather than looping through
* a shell that cannot load data.
*/
export async function GET(): Promise<Response> {
const store = await cookies();
const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value);
export async function GET() {
try {
const user = await withUpstream((token) => authApi.me(token));
const user = payload ? findUserById(payload.sub) : null;
const session: AuthSession = {
user: toAuthUser(user),
// The cookie's own expiry is the browser-side lifetime; the platform's
// access token expiry is refreshed transparently underneath it.
expiresAt: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString(),
};
const body: ApiSuccess<AuthSession | null> = {
data:
payload && user
? {user, expiresAt: new Date(payload.exp * 1000).toISOString()}
: null,
meta: {generatedAt: new Date().toISOString()},
};
return NextResponse.json(
{data: session, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},
);
} catch (err) {
const isAnonymous =
err instanceof NoSessionError ||
(err instanceof UpstreamError && err.status === 401);
return Response.json(body, {headers: {'cache-control': 'no-store'}});
// A network failure is NOT a sign-out. Returning null here would log
// everyone out the moment the platform blipped; a 503 lets the client keep
// the shell it already has and retry.
if (!isAnonymous) {
const message =
err instanceof UpstreamError
? err.message
: 'Could not reach Loyaly. Retrying shortly.';
return NextResponse.json(
{error: {code: 'internal', message}},
{status: 503, headers: {'cache-control': 'no-store'}},
);
}
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, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
}
}

View File

@@ -1,17 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildActivityMetrics} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(
wantsEmpty(q) ? [] : buildActivityMetrics(q.range, q.storeId, q.nowMs),
q,
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildActivity} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildActivity(q.storeId, q.nowMs), q);
}

View File

@@ -1,19 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildBriefing} from '@/features/dashboard/mock/briefing.mock';
export const dynamic = 'force-dynamic';
const EMPTY = {summary: '', alerts: [], tasks: []};
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(
wantsEmpty(q) ? EMPTY : buildBriefing(q.storeId, q.range, q.nowMs),
q,
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildCampaigns} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildCampaigns(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,20 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildInsights} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
/**
* Separate from /briefing on purpose. The briefing is Loyaly AI's narrative
* for the panel; these are store-intelligence findings computed from the
* activity layer, and the dashboard must be able to render one without
* pulling in the other.
*/
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildInsights(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildJourney} from '@/features/dashboard/mock/intelligence.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildJourney(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildKpis} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildKpis(q.range, q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildPeakHours} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildPeakHours(q.storeId), q);
}

View File

@@ -1,22 +0,0 @@
import type {NextRequest} from 'next/server';
import type {Granularity} from '@/features/dashboard/types/dashboard';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildPeriodPerformance} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const raw = req.nextUrl.searchParams.get('granularity');
const granularity: Granularity = raw === 'monthly' ? 'monthly' : 'weekly';
return ok(
wantsEmpty(q) ? [] : buildPeriodPerformance(granularity, q.storeId, q.nowMs),
q,
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRewardUsage} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRewardUsage(q.storeId, q.range), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStoreComparison} from '@/features/dashboard/mock/analytics.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStoreComparison(q.range, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildTimeseries} from '@/features/dashboard/mock/dashboard.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildTimeseries(q.range, q.storeId, q.nowMs), q);
}

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

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildLytActivity} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildLytActivity(q.storeId, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRedemptions} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRedemptions(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildRewards} from '@/features/lyts/mock/lyts.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildRewards(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -0,0 +1,35 @@
import type {NextRequest} from 'next/server';
import {reportsApi} from '@/services/api/reportsApi';
import {toConversion} from '@/services/api/reportMapper';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {previousWindow} from '@/services/api/previousWindow';
import {serveUpstream} from '@/shared/services/bff';
import type {ApiConversionReport, BucketSize} from '@/services/api/types';
export const dynamic = 'force-dynamic';
const BUCKETS: BucketSize[] = ['hour', 'day', 'week', 'month'];
/** GET /api/reports/conversion — purchases, revenue and basket size. */
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const bucketParam = url.searchParams.get('bucket');
const bucket = BUCKETS.includes(bucketParam as BucketSize)
? (bucketParam as BucketSize)
: undefined;
const wantsPrevious = url.searchParams.get('compare') === 'previous';
return serveUpstream(req, async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs), bucket);
const site = toSiteParam(query.storeId);
const [current, previous] = await Promise.all([
reportsApi.conversion(token, window, site),
wantsPrevious
? reportsApi.conversion(token, previousWindow(window), site)
: Promise.resolve(null as ApiConversionReport | null),
]);
return toConversion(current, previous);
});
}

View File

@@ -0,0 +1,52 @@
import type {NextRequest} from 'next/server';
import {reportsApi} from '@/services/api/reportsApi';
import {toFootfall} from '@/services/api/reportMapper';
import {toReportWindow, toSiteParam} from '@/services/api/range';
import {previousWindow} from '@/services/api/previousWindow';
import {serveUpstream} from '@/shared/services/bff';
import type {ApiFootfallReport} from '@/services/api/types';
import type {BucketSize} from '@/services/api/types';
export const dynamic = 'force-dynamic';
const BUCKETS: BucketSize[] = ['hour', 'day', 'week', 'month'];
/**
* GET /api/reports/footfall
*
* Domain-named on purpose: this is the platform's own resource, not a
* dashboard-shaped one. Mobile calls the same report on the same platform, so
* there is exactly one definition of footfall in the product.
*
* `?compare=previous` fetches the preceding window of equal length in the same
* round trip. Without it every KPI card would issue a second request and do
* its own date arithmetic, which is how two panels start disagreeing about
* what "last 30 days" means.
*/
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const bucketParam = url.searchParams.get('bucket');
const bucket = BUCKETS.includes(bucketParam as BucketSize)
? (bucketParam as BucketSize)
: undefined;
const wantsPrevious = url.searchParams.get('compare') === 'previous';
return serveUpstream(
req,
async (token, query) => {
const window = toReportWindow(query.range, new Date(query.nowMs), bucket);
const site = toSiteParam(query.storeId);
// Sequential would double the latency of every dashboard load; both
// windows are independent reads.
const [current, previous] = await Promise.all([
reportsApi.footfall(token, window, site),
wantsPrevious
? reportsApi.footfall(token, previousWindow(window), site)
: Promise.resolve(null as ApiFootfallReport | null),
]);
return toFootfall(current, previous);
},
);
}

View File

@@ -1,32 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {readProfile} from '@/features/settings/mock/settings.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(readProfile(), q);
}
/**
* Accepts a profile patch and echoes the merged result.
*
* There is no persistence layer yet, so this deliberately does NOT pretend to
* save — it validates the shape and returns what the merged record would be,
* which is enough for the form to exercise its success path honestly.
*/
export async function PATCH(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const patch = (await req.json()) as Record<string, unknown>;
return ok({...readProfile(), ...patch}, q);
}

View File

@@ -0,0 +1,38 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {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.
*
* 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 {
// 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,
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),
);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildAttendance} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildAttendance(q.storeId, q.range, q.nowMs), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStaff} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStaff(q.storeId, q.range), q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {buildStaffSummary} from '@/features/staff/mock/staff.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(buildStaffSummary(q.storeId, q.range), q);
}

View File

@@ -1,23 +0,0 @@
import type {NextRequest} from 'next/server';
import {fail, ok, parseQuery, requireApiSession, simulate} from '@/shared/services/apiRoute';
import {buildStore} from '@/features/stores/mock/stores.mock';
export const dynamic = 'force-dynamic';
export async function GET(
req: NextRequest,
{params}: {params: Promise<{storeId: string}>},
) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
const {storeId} = await params;
const store = buildStore(storeId, q.range, q.nowMs);
if (!store) {
return fail('not_found', `No store with id "${storeId}"`, 404);
}
return ok(store, q);
}

View File

@@ -1,14 +0,0 @@
import type {NextRequest} from 'next/server';
import {ok, parseQuery, requireApiSession, simulate, wantsEmpty} from '@/shared/services/apiRoute';
import {buildStores} from '@/features/stores/mock/stores.mock';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const denied = await requireApiSession();
if (denied) return denied;
const q = parseQuery(req);
const simulated = await simulate(q);
if (simulated) return simulated;
return ok(wantsEmpty(q) ? [] : buildStores(q.range, q.nowMs), q);
}

26
src/app/api/team/route.ts Normal file
View File

@@ -0,0 +1,26 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {serveUpstream} from '@/shared/services/bff';
import {toAuthUser} from '@/features/auth/services/userMapper';
import type {ApiUser} from '@/services/api/types';
import type {TeamMember} from '@/features/team/types/team';
export const dynamic = 'force-dynamic';
/** GET /api/team — console accounts for this company. */
function toMember(u: ApiUser): TeamMember {
const user = toAuthUser(u);
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
organisation: user.organisation,
};
}
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => teamApi.list(token), (users) =>
users.map(toMember),
);
}

107
src/app/api/visits/route.ts Normal file
View File

@@ -0,0 +1,107 @@
import type {NextRequest} from 'next/server';
import {visitsApi} from '@/services/api/visitsApi';
import {purchasesApi} from '@/services/api/purchasesApi';
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 type {ApiArrival, ApiVisitsPage} from '@/services/api/types';
import type {Arrival, VisitsPage} from '@/features/dashboard/types/visits';
export const dynamic = 'force-dynamic';
/**
* GET /api/visits — the arrivals feed.
*
* Cursor in, cursor out, unchanged. It is opaque and version-prefixed, so this
* route must not interpret, shorten or regenerate it: doing so is how a feed
* silently starts skipping rows. An empty poll returns the caller's own cursor
* back, which is what distinguishes "nothing new" from "start over".
*/
function toArrival(a: ApiArrival): Arrival {
return {
visitId: a.visit_id,
occurredAt: a.occurred_at,
siteId: a.site_slug || a.site_id,
siteName: a.site,
cameraId: a.camera_id,
visitorId: a.visitor_id,
visitorRef: a.visitor_ref,
label: a.label,
isNewVisitor: a.is_new_visitor,
similarity: a.similarity,
image: a.image
? {
available: a.image.available,
// Relative URLs are proxied through this app so an <img> works
// without an Authorization header it cannot send.
url: a.image.url
? a.image.url.startsWith('http')
? a.image.url
: `/api/faces?src=${encodeURIComponent(a.image.url)}`
: null,
reason: a.image.reason ?? null,
}
: {available: false, url: null, reason: null},
};
}
function toPage(p: ApiVisitsPage): VisitsPage {
return {
arrivals: (p.arrivals ?? []).map(toArrival),
cursor: p.cursor,
polledAt: p.polled_at,
};
}
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const cursor = url.searchParams.get('cursor') ?? undefined;
const limitRaw = Number(url.searchParams.get('limit') ?? 50);
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(limitRaw, 1), 200)
: 50;
return serveUpstream(
req,
(token, query) =>
visitsApi.list(token, {
limit,
cursor,
site: toSiteParam(query.storeId),
}),
toPage,
);
}
/**
* POST /api/visits is NOT proxied here.
*
* Arrivals are written by the shop's camera pipeline, not by a console user.
* The mobile app writes PURCHASES against an existing visit — see
* POST /api/purchases — and a console-authored arrival would be a fabricated
* observation in a system whose whole value is that its observations are real.
*/
export async function POST(req: NextRequest) {
const query = parseQuery(req);
try {
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);
} catch (err) {
return failResponse(err);
}
}