update ui and remove mock data
This commit is contained in:
78
src/app/api/assistant/route.ts
Normal file
78
src/app/api/assistant/route.ts
Normal 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'}},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
57
src/app/api/faces/route.ts
Normal file
57
src/app/api/faces/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
35
src/app/api/reports/conversion/route.ts
Normal file
35
src/app/api/reports/conversion/route.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
52
src/app/api/reports/footfall/route.ts
Normal file
52
src/app/api/reports/footfall/route.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
38
src/app/api/sites/route.ts
Normal file
38
src/app/api/sites/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
26
src/app/api/team/route.ts
Normal 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
107
src/app/api/visits/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user