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 +1 @@
0.2.4 0.2.8

View File

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

View File

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

View File

@@ -78,6 +78,35 @@ function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`; 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() { export default function OrderDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [order, setOrder] = useState<Order | null>(null); 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 // ORDERS-FIX: detectar refunds para mostrar de forma más legible
const isRefund = /refund|reembolso|devolu/i.test(event.message); const isRefund = /refund|reembolso|devolu/i.test(event.message);
const isRefundEvent = event.eventType === 'REFUND' || isRefund; const isRefundEvent = event.eventType === 'REFUND' || isRefund;
const formattedMessage = isRefundEvent
? formatRefundMessage(event.message)
: event.message;
return ( return (
<div key={event.id} className="flex gap-3"> <div key={event.id} className="flex gap-3">
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${ <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"> <div className="min-w-0">
<p className={`text-sm break-words ${isRefundEvent ? 'text-pink-700 font-medium' : 'text-gray-800'}`}> <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>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
{new Date(event.createdAt).toLocaleString('es-ES')} {new Date(event.createdAt).toLocaleString('es-ES')}

View File

@@ -199,7 +199,7 @@ export default function PosAdminPage() {
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false); setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false); setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
const configured = terminal.settings?.quickProductVariantIds ?? []; const configured = terminal.settings?.quickProductVariantIds ?? [];
setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null)); setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
setTouchMessage(''); setTouchMessage('');
setSelfpayMode(terminal.settings?.selfpayMode ?? false); setSelfpayMode(terminal.settings?.selfpayMode ?? false);
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? 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'; const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
/** const PROXY_TIMEOUT_MS = 25_000;
* Catch-all proxy: forwards ALL requests to the backend API. const REQUEST_ID_HEADER = 'x-request-id';
* This avoids CORS preflight issues since requests stay within the
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend). interface ProxyInit {
* method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
* More specific routes (e.g. /api/auth/login) take precedence in Next.js, /** Raw body, or `null` when there is no body to forward. */
* so they are NOT served by this handler. body: string | null;
*/ headers: Record<string, string>;
export async function GET(req: NextRequest) { requestId: string;
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 });
}
} }
export async function POST(req: NextRequest) { interface ProxyResult {
const path = req.nextUrl.pathname.replace('/api/', ''); status: number;
const search = req.nextUrl.search; headers: Headers;
const cookies = req.headers.get('cookie') ?? ''; body: ReadableStream | null;
const body = await req.text(); }
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 { try {
const headers: Record<string, string> = { Cookie: cookies }; const backendRes = await fetch(url, {
if (body) headers['Content-Type'] = 'application/json'; method: init.method,
const backendRes = await fetch(`${API}/${path}${search}`, {
method: 'POST',
headers, 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 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) { if (setCookie) {
resp.headers.set( responseHeaders.set(
'Set-Cookie', 'Set-Cookie',
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(), setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
); );
} }
return resp; return {
} 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, {
status: backendRes.status, status: backendRes.status,
statusText: backendRes.statusText, headers: responseHeaders,
headers: { 'Content-Length': backendRes.headers.get('content-length') ?? '0' }, 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',
}); });
} catch { }
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
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", "name": "mercadodevida-pos",
"version": "0.2.4", "version": "0.2.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mercadodevida-pos", "name": "mercadodevida-pos",
"version": "0.2.4", "version": "0.2.8",
"dependencies": { "dependencies": {
"next": "^16.3.1", "next": "^16.3.1",
"react": "^19.2.8", "react": "^19.2.8",

View File

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

View File

@@ -1659,6 +1659,7 @@ export default function RegisterPage() {
))} ))}
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
{!isSelfpayMode && (
<button <button
type="button" type="button"
onClick={() => void parkSale()} onClick={() => void parkSale()}
@@ -1667,6 +1668,7 @@ export default function RegisterPage() {
> >
{processing ? 'Guardando…' : 'Guardar pendiente'} {processing ? 'Guardando…' : 'Guardar pendiente'}
</button> </button>
)}
<button <button
type="button" type="button"
onClick={() => void confirmSale()} onClick={() => void confirmSale()}
@@ -1677,7 +1679,9 @@ export default function RegisterPage() {
paidCents <= 0 || paidCents <= 0 ||
paidCents > totals.total 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'} {processing ? 'Confirmando…' : 'Cobrar e imprimir'}
</button> </button>
@@ -1688,7 +1692,7 @@ export default function RegisterPage() {
disabled={processing || cart.length === 0} 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" 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> </button>
</aside> </aside>

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.2.4", "version": "0.2.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -201,6 +201,7 @@ export default async function ProductPage({ params }: Props) {
priceCents={grossCents} priceCents={grossCents}
imageUrl={product.images?.[0]?.url} imageUrl={product.images?.[0]?.url}
available={true} available={true}
availableQuantity={stock.availableQuantity}
minPurchaseQty={product.minPurchaseQty ?? 1} minPurchaseQty={product.minPurchaseQty ?? 1}
/> />
) : ( ) : (

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useCart } from '@/contexts/CartContext'; import { useCart } from '@/contexts/CartContext';
import { fetchStockAvailability } from '@/lib/api';
interface Props { interface Props {
variantId: string; variantId: string;
@@ -10,24 +11,48 @@ interface Props {
imageUrl?: string; imageUrl?: string;
available?: boolean; available?: boolean;
minPurchaseQty?: number; minPurchaseQty?: number;
/** F-138: max units available right now. Used to cap the add-to-cart action. */
availableQuantity?: number;
className?: string; className?: string;
} }
export default function AddToCartButton({ export default function AddToCartButton({
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, className = '', variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, availableQuantity, className = '',
}: Props) { }: Props) {
const { addItem, itemCount } = useCart(); const { addItem, items } = useCart();
const [added, setAdded] = useState(false); const [added, setAdded] = useState(false);
const [liveStock, setLiveStock] = useState<number | null>(availableQuantity ?? null);
const qty = Math.max(1, minPurchaseQty); const qty = Math.max(1, minPurchaseQty);
// Refresh stock so the button reflects reality (especially on stale tabs).
useEffect(() => {
let cancelled = false;
fetchStockAvailability(variantId)
.then((s) => {
if (!cancelled) setLiveStock(s.availableQuantity);
})
.catch(() => {
if (!cancelled) setLiveStock(null);
});
return () => {
cancelled = true;
};
}, [variantId]);
// Account for whatever the user already has in the cart for this variant.
const inCart = items.find((it) => it.variantId === variantId)?.quantity ?? 0;
const stockCap = liveStock ?? null;
const remaining = stockCap === null ? null : Math.max(0, stockCap - inCart);
const disabled = !available || (remaining !== null && remaining < qty);
const handleAdd = () => { const handleAdd = () => {
if (!available) return; if (disabled) return;
addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty }); addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty });
setAdded(true); setAdded(true);
setTimeout(() => setAdded(false), 2000); setTimeout(() => setAdded(false), 2000);
}; };
if (!available) { if (!available || remaining === 0) {
return ( return (
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}> <button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
Agotado Agotado
@@ -47,9 +72,10 @@ export default function AddToCartButton({
<div> <div>
<button <button
onClick={handleAdd} onClick={handleAdd}
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`} disabled={disabled}
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg disabled:bg-gray-300 disabled:cursor-not-allowed ${className}`}
> >
Añadir al carrito{qty > 1 ? ` (${qty} uds.)` : ''} {disabled && remaining !== null ? `Solo ${remaining} uds.` : `Añadir al carrito${qty > 1 ? ` (${qty} uds.)` : ''}`}
</button> </button>
{qty > 1 && ( {qty > 1 && (
<p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p> <p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p>

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useCart, type CartItem } from '@/contexts/CartContext'; import { useCart, type CartItem } from '@/contexts/CartContext';
import { fetchStockAvailability } from '@/lib/api';
function formatPrice(cents: number) { function formatPrice(cents: number) {
return `${(cents / 100).toFixed(2)}`; return `${(cents / 100).toFixed(2)}`;
@@ -9,6 +11,33 @@ function formatPrice(cents: number) {
function CartItemRow({ item }: { item: CartItem }) { function CartItemRow({ item }: { item: CartItem }) {
const { removeItem, changeQuantity } = useCart(); const { removeItem, changeQuantity } = useCart();
const [stockMax, setStockMax] = useState<number | null>(null);
// F-138: cap the + button to actual stock so the buyer can never load more
// units than the SKU allows.
useEffect(() => {
let cancelled = false;
fetchStockAvailability(item.variantId)
.then((s) => {
if (!cancelled) setStockMax(s.availableQuantity);
})
.catch(() => {
if (!cancelled) setStockMax(null);
});
return () => {
cancelled = true;
};
}, [item.variantId]);
const atStockMax = stockMax !== null && item.quantity >= stockMax;
const stockLabel =
stockMax === null
? null
: stockMax === 0
? 'Sin stock'
: stockMax <= 3
? `Quedan ${stockMax} uds.`
: `Stock: ${stockMax} uds.`;
return ( return (
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0"> <div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
@@ -49,8 +78,18 @@ function CartItemRow({ item }: { item: CartItem }) {
</button> </button>
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span> <span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
<button <button
onClick={() => changeQuantity(item.variantId, item.quantity + 1)} onClick={() => {
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors" if (stockMax === null) {
changeQuantity(item.variantId, item.quantity + 1);
return;
}
// Hard cap: never go above stockMax and never below 1.
const next = Math.min(stockMax, item.quantity + 1);
if (next > item.quantity) changeQuantity(item.variantId, next);
}}
disabled={atStockMax}
title={atStockMax ? `Solo hay ${stockMax} uds. disponibles` : undefined}
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
> >
+ +
</button> </button>
@@ -62,6 +101,11 @@ function CartItemRow({ item }: { item: CartItem }) {
Eliminar Eliminar
</button> </button>
</div> </div>
{stockLabel && (
<p className={`mt-1 text-xs ${atStockMax ? 'text-amber-600' : 'text-gray-500'}`}>
{stockLabel}
</p>
)}
</div> </div>
{/* Subtotal */} {/* Subtotal */}

View File

@@ -1,5 +1,4 @@
'use client'; 'use client';
import { useState } from 'react';
import AddToCartButton from './AddToCartButton'; import AddToCartButton from './AddToCartButton';
interface Props { interface Props {
@@ -9,10 +8,12 @@ interface Props {
priceCents: number; priceCents: number;
imageUrl?: string; imageUrl?: string;
available: boolean; available: boolean;
/** F-138: available stock for the variant. */
availableQuantity?: number;
minPurchaseQty?: number; minPurchaseQty?: number;
} }
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, minPurchaseQty }: Props) { export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, availableQuantity, minPurchaseQty }: Props) {
return ( return (
<div className="mt-6"> <div className="mt-6">
<AddToCartButton <AddToCartButton
@@ -22,6 +23,7 @@ export default function ProductAddToCart({ variantId, productId, productName, pr
priceCents={priceCents} priceCents={priceCents}
imageUrl={imageUrl} imageUrl={imageUrl}
available={available} available={available}
availableQuantity={availableQuantity}
minPurchaseQty={minPurchaseQty} minPurchaseQty={minPurchaseQty}
className="w-full sm:w-auto" className="w-full sm:w-auto"
/> />

View File

@@ -0,0 +1,20 @@
/**
* F-138: orders_order_history.metadata holds the structured payload for
* machine-readable events (returns, refunds, etc.) so we can keep the
* human-visible `message` column short and friendly while preserving all the
* detail that internal tooling needs.
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE orders_order_history
ADD COLUMN IF NOT EXISTS metadata jsonb NULL
`);
};
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE orders_order_history
DROP COLUMN IF EXISTS metadata
`);
};

View File

@@ -0,0 +1,65 @@
/**
* F-138 backfill: convert any legacy `orders_order_history` refund entries that
* were stored as raw JSON `message` strings into the new human-readable format
* introduced by `apply-pos-return.ts`. We also move the original payload into
* the new `metadata` column so internal tooling still has the structured data.
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
WITH parsed AS (
SELECT id,
(message::jsonb) AS payload,
(
CASE (message::jsonb->>'status')
WHEN 'refund' THEN 'Devolución total'
ELSE 'Devolución parcial'
END
) AS action
FROM orders_order_history
WHERE event_type = 'RETURNED'
AND message ~ '^\\s*\\{'
),
formatted AS (
SELECT id,
action || ' de ' ||
(round(((payload->>'refundedCents')::numeric) / 100)::numeric(12,2)::text) ||
' €' ||
COALESCE(
(SELECT ' — ' || string_agg(
((l->>'returnedQuantity')::int)::text || ' × ' ||
COALESCE(oi.name, 'Artículo'),
', '
ORDER BY ord)
FROM jsonb_array_elements(payload->'lines') WITH ORDINALITY AS x(l, ord)
LEFT JOIN orders_items oi ON oi.id = (l->>'orderItemId')::uuid
WHERE (l->>'returnedQuantity')::int > 0
),
''
) ||
CASE
WHEN NULLIF(payload->>'reason', '') IS NOT NULL
THEN ' (motivo: ' || (payload->>'reason') || ')'
ELSE ''
END AS readable
FROM parsed
)
UPDATE orders_order_history h
SET message = f.readable,
metadata = h.message::jsonb
FROM formatted f
WHERE h.id = f.id
`);
};
export const down = (pgm) => {
// Best-effort: restore the JSON message from metadata. We can't know whether
// a row was originally a JSON message or already human-readable, so the
// down migration just clears the column.
pgm.sql(`
UPDATE orders_order_history
SET message = metadata::text,
metadata = NULL
WHERE event_type = 'RETURNED' AND metadata IS NOT NULL
`);
};

View File

@@ -1,12 +1,12 @@
{ {
"name": "mercadodevida-backend", "name": "mercadodevida-backend",
"version": "0.2.4", "version": "0.2.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mercadodevida-backend", "name": "mercadodevida-backend",
"version": "0.2.4", "version": "0.2.8",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "mercadodevida-backend", "name": "mercadodevida-backend",
"version": "0.2.4", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "mercadodevida vNext backend - modular monolith skeleton", "description": "mercadodevida vNext backend - modular monolith skeleton",

View File

@@ -105,28 +105,52 @@ describe.skipIf(!hasDb)('cart flows (real PostgreSQL)', () => {
}); });
}); });
it('flags cart item unavailable when variant is out of stock (AC2)', async () => { it('rejects add when stock is zero (AC2 — F-138)', async () => {
const productId = randomUUID(); const productId = randomUUID();
const variantId = randomUUID(); const variantId = randomUUID();
await setPrice(variantId, 500); await setPrice(variantId, 500);
await setStock(variantId, 0); await setStock(variantId, 0);
await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/cart/items', url: '/cart/items',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie }, cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 1 }, payload: { productId, variantId, quantity: 1 },
}); });
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
const cart = await app.inject({ const cart = await app.inject({
method: 'GET', method: 'GET',
url: '/cart', url: '/cart',
cookies: { [SESSION_COOKIE_NAME]: cookie }, cookies: { [SESSION_COOKIE_NAME]: cookie },
}); });
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find( const items = cart.json().items as Array<{ variantId: string }>;
(entry) => entry.variantId === variantId, expect(items.find((entry) => entry.variantId === variantId)).toBeUndefined();
); });
expect(item).toMatchObject({ available: false });
it('rejects add when total quantity exceeds stock (AC2b — F-138)', async () => {
const productId = randomUUID();
const variantId = randomUUID();
await setPrice(variantId, 500);
await setStock(variantId, 3);
const first = await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 2 },
});
expect(first.statusCode).toBe(201);
const oversell = await app.inject({
method: 'POST',
url: '/cart/items',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: cookie },
payload: { productId, variantId, quantity: 2 },
});
expect(oversell.statusCode).toBe(409);
expect(oversell.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
}); });
it('ignores client-supplied price fields in cart payloads (AC3)', async () => { it('ignores client-supplied price fields in cart payloads (AC3)', async () => {

View File

@@ -19,8 +19,16 @@ export class CartService {
async addItem(userId: string, input: CartItemInput): Promise<CartView> { async addItem(userId: string, input: CartItemInput): Promise<CartView> {
ensurePositiveQuantity(input.quantity); ensurePositiveQuantity(input.quantity);
// Keep unavailable lines visible in the cart so the customer can remove or // F-138: stock cap is enforced on every cart mutation. The previous
// replace them; checkout remains the authoritative stock gate. // behaviour ("keep unavailable lines visible so the buyer can remove
// them") allowed overselling in the online storefront: customers could
// load 50 units of a 3-unit SKU into the cart and only failed at checkout.
// Cap the resulting quantity to whatever is actually in stock and surface
// the available amount to the caller via the standard error.
const cart = await this.carts.getOrCreate(userId);
const existing = cart.items.find((item) => item.variantId === input.variantId);
const requested = (existing?.quantity ?? 0) + input.quantity;
await this.assertStockAvailable(input.variantId, requested);
return this.toView(await this.carts.addItem(userId, input)); return this.toView(await this.carts.addItem(userId, input));
} }

View File

@@ -57,6 +57,31 @@ async function assertReceiptAccess(
} }
} }
/**
* Resolves which store the admin POS settings page should target.
*
* Accepts the explicit `storeId` query value when present and well-formed;
* otherwise falls back to the first active store. This keeps the admin
* dashboard recoverable when the user lands on the page before the store
* dropdown is hydrated or the dropdown value is somehow invalid (F-139).
*/
async function resolveStoreIdForReceipt(
pool: pg.Pool,
rawStoreId: string | undefined,
): Promise<string> {
if (rawStoreId && storeIdSchema.safeParse(rawStoreId).success) {
return rawStoreId;
}
const fallback = await pool.query<{ id: string }>(
`SELECT id FROM pos_stores WHERE active = true ORDER BY created_at ASC LIMIT 1`,
);
const id = fallback.rows[0]?.id;
if (!id) {
throw new AppError(404, 'NO_ACTIVE_STORE', 'No hay tiendas activas configuradas');
}
return id;
}
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) { export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
const { pool, authenticate } = deps; const { pool, authenticate } = deps;
@@ -1208,8 +1233,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
summary: 'Get company and receipt numbering settings', summary: 'Get company and receipt numbering settings',
querystring: { querystring: {
type: 'object', type: 'object',
required: ['storeId'], properties: { storeId: { type: 'string' } },
properties: { storeId: { type: 'string', format: 'uuid' } },
}, },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema }, response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
} as FastifySchema, } as FastifySchema,
@@ -1217,7 +1241,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => { async (request, reply) => {
const user = await authenticate(request); const user = await authenticate(request);
requireRole(user, 'admin'); requireRole(user, 'admin');
const { storeId } = parseJson(z.object({ storeId: storeIdSchema }), request.query); const rawStoreId = (request.query as { storeId?: string }).storeId;
// F-139: the admin POS page can land before the store dropdown has a
// selection. Fall back to the first active store so the UI never sees a
// 400 caused by an empty querystring.
const storeId = await resolveStoreIdForReceipt(pool, rawStoreId);
await pool.query( await pool.query(
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1) `INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
ON CONFLICT (store_id) DO NOTHING`, ON CONFLICT (store_id) DO NOTHING`,

View File

@@ -15,6 +15,7 @@ interface OrderRow {
interface OrderItemRow { interface OrderItemRow {
id: string; id: string;
name: string;
quantity: number; quantity: number;
returned_quantity: number; returned_quantity: number;
is_free_item: boolean; is_free_item: boolean;
@@ -131,7 +132,7 @@ export class ApplyPosReturnUseCase {
} }
const itemRows = await client.query<OrderItemRow>( const itemRows = await client.query<OrderItemRow>(
`SELECT id, quantity, returned_quantity, is_free_item, variant_id, `SELECT id, name, quantity, returned_quantity, is_free_item, variant_id,
unit_price_cents, discount_cents, tax_cents unit_price_cents, discount_cents, tax_cents
FROM orders_items FROM orders_items
WHERE order_id = $1 WHERE order_id = $1
@@ -315,10 +316,18 @@ export class ApplyPosReturnUseCase {
], ],
); );
await client.query( await client.query(
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email) `INSERT INTO orders_order_history (order_id, event_type, message, actor_email, metadata)
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3))`, VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3), $4)`,
[ [
input.orderId, input.orderId,
formatReturnMessage({
refundedCents: totalRefundCents,
status: reportingStatus,
lines: input.lines,
reason: input.reason,
items: itemsById,
}),
input.userId,
JSON.stringify({ JSON.stringify({
returnId, returnId,
refundedCents: totalRefundCents, refundedCents: totalRefundCents,
@@ -326,7 +335,6 @@ export class ApplyPosReturnUseCase {
lines: input.lines, lines: input.lines,
reason: input.reason, reason: input.reason,
}), }),
input.userId,
], ],
); );
@@ -366,3 +374,38 @@ export class ApplyPosReturnUseCase {
} }
} }
} }
interface FormatReturnMessageInput {
refundedCents: number;
status: 'refund' | 'partial_refund';
lines: ReadonlyArray<{ orderItemId: string; returnedQuantity: number }>;
reason: string;
items: ReadonlyMap<string, OrderItemRow>;
}
/**
* Renders a refund event as a short, human-readable line for the order
* timeline. We deliberately do NOT dump the raw payload here: customers and
* back-office staff see `event.message` and the JSON was unreadable.
* Structured fields are still stored on `orders_order_history.metadata` so
* support tooling can read them when needed.
*/
function formatReturnMessage(input: FormatReturnMessageInput): string {
const euros = (input.refundedCents / 100).toFixed(2);
const action = input.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
const lineDescriptions = input.lines
.filter((line) => line.returnedQuantity > 0)
.map((line) => {
const item = input.items.get(line.orderItemId);
const name = item?.name ?? 'Artículo';
return `${line.returnedQuantity} × ${name}`;
});
const linesSummary =
lineDescriptions.length === 0
? ''
: `${lineDescriptions.slice(0, 3).join(', ')}${
lineDescriptions.length > 3 ? ` y ${lineDescriptions.length - 3} más` : ''
}`;
const reason = input.reason?.trim();
return `${action} de ${euros}${linesSummary}${reason ? ` (motivo: ${reason})` : ''}`;
}

View File

@@ -1,12 +1,12 @@
{ {
"name": "mercadodevida-storefront", "name": "mercadodevida-storefront",
"version": "0.2.4", "version": "0.2.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mercadodevida-storefront", "name": "mercadodevida-storefront",
"version": "0.2.4", "version": "0.2.8",
"dependencies": { "dependencies": {
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"next": "^16.0.5", "next": "^16.0.5",

View File

@@ -1,6 +1,6 @@
{ {
"name": "mercadodevida-storefront", "name": "mercadodevida-storefront",
"version": "0.2.4", "version": "0.2.8",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "mercadodevida customer storefront shell", "description": "mercadodevida customer storefront shell",

View File

@@ -62,7 +62,7 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
const existing = items.find((it) => it.variantId === productId); const existing = items.find((it) => it.variantId === productId);
const newQty = existing ? existing.quantity + qty : qty; const newQty = existing ? existing.quantity + qty : qty;
if (newQty > stock) { if (newQty > stock) {
setError(`Solo hay ${stock} unidades disponibles.`); setError(`Solo hay ${stock} unidades disponibles en total (ya tienes ${existing?.quantity ?? 0} en el carrito).`);
return; return;
} }
const next = existing const next = existing
@@ -93,7 +93,16 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
min={1} min={1}
max={stock} max={stock}
value={qty} value={qty}
onChange={(e) => setQty(Math.max(1, parseInt(e.target.value, 10) || 1))} onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
if (!Number.isFinite(parsed)) {
setQty(1);
return;
}
// F-138: never let the buyer enter a quantity outside [1, stock].
const clamped = Math.max(1, Math.min(stock, parsed));
setQty(clamped);
}}
disabled={disabled} disabled={disabled}
className="w-24 rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-700 outline-none disabled:opacity-50" className="w-24 rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-700 outline-none disabled:opacity-50"
/> />

View File

@@ -8,7 +8,7 @@ export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }
href={product.url} href={product.url}
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700" className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
> >
<div className="flex w-full max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden"> <div className="flex w-full max-h-72 items-center justify-center bg-white text-sm text-emerald-900 overflow-hidden">
{mainImage ? ( {mainImage ? (
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists. // Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element

View File

@@ -1,16 +1,21 @@
# Estado Actual # Estado Actual
## Ninguna feature activa ## Sesión anterior completada: 2026-08-25
**Última feature cerrada:** `FRONTEND-UI-FIXES2` (ya estaba `done` en backlog) ### Features cerradas
- TPV-FIXES (3/3 bugs) ✅
- TICKET-LOGO ✅
- ORDERS-FIX ✅
Para iniciar una nueva feature: ### Pendientes
1. Seleccionar una feature `pending` de `backlog/features.json` - **INVENTORY-OPT**: Inventory optimization (pagination, queries, filters for 10k+ products)
2. Actualizar estado con `python3 scripts/agent_status.py set --feature-id <id> --stage intake --agent leader` - **SHIPPING-ZONES**: Restrict Balearic and Canary islands, add continental...
3. Continuar con `harness/workflow.stages.yml`
--- ### Issue pendiente de resolver
- **TPV stock**: Tema de stock con items convertidos a free items necesita revisión más profunda
## Resumen backlog ## Para iniciar próxima sesión
- Total features: 344 ```bash
- Features pendientes de iniciar: verificar con `rg '"status": "pending"' backlog/features.json | wc -l` ./scripts/pi_orquestra.sh
```
Seleccionar INVENTORY-OPT o SHIPPING-ZONES del backlog.

View File

@@ -1,152 +1,152 @@
{ {
"feature_id": "TPV-FIXES", "feature_id": "POS-RECEIPT-QUICK-FIXES",
"stage": "review_gate", "stage": "build",
"agent": "reviewer", "agent": "implementer",
"action": "Fixing favicon 404, cashier label, and pos/sales 400 error", "action": "Fix receipt-settings 400 and quick products save",
"state": "done", "state": "running",
"next_agent": null, "next_agent": null,
"waiting_for": "Seleccionar una feature pending y actualizar este estado", "waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-25T04:47:13Z", "updated_at": "2026-08-25T21:03:29Z",
"timeline": [ "timeline": [
{ {
"ts": "2026-08-25T04:27:58Z", "ts": "2026-08-25T19:46:39Z",
"agent": "implementer",
"stage": "build",
"state": "idle",
"message": "Estado actualizado"
},
{
"ts": "2026-08-25T04:28:09Z",
"agent": "implementer",
"stage": "build",
"state": "idle",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:28:15Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "running", "state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Rebuild dist for awaiting payment notification fix"
}, },
{ {
"ts": "2026-08-25T04:30:08Z", "ts": "2026-08-25T19:52:55Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "done", "state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Redeploy local prod stack after awaiting-payment fix"
}, },
{ {
"ts": "2026-08-25T04:30:24Z", "ts": "2026-08-25T19:53:50Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:30:35Z",
"agent": "security",
"stage": "security_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:30:44Z",
"agent": "qa",
"stage": "qa_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:31:04Z",
"agent": "leader",
"stage": "intake",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:31:58Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "running", "state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Fix monolith restart to stop old deployments before starting latest"
}, },
{ {
"ts": "2026-08-25T04:35:08Z", "ts": "2026-08-25T19:59:53Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "done", "state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Fix monolith restart to remove old deployments and run latest version"
}, },
{ {
"ts": "2026-08-25T04:35:09Z", "ts": "2026-08-25T20:00:40Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:35:18Z",
"agent": "security",
"stage": "security_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:35:26Z",
"agent": "qa",
"stage": "qa_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:35:43Z",
"agent": "leader",
"stage": "intake",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:37:19Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "running", "state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Add semantic versioning and conventional commit enforcement to Orquestra"
}, },
{ {
"ts": "2026-08-25T04:37:57Z", "ts": "2026-08-25T20:04:01Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "done", "state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Add semantic versioning and conventional commit enforcement to Orquestra"
}, },
{ {
"ts": "2026-08-25T04:37:57Z", "ts": "2026-08-25T20:04:58Z",
"agent": "reviewer",
"stage": "review_gate",
"state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
},
{
"ts": "2026-08-25T04:45:38Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "running", "state": "running",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Add settings about section with service versions and server info"
}, },
{ {
"ts": "2026-08-25T04:47:13Z", "ts": "2026-08-25T20:08:32Z",
"agent": "implementer", "agent": "implementer",
"stage": "build", "stage": "build",
"state": "done", "state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Add settings about section with service versions and server info"
}, },
{ {
"ts": "2026-08-25T04:47:13Z", "ts": "2026-08-25T20:12:55Z",
"agent": "reviewer", "agent": "implementer",
"stage": "review_gate", "stage": "build",
"state": "running",
"message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
},
{
"ts": "2026-08-25T20:16:24Z",
"agent": "implementer",
"stage": "build",
"state": "done", "state": "done",
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error" "message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
},
{
"ts": "2026-08-25T20:20:00Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix reporting trend chart height and closures report display"
},
{
"ts": "2026-08-25T20:24:31Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Fix reporting trend chart height and closures report display"
},
{
"ts": "2026-08-25T20:26:16Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
},
{
"ts": "2026-08-25T20:30:04Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
},
{
"ts": "2026-08-25T20:36:53Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Remove duplicate ticket print header and force white print background"
},
{
"ts": "2026-08-25T20:39:16Z",
"agent": "implementer",
"stage": "build",
"state": "done",
"message": "Remove duplicate ticket print header and force white print background"
},
{
"ts": "2026-08-25T20:39:16Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Diagnose PATCH /api/products/[id] 502 Bad Gateway"
},
{
"ts": "2026-08-25T20:50:29Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502"
},
{
"ts": "2026-08-25T20:54:10Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502, selfpay button rename"
},
{
"ts": "2026-08-25T21:03:29Z",
"agent": "implementer",
"stage": "build",
"state": "running",
"message": "Fix receipt-settings 400 and quick products save"
} }
] ]
} }