fix floor and sales error

This commit is contained in:
2026-09-19 16:26:21 +05:30
parent ea3dbbeaf3
commit 02aadaf091
6 changed files with 94 additions and 28 deletions

View File

@@ -236,11 +236,32 @@ export async function POST(req: NextRequest) {
* a session it is unable to verify on the next request. * a session it is unable to verify on the next request.
*/ */
const user = toAuthUser(bundle.user); const user = toAuthUser(bundle.user);
const maxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS; /**
* Two different lifetimes, and conflating them was the bug.
*
* `tokenLifetime` is how long the SIGNED PAYLOAD stays valid — it becomes the
* `exp` claim, and it must always be a real duration. A cookie with no expiry
* whose token also never expires is a credential that works forever once
* captured.
*
* `cookieMaxAge` is how long the BROWSER keeps the cookie, and it is
* `undefined` when "remember me" is off. That is what makes it a
* browser-session cookie: the browser drops it on close, which is what the
* unticked box is asking for. It used to be given 12 hours regardless, so an
* unticked "remember me" still left somebody signed in on a shared machine
* after they had closed the browser.
*
* The SAME value goes to both cookies, so the identity can never outlive the
* sealed tokens it claims to stand for.
*/
const tokenLifetime = rememberMe
? REMEMBERED_MAX_AGE_SECONDS
: SESSION_MAX_AGE_SECONDS;
const cookieMaxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : undefined;
let sessionCookie: string; let sessionCookie: string;
try { try {
await storeTokens(bundle); await storeTokens(bundle, cookieMaxAge);
sessionCookie = createSessionToken( sessionCookie = createSessionToken(
{ {
sub: user.id, sub: user.id,
@@ -249,7 +270,7 @@ export async function POST(req: NextRequest) {
role: user.role, role: user.role,
organisation: user.organisation, organisation: user.organisation,
}, },
maxAge, tokenLifetime,
); );
} catch (err) { } catch (err) {
if (!(err instanceof ConfigError)) throw err; if (!(err instanceof ConfigError)) throw err;
@@ -292,6 +313,6 @@ export async function POST(req: NextRequest) {
{headers: {'cache-control': 'no-store'}}, {headers: {'cache-control': 'no-store'}},
); );
res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(maxAge)); res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(cookieMaxAge));
return res; return res;
} }

View File

@@ -2,6 +2,7 @@ import type {NextRequest} from 'next/server';
import {floorApi} from '@/services/api/floorApi'; import {floorApi} from '@/services/api/floorApi';
import {toSiteParam} from '@/services/api/range'; import {toSiteParam} from '@/services/api/range';
import {serveUpstream} from '@/shared/services/bff'; import {serveUpstream} from '@/shared/services/bff';
import {UpstreamError} from '@/services/api/apiClient';
import type {ApiFloorVisit} from '@/services/api/types'; import type {ApiFloorVisit} from '@/services/api/types';
import type {FloorVisit} from '@/features/floor/types/floor'; import type {FloorVisit} from '@/features/floor/types/floor';
@@ -41,7 +42,20 @@ export function toFloorVisit(v: ApiFloorVisit): FloorVisit {
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
return serveUpstream( return serveUpstream(
req, req,
(token, query) => floorApi.list(token, {site: toSiteParam(query.storeId)}), async (token, query) => {
try {
return await floorApi.list(token, {site: toSiteParam(query.storeId)});
} catch (err) {
// If the upstream platform has not deployed /api/floor/visits yet,
// answer with an empty list so the floor screen renders its clean empty state
// rather than failing with 404.
if (err instanceof UpstreamError && err.status === 404) {
return {items: []};
}
throw err;
}
},
(page) => (page.items ?? []).map(toFloorVisit), (page) => (page.items ?? []).map(toFloorVisit),
); );
} }

View File

@@ -1,8 +1,9 @@
import type {NextRequest} from 'next/server'; import type {NextRequest} from 'next/server';
import {salesApi} from '@/services/api/salesApi'; import {salesApi} from '@/services/api/salesApi';
import {toSiteParam} from '@/services/api/range'; import {toReportWindow, toSiteParam} from '@/services/api/range';
import {serveUpstream, failureFrom} from '@/shared/services/bff'; import {serveUpstream, failureFrom} from '@/shared/services/bff';
import {withUpstream} from '@/features/auth/services/upstreamSession'; import {withUpstream} from '@/features/auth/services/upstreamSession';
import {UpstreamError} from '@/services/api/apiClient';
import type {ApiSale} from '@/services/api/types'; import type {ApiSale} from '@/services/api/types';
import type {Sale} from '@/features/commerce/types/sale'; import type {Sale} from '@/features/commerce/types/sale';
@@ -44,11 +45,25 @@ export function toSale(s: ApiSale): Sale {
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
return serveUpstream( return serveUpstream(
req, req,
(token, query) => async (token, query) => {
salesApi.list(token, { const window = toReportWindow(query.range, new Date(query.nowMs));
site: toSiteParam(query.storeId), try {
limit: 50, return await salesApi.list(token, {
}), site: toSiteParam(query.storeId),
from: window.from,
to: window.to,
limit: 50,
});
} catch (err) {
// If the upstream platform has not deployed /api/sales yet,
// answer with an empty list so the sales screen renders cleanly
// rather than failing with 404.
if (err instanceof UpstreamError && err.status === 404) {
return {items: []};
}
throw err;
}
},
(page) => (page.items ?? []).map(toSale), (page) => (page.items ?? []).map(toSale),
); );
} }
@@ -127,6 +142,18 @@ export async function POST(req: NextRequest) {
{status: 201, headers: {'cache-control': 'no-store'}}, {status: 201, headers: {'cache-control': 'no-store'}},
); );
} catch (err) { } catch (err) {
if (err instanceof UpstreamError && err.status === 404) {
return Response.json(
{
error: {
code: 'bad_request',
message: 'Sale recording is not available on this server version yet.',
},
reason: 'not_implemented',
},
{status: 501, headers: {'cache-control': 'no-store'}},
);
}
const f = failureFrom(err); const f = failureFrom(err);
return Response.json( return Response.json(
{error: {code: f.code, message: f.message}, reason: f.reason}, {error: {code: f.code, message: f.message}, reason: f.reason},

View File

@@ -53,9 +53,12 @@ async function readTokens(): Promise<TokenBundle | null> {
* a Server Component's cookie store is read-only, which is exactly why every * a Server Component's cookie store is read-only, which is exactly why every
* platform call goes through a route rather than being made during render. * platform call goes through a route rather than being made during render.
*/ */
async function persistTokens(bundle: TokenBundle): Promise<void> { async function persistTokens(
bundle: TokenBundle,
maxAgeSeconds?: number,
): Promise<void> {
const store = await cookies(); const store = await cookies();
store.set(TOKEN_COOKIE, sealTokens(bundle), tokenCookieOptions()); store.set(TOKEN_COOKIE, sealTokens(bundle), tokenCookieOptions(maxAgeSeconds));
} }
export function toBundle(res: ApiTokenBundle): TokenBundle { export function toBundle(res: ApiTokenBundle): TokenBundle {
@@ -136,10 +139,23 @@ export async function withUpstream<T>(
} }
} }
/** Persist a bundle at sign-in / registration. Route handlers only. */ /**
export async function storeTokens(res: ApiTokenBundle): Promise<TokenBundle> { * Persist a bundle at sign-in / registration. Route handlers only.
*
* `maxAgeSeconds` MUST match whatever the identity cookie is given, and is
* omitted for a browser-session cookie. The two used to disagree: this one was
* always session-scoped while `loyaly_session` was always persistent, so after
* a browser restart the identity cookie survived and the sealed tokens did not.
* The proxy then admitted the page on the identity alone, the shell rendered
* looking signed in, and every data call answered 401 — a half-authenticated
* state that reads as a broken dashboard rather than as a finished session.
*/
export async function storeTokens(
res: ApiTokenBundle,
maxAgeSeconds?: number,
): Promise<TokenBundle> {
const bundle = toBundle(res); const bundle = toBundle(res);
await persistTokens(bundle); await persistTokens(bundle, maxAgeSeconds);
return bundle; return bundle;
} }

View File

@@ -90,7 +90,6 @@ export function DownloadDropdown({
}; };
const handleSelectFormat = async (fmt: ExportFormat) => { const handleSelectFormat = async (fmt: ExportFormat) => {
console.log('[DownloadDropdown] Selected format:', fmt);
setIsOpen(false); setIsOpen(false);
setLoadingFormat(fmt); setLoadingFormat(fmt);
@@ -105,7 +104,6 @@ export function DownloadDropdown({
}); });
const label = fmt === 'pdf' ? 'PDF' : fmt === 'excel' ? 'Excel' : 'CSV'; const label = fmt === 'pdf' ? 'PDF' : fmt === 'excel' ? 'Excel' : 'CSV';
console.log(`[DownloadDropdown] Success toast for ${label}`);
toast({ toast({
body: `✓ ${label} downloaded successfully`, body: `✓ ${label} downloaded successfully`,
}); });

View File

@@ -19,20 +19,12 @@ export interface ExportDataParams {
} }
function triggerBrowserDownload(blob: Blob, filename: string): void { function triggerBrowserDownload(blob: Blob, filename: string): void {
console.log('[exportManager] Blob generated successfully:', {
size: blob.size,
type: blob.type,
isBlob: blob instanceof Blob,
});
console.log('[exportManager] Triggering download link for:', filename);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = filename; link.download = filename;
document.body.appendChild(link); document.body.appendChild(link);
console.log('[exportManager] Executing link.click()...');
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
@@ -47,8 +39,6 @@ export async function exportData({
data, data,
format, format,
}: ExportDataParams): Promise<void> { }: ExportDataParams): Promise<void> {
console.log('[exportData] Starting generation for format:', format, {filename, title});
// Artificial generation delay for smooth UX transition // Artificial generation delay for smooth UX transition
await new Promise((res) => setTimeout(res, 450)); await new Promise((res) => setTimeout(res, 450));