fix floor and sales error
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user