fix(release): 0.2.8 harden admin proxy, cart stock caps, selfpay and refund timeline

- admin proxy: 25s timeout, body guards, structured failure logs
- cart: addItem enforces stock cap (409 INSUFFICIENT_STOCK), UI clamps qty
- tpv selfpay: hide sidebar/discounts/save-pending, rename button, receipt-settings 400 fix
- pos admin: quick products slot count aligned to 8
- returns: human-readable history message + metadata jsonb (migrations 064-065) + admin fallback formatter
- storefront: product card white background
- product page: remove duplicate stock label under add-to-cart button
This commit is contained in:
Deploy
2026-08-25 23:41:29 +02:00
parent b6adf681d1
commit b6af852b54
29 changed files with 671 additions and 279 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@mercadodevida/admin",
"version": "0.2.4",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mercadodevida/admin",
"version": "0.2.4",
"version": "0.2.8",
"dependencies": {
"@lexical/history": "^0.49.0",
"@lexical/html": "^0.49.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@mercadodevida/admin",
"version": "0.2.4",
"version": "0.2.8",
"private": true,
"scripts": {
"dev": "next dev --port 3001",

View File

@@ -78,6 +78,35 @@ function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`;
}
/**
* F-138: legacy return events were stored as raw JSON. If the migration
* hasn't caught up (or didn't run on a particular row) we still want the UI
* to show something readable instead of dumping the payload. Falls back to
* the original string when the value isn't JSON.
*/
function formatRefundMessage(raw: string): string {
const trimmed = raw?.trim() ?? '';
if (!trimmed.startsWith('{')) return raw;
try {
const payload = JSON.parse(trimmed) as {
refundedCents?: number;
status?: 'refund' | 'partial_refund';
lines?: Array<{ returnedQuantity?: number }>;
reason?: string;
};
const euros = ((payload.refundedCents ?? 0) / 100).toFixed(2);
const action = payload.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
const lineCount = Array.isArray(payload.lines)
? payload.lines.reduce((sum, line) => sum + (line.returnedQuantity ?? 0), 0)
: 0;
const reason = payload.reason?.trim();
const detail = lineCount > 0 ? ` (${lineCount} uds.)` : '';
return `${action} de ${euros}${detail}${reason ? `${reason}` : ''}`;
} catch {
return raw;
}
}
export default function OrderDetailPage() {
const { id } = useParams<{ id: string }>();
const [order, setOrder] = useState<Order | null>(null);
@@ -710,6 +739,9 @@ export default function OrderDetailPage() {
// ORDERS-FIX: detectar refunds para mostrar de forma más legible
const isRefund = /refund|reembolso|devolu/i.test(event.message);
const isRefundEvent = event.eventType === 'REFUND' || isRefund;
const formattedMessage = isRefundEvent
? formatRefundMessage(event.message)
: event.message;
return (
<div key={event.id} className="flex gap-3">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
@@ -718,7 +750,7 @@ export default function OrderDetailPage() {
}`} />
<div className="min-w-0">
<p className={`text-sm break-words ${isRefundEvent ? 'text-pink-700 font-medium' : 'text-gray-800'}`}>
{isRefundEvent && <span className="mr-1">💸</span>}{event.message}
{isRefundEvent && <span className="mr-1">💸</span>}{formattedMessage}
</p>
<p className="text-xs text-gray-400">
{new Date(event.createdAt).toLocaleString('es-ES')}

View File

@@ -199,7 +199,7 @@ export default function PosAdminPage() {
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
const configured = terminal.settings?.quickProductVariantIds ?? [];
setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null));
setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
setTouchMessage('');
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);

View File

@@ -2,126 +2,207 @@ import { NextRequest, NextResponse } from 'next/server';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/**
* Catch-all proxy: forwards ALL requests to the backend API.
* This avoids CORS preflight issues since requests stay within the
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
*
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
* so they are NOT served by this handler.
*/
export async function GET(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
headers: { Cookie: cookies },
});
if (path === 'admin/logs/stream') {
// Pipe SSE directly: Next.js App Router supports ReadableStream passthrough.
// X-Accel-Buffering: no tells any intermediate proxy (nginx) not to buffer.
const headers = new Headers();
headers.set('Content-Type', backendRes.headers.get('content-type') ?? 'text/event-stream');
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
headers.set('X-Accel-Buffering', 'no');
// Do NOT set Connection: keep-alive — it is HTTP/1.1 default for persistent
// connections and can confuse proxies that do not expect streaming.
return new Response(backendRes.body, {
status: backendRes.status,
headers,
});
}
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
const PROXY_TIMEOUT_MS = 25_000;
const REQUEST_ID_HEADER = 'x-request-id';
interface ProxyInit {
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
/** Raw body, or `null` when there is no body to forward. */
body: string | null;
headers: Record<string, string>;
requestId: string;
}
export async function POST(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
interface ProxyResult {
status: number;
headers: Headers;
body: ReadableStream | null;
}
async function proxyToBackend(
request: NextRequest,
init: ProxyInit,
): Promise<ProxyResult> {
const path = request.nextUrl.pathname.replace('/api/', '');
const search = request.nextUrl.search;
const url = `${API}/${path}${search}`;
// Forward only the cookies the proxy knows are safe to relay. We deliberately
// do NOT pass `content-length` (Node fetch sets it from `body`), and we keep
// `content-type` only when we actually have a body to send — otherwise some
// upstreams reject the request or return 502 on bodyless PATCH/POST calls.
const headers: Record<string, string> = {
Cookie: init.headers.Cookie ?? '',
[REQUEST_ID_HEADER]: init.requestId,
};
if (init.body !== null) {
headers['Content-Type'] = 'application/json';
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
try {
const headers: Record<string, string> = { Cookie: cookies };
if (body) headers['Content-Type'] = 'application/json';
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'POST',
const backendRes = await fetch(url, {
method: init.method,
headers,
...(body ? { body } : {}),
body: init.body,
signal: controller.signal,
});
const responseHeaders = new Headers();
const contentType = backendRes.headers.get('content-type');
if (contentType) responseHeaders.set('Content-Type', contentType);
const setCookie = backendRes.headers.get('set-cookie');
const data = await backendRes.json().catch(() => null);
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
if (setCookie) {
resp.headers.set(
responseHeaders.set(
'Set-Cookie',
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
);
}
return resp;
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PATCH(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function PUT(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
const body = await req.text();
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Cookie: cookies },
body,
});
const data = await backendRes.json().catch(() => null);
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
}
}
export async function DELETE(req: NextRequest) {
const path = req.nextUrl.pathname.replace('/api/', '');
const search = req.nextUrl.search;
const cookies = req.headers.get('cookie') ?? '';
try {
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'DELETE',
headers: { Cookie: cookies },
});
// Reenviar la respuesta del backend tal cual: el backend puede devolver
// 204 No Content (sin body) en borrados exitosos, y forzar un JSON con
// status 204 es HTTP inválido (F-127).
return new Response(backendRes.body, {
return {
status: backendRes.status,
statusText: backendRes.statusText,
headers: { 'Content-Length': backendRes.headers.get('content-length') ?? '0' },
});
} catch {
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
headers: responseHeaders,
body: backendRes.body,
};
} finally {
clearTimeout(timeout);
}
}
/**
* Reads the incoming request body safely. Returns `null` for bodyless methods
* (GET/DELETE) and an empty string when the body is empty.
*/
async function safeReadBody(request: NextRequest): Promise<string | null> {
if (request.method === 'GET' || request.method === 'DELETE' || request.method === 'HEAD') {
return null;
}
try {
return await request.text();
} catch (error) {
console.warn('[proxy] failed to read body', { message: (error as Error)?.message });
return null;
}
}
function getRequestId(request: NextRequest): string {
return (
request.headers.get(REQUEST_ID_HEADER) ??
request.headers.get('x-vercel-id') ??
`adm-${Math.random().toString(36).slice(2, 10)}`
);
}
function logProxyFailure(stage: string, request: NextRequest, requestId: string, error: unknown) {
console.warn('[proxy] failure', {
stage,
requestId,
method: request.method,
target: request.nextUrl.pathname,
message: error instanceof Error ? error.message : String(error),
name: error instanceof Error ? error.name : 'unknown',
});
}
export async function GET(request: NextRequest) {
const requestId = getRequestId(request);
try {
const result = await proxyToBackend(request, {
method: 'GET',
body: null,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
if (request.nextUrl.pathname === 'admin/logs/stream') {
result.headers.set('Content-Type', result.headers.get('content-type') ?? 'text/event-stream');
result.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
result.headers.set('X-Accel-Buffering', 'no');
}
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('get', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function POST(request: NextRequest) {
const requestId = getRequestId(request);
try {
const body = await safeReadBody(request);
const result = await proxyToBackend(request, {
method: 'POST',
body,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('post', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function PATCH(request: NextRequest) {
const requestId = getRequestId(request);
try {
const body = await safeReadBody(request);
const result = await proxyToBackend(request, {
method: 'PATCH',
body,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('patch', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function PUT(request: NextRequest) {
const requestId = getRequestId(request);
try {
const body = await safeReadBody(request);
const result = await proxyToBackend(request, {
method: 'PUT',
body,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('put', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}
export async function DELETE(request: NextRequest) {
const requestId = getRequestId(request);
try {
const result = await proxyToBackend(request, {
method: 'DELETE',
body: null,
headers: { Cookie: request.headers.get('cookie') ?? '' },
requestId,
});
return new Response(result.body, { status: result.status, headers: result.headers });
} catch (error) {
logProxyFailure('delete', request, requestId, error);
return NextResponse.json(
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
{ status: 502 },
);
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "mercadodevida-pos",
"version": "0.2.4",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mercadodevida-pos",
"version": "0.2.4",
"version": "0.2.8",
"dependencies": {
"next": "^16.3.1",
"react": "^19.2.8",

View File

@@ -1,6 +1,6 @@
{
"name": "mercadodevida-pos",
"version": "0.2.4",
"version": "0.2.8",
"private": true,
"scripts": {
"dev": "next dev --port 3002",

View File

@@ -1659,14 +1659,16 @@ export default function RegisterPage() {
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => void parkSale()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Guardando…' : 'Guardar pendiente'}
</button>
{!isSelfpayMode && (
<button
type="button"
onClick={() => void parkSale()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Guardando…' : 'Guardar pendiente'}
</button>
)}
<button
type="button"
onClick={() => void confirmSale()}
@@ -1677,7 +1679,9 @@ export default function RegisterPage() {
paidCents <= 0 ||
paidCents > totals.total
}
className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
className={`min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40 ${
isSelfpayMode ? 'col-span-2' : ''
}`}
>
{processing ? 'Confirmando…' : 'Cobrar e imprimir'}
</button>
@@ -1688,7 +1692,7 @@ export default function RegisterPage() {
disabled={processing || cart.length === 0}
className="mt-2 w-full rounded-xl border border-gray-300 bg-white py-2 text-sm font-bold text-red-600 disabled:opacity-40"
>
Vaciar caja
{isSelfpayMode ? '🧹 Limpiar Ticket' : 'Vaciar caja'}
</button>
</aside>