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:
4
project/apps/admin/package-lock.json
generated
4
project/apps/admin/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mercadodevida/admin",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
4
project/apps/pos/package-lock.json
generated
4
project/apps/pos/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mercadodevida-pos",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3002",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user