feat(F-158): completed feature
This commit is contained in:
@@ -6692,6 +6692,23 @@
|
|||||||
"close": true
|
"close": true
|
||||||
},
|
},
|
||||||
"completed_at": "2026-08-22T15:42:35Z"
|
"completed_at": "2026-08-22T15:42:35Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "F-158",
|
||||||
|
"type": "fix",
|
||||||
|
"title": "POS use same-origin API proxy on LAN",
|
||||||
|
"description": "Browser POS requests must use /api proxy instead of localhost:3000; proxy forwards backend cookies and terminal headers.",
|
||||||
|
"priority": "high",
|
||||||
|
"risk": "med",
|
||||||
|
"status": "done",
|
||||||
|
"created_at": "2026-08-22",
|
||||||
|
"gates": {
|
||||||
|
"reviewer": true,
|
||||||
|
"security": true,
|
||||||
|
"qa": true,
|
||||||
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T15:50:54Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { NextConfig } from 'next';
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
port: 3006,
|
allowedDevOrigins: ['192.168.18.93', 'localhost'],
|
||||||
env: {
|
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import type { NextLayout } from 'next';
|
export default function TerminalLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
const TerminalLayout: NextLayout = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default TerminalLayout;
|
|
||||||
|
|||||||
@@ -30,26 +30,65 @@ interface Config {
|
|||||||
paymentMethods: { id: string; code: string; label: string; kind: string }[];
|
paymentMethods: { id: string; code: string; label: string; kind: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [config, setConfig] = useState<Config | null>(null);
|
const [config, setConfig] = useState<Config | null>(null);
|
||||||
|
const [configError, setConfigError] = useState('');
|
||||||
|
const [needsBinding, setNeedsBinding] = useState(false);
|
||||||
|
const [bindingCode, setBindingCode] = useState('');
|
||||||
|
const [binding, setBinding] = useState(false);
|
||||||
const [cart, setCart] = useState<CartItem[]>([]);
|
const [cart, setCart] = useState<CartItem[]>([]);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<CartItem | null>(null);
|
||||||
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
|
const [showDiscountPanel, setShowDiscountPanel] = useState(false);
|
||||||
const [customer, setCustomer] = useState<{ id: string; email: string; firstName?: string; lastName?: string } | null>(null);
|
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||||
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
|
const [showCustomerSearch, setShowCustomerSearch] = useState(false);
|
||||||
const [customerQuery, setCustomerQuery] = useState('');
|
const [customerQuery, setCustomerQuery] = useState('');
|
||||||
const [customerResults, setCustomerResults] = useState<typeof customer[]>([]);
|
const [customerResults, setCustomerResults] = useState<Customer[]>([]);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
|
const [lastSale, setLastSale] = useState<{ orderId: string; totalCents: number } | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const loadConfig = useCallback(async () => {
|
||||||
posApi.config().then(setConfig).catch(() => setConfig(null));
|
setConfigError('');
|
||||||
|
try {
|
||||||
|
setConfig(await posApi.config<Config>());
|
||||||
|
setNeedsBinding(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiError = err as { code?: string; message?: string };
|
||||||
|
setConfig(null);
|
||||||
|
setNeedsBinding(apiError.code === 'MISSING_TERMINAL_ID');
|
||||||
|
setConfigError(apiError.message ?? 'No se pudo cargar la configuración del TPV');
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadConfig();
|
||||||
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
const bindTerminal = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBinding(true);
|
||||||
|
setConfigError('');
|
||||||
|
try {
|
||||||
|
await posApi.bind(bindingCode.trim().toUpperCase());
|
||||||
|
setBindingCode('');
|
||||||
|
await loadConfig();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setConfigError(err instanceof Error ? err.message : 'No se pudo vincular el terminal');
|
||||||
|
} finally {
|
||||||
|
setBinding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
if (q.trim().length < 2) { setSearchResults([]); return; }
|
if (q.trim().length < 2) { setSearchResults([]); return; }
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
@@ -104,7 +143,7 @@ export default function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' });
|
const res = await fetch(`/api/pos/customers/search?q=${encodeURIComponent(q)}`, { credentials: 'include' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json() as { items: typeof customer[] };
|
const data = await res.json() as { items: Customer[] };
|
||||||
setCustomerResults(data.items ?? []);
|
setCustomerResults(data.items ?? []);
|
||||||
}
|
}
|
||||||
} catch { setCustomerResults([]); }
|
} catch { setCustomerResults([]); }
|
||||||
@@ -138,15 +177,56 @@ export default function RegisterPage() {
|
|||||||
setCart([]);
|
setCart([]);
|
||||||
setCustomer(null);
|
setCustomer(null);
|
||||||
setTimeout(() => setLastSale(null), 5000);
|
setTimeout(() => setLastSale(null), 5000);
|
||||||
} catch (err: { message?: string }) {
|
} catch (err: unknown) {
|
||||||
setError((err as { message?: string }).message ?? 'Error');
|
setError(err instanceof Error ? err.message : 'Error');
|
||||||
} finally {
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!config && needsBinding) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-100 p-6">
|
||||||
|
<form onSubmit={bindTerminal} className="w-full max-w-sm space-y-4 rounded-2xl bg-white p-8 shadow-lg">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Vincular terminal</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Introduce el código de 8 caracteres generado en administración.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={bindingCode}
|
||||||
|
onChange={(event) => setBindingCode(event.target.value.toUpperCase())}
|
||||||
|
minLength={8}
|
||||||
|
maxLength={8}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full rounded-xl border border-gray-300 px-4 py-3 text-center font-mono text-xl tracking-widest outline-none focus:ring-2 focus:ring-[#2D6A4F]"
|
||||||
|
placeholder="AB12CD34"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{configError && <p className="text-sm text-red-600">{configError}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={binding || bindingCode.trim().length !== 8}
|
||||||
|
className="w-full rounded-xl bg-[#2D6A4F] py-2.5 font-semibold text-white transition-colors hover:bg-[#1B4332] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{binding ? 'Vinculando…' : 'Vincular TPV'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return <div className="flex items-center justify-center min-h-screen text-gray-500">Cargando TPV…</div>;
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-gray-500">
|
||||||
|
<p>{configError || 'Cargando TPV…'}</p>
|
||||||
|
{configError && (
|
||||||
|
<button onClick={() => void loadConfig()} className="text-sm font-medium text-[#2D6A4F] hover:underline">
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.session?.status !== 'OPEN') {
|
if (config.session?.status !== 'OPEN') {
|
||||||
|
|||||||
@@ -1,32 +1,79 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
const BACKEND_URL = process.env.POS_BACKEND_URL ?? 'http://127.0.0.1:3000';
|
||||||
|
|
||||||
|
function apiPath(request: NextRequest): string {
|
||||||
|
return request.nextUrl.pathname.replace(/^\/api\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendUrl(request: NextRequest): string {
|
||||||
|
return `${BACKEND_URL}/${apiPath(request)}${request.nextUrl.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestHeaders(request: NextRequest, hasBody = false): Headers {
|
||||||
|
const headers = new Headers();
|
||||||
|
const cookie = request.headers.get('cookie');
|
||||||
|
const terminalId = request.headers.get('x-terminal-id') ?? request.cookies.get('pos_terminal_id')?.value;
|
||||||
|
|
||||||
|
if (cookie) headers.set('cookie', cookie);
|
||||||
|
if (terminalId) headers.set('x-terminal-id', terminalId);
|
||||||
|
if (hasBody) headers.set('content-type', request.headers.get('content-type') ?? 'application/json');
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyResponse(backendResponse: Response): NextResponse {
|
||||||
|
const response = new NextResponse(backendResponse.body, {
|
||||||
|
status: backendResponse.status,
|
||||||
|
statusText: backendResponse.statusText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = backendResponse.headers.get('content-type');
|
||||||
|
const setCookie = backendResponse.headers.get('set-cookie');
|
||||||
|
if (contentType) response.headers.set('content-type', contentType);
|
||||||
|
if (setCookie) response.headers.set('set-cookie', setCookie);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const path = request.nextUrl.pathname.replace('/api/', '');
|
try {
|
||||||
const search = request.nextUrl.search;
|
const response = await fetch(backendUrl(request), {
|
||||||
const cookie = request.headers.get('cookie') ?? '';
|
headers: requestHeaders(request),
|
||||||
const terminalId = request.headers.get('x-terminal-id');
|
cache: 'no-store',
|
||||||
|
});
|
||||||
const headers: Record<string, string> = { Cookie: cookie };
|
return proxyResponse(response);
|
||||||
if (terminalId) headers['x-terminal-id'] = terminalId;
|
} catch {
|
||||||
|
return NextResponse.json({ message: 'Backend no disponible' }, { status: 502 });
|
||||||
const res = await fetch(`${API}/${path}${search}`, { headers, credentials: 'include' });
|
}
|
||||||
const body = await res.text();
|
|
||||||
return new NextResponse(body, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const path = request.nextUrl.pathname.replace('/api/', '');
|
try {
|
||||||
const search = request.nextUrl.search;
|
const body = await request.text();
|
||||||
const cookie = request.headers.get('cookie') ?? '';
|
const response = await fetch(backendUrl(request), {
|
||||||
const terminalId = request.headers.get('x-terminal-id');
|
method: 'POST',
|
||||||
const body = await request.text();
|
headers: requestHeaders(request, true),
|
||||||
|
body,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const bindingPayload = response.ok && apiPath(request) === 'pos/terminals/bind'
|
||||||
|
? await response.clone().json() as { terminalId?: string }
|
||||||
|
: null;
|
||||||
|
const proxied = proxyResponse(response);
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', Cookie: cookie };
|
if (bindingPayload?.terminalId) {
|
||||||
if (terminalId) headers['x-terminal-id'] = terminalId;
|
proxied.cookies.set('pos_terminal_id', bindingPayload.terminalId, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 365 * 24 * 60 * 60,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API}/${path}${search}`, { method: 'POST', headers, body, credentials: 'include' });
|
return proxied;
|
||||||
const resBody = await res.text();
|
} catch {
|
||||||
return new NextResponse(resBody, { status: res.status, headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' } });
|
return NextResponse.json({ message: 'Backend no disponible' }, { status: 502 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
// Browser requests stay on the POS origin. The Next.js route handler proxies
|
||||||
|
// `/api/*` to the backend, so LAN clients never resolve their own localhost.
|
||||||
|
const API = '/api';
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API}${path}`, {
|
const res = await fetch(`${API}${path}`, {
|
||||||
@@ -10,8 +12,14 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
const err = await res.json().catch(() => ({ message: res.statusText })) as {
|
||||||
throw Object.assign(new Error((err as { message?: string }).message ?? 'Request failed'), { status: res.status });
|
message?: string;
|
||||||
|
error?: { message?: string; code?: string };
|
||||||
|
};
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(err.error?.message ?? err.message ?? 'Request failed'),
|
||||||
|
{ status: res.status, code: err.error?.code },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -21,9 +29,12 @@ export const posApi = {
|
|||||||
me: () => apiFetch('/pos/terminals/me'),
|
me: () => apiFetch('/pos/terminals/me'),
|
||||||
/** Bind terminal with code. */
|
/** Bind terminal with code. */
|
||||||
bind: (bindingCode: string) =>
|
bind: (bindingCode: string) =>
|
||||||
apiFetch('/pos/terminals/bind', { method: 'POST', body: JSON.stringify({ bindingCode }) }),
|
apiFetch<{ terminalId: string; storeId: string }>('/pos/terminals/bind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ bindingCode }),
|
||||||
|
}),
|
||||||
/** Get POS config (store + terminal + payment methods + session status). */
|
/** Get POS config (store + terminal + payment methods + session status). */
|
||||||
config: () => apiFetch('/pos/config'),
|
config: <T>() => apiFetch<T>('/pos/config'),
|
||||||
/** List products by query. */
|
/** List products by query. */
|
||||||
searchProducts: (q: string, storeId?: string, limit = 20) =>
|
searchProducts: (q: string, storeId?: string, limit = 20) =>
|
||||||
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
|
apiFetch(`/pos/products/search?q=${encodeURIComponent(q)}&storeId=${storeId ?? ''}&limit=${limit}`),
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ export function middleware(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for backoffice session cookie
|
// The identity backend issues the mdv_session cookie. Keep legacy names
|
||||||
|
// accepted during migration, but do not redirect valid current sessions.
|
||||||
const cookie = request.headers.get('cookie') ?? '';
|
const cookie = request.headers.get('cookie') ?? '';
|
||||||
const hasSession = cookie.includes('backoffice_session') || cookie.includes('session_token');
|
const hasSession =
|
||||||
|
cookie.includes('mdv_session=') ||
|
||||||
|
cookie.includes('backoffice_session=') ||
|
||||||
|
cookie.includes('session_token=');
|
||||||
|
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
return NextResponse.redirect(new URL('/login', request.url));
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
|||||||
20
work/artifacts/F-158/architect.md
Normal file
20
work/artifacts/F-158/architect.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# F-158 — Diseño
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
Separar explícitamente URL pública y URL privada:
|
||||||
|
|
||||||
|
- Navegador POS: siempre usa `/api` del mismo origen (`:3006`).
|
||||||
|
- Route handler Next.js: usa `POS_BACKEND_URL`, server-only, con fallback `http://127.0.0.1:3000`.
|
||||||
|
- No exponer ni incrustar `NEXT_PUBLIC_API_URL` en el bundle.
|
||||||
|
|
||||||
|
## Proxy
|
||||||
|
- Construye destino desde el catch-all path y query string.
|
||||||
|
- Reenvía Cookie, Content-Type y `x-terminal-id`.
|
||||||
|
- Propaga status, Content-Type y todos los `Set-Cookie` del backend.
|
||||||
|
- GET y POST cubren las operaciones actuales del cliente POS.
|
||||||
|
|
||||||
|
## Desarrollo LAN
|
||||||
|
`allowedDevOrigins` incluye `192.168.18.93` y `localhost` para recursos Next/HMR.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
La URL backend queda server-only. Los paths proceden del segmento catch-all y se concatenan contra una base fija, sin aceptar host suministrado por el cliente.
|
||||||
14
work/artifacts/F-158/documenter.md
Normal file
14
work/artifacts/F-158/documenter.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# F-158 — Documentación
|
||||||
|
|
||||||
|
## Acceso LAN
|
||||||
|
El TPV se abre en `http://192.168.18.93:3006`. Todas las llamadas del navegador permanecen en ese origen mediante `/api`; solo el servidor Next accede al backend en `127.0.0.1:3000`.
|
||||||
|
|
||||||
|
## Primera vinculación
|
||||||
|
Después del login, un dispositivo sin terminal muestra la pantalla **Vincular terminal**. Debe introducir el código de 8 caracteres. La vinculación se guarda en una cookie HttpOnly durante un año.
|
||||||
|
|
||||||
|
## Entorno local actual
|
||||||
|
- Terminal: `TPV Principal`
|
||||||
|
- Código de vinculación: `B794401C`
|
||||||
|
- Backend: puerto 3000
|
||||||
|
- Admin: puerto 3004
|
||||||
|
- POS: puerto 3006
|
||||||
24
work/artifacts/F-158/implementer.md
Normal file
24
work/artifacts/F-158/implementer.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# F-158 — Implementer
|
||||||
|
|
||||||
|
## Implementación
|
||||||
|
- El cliente POS usa exclusivamente `/api`, nunca `localhost:3000` desde el navegador.
|
||||||
|
- El proxy Next usa `POS_BACKEND_URL` server-only con fallback `127.0.0.1:3000`.
|
||||||
|
- El proxy reenvía sesión, terminal y Content-Type; propaga `Set-Cookie` del login.
|
||||||
|
- La vinculación guarda `pos_terminal_id` como cookie HttpOnly durante un año y el proxy la traduce a `x-terminal-id`.
|
||||||
|
- Middleware reconoce la cookie real `mdv_session` además de nombres legacy.
|
||||||
|
- Añadida pantalla de vinculación inicial de 8 caracteres y estados de error/reintento.
|
||||||
|
- Corregidos errores TypeScript previos del esqueleto POS (`NextLayout`, tipos de config/customer/catch).
|
||||||
|
- `allowedDevOrigins` permite `192.168.18.93` y `localhost`.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
- `cd project/apps/pos && npm run build`: PASS, 4 rutas + middleware.
|
||||||
|
- `cd project/apps/pos && npx tsc --noEmit`: PASS.
|
||||||
|
- `cd project && node_modules/.bin/tsc --noEmit`: PASS.
|
||||||
|
- Login proxy: HTTP 200 + cookie `mdv_session`.
|
||||||
|
- Bind proxy: HTTP 200 + cookie `pos_terminal_id`.
|
||||||
|
- Config proxy autenticado/vinculado: HTTP 200 con store, terminal y métodos de pago.
|
||||||
|
- Servicios persistentes: 3000, 3004 y 3006 escuchando.
|
||||||
|
- `./scripts/verify.sh`: PASS.
|
||||||
|
|
||||||
|
## Configuración local
|
||||||
|
Se creó `TPV Principal` para la tienda por defecto. Código de vinculación: `B794401C`.
|
||||||
16
work/artifacts/F-158/leader-close.json
Normal file
16
work/artifacts/F-158/leader-close.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-158",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "POS LAN usa proxy same-origin y completa login, vinculación y config sin localhost del navegador.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Reviewer approved", "ok": true },
|
||||||
|
{ "item": "Security approved", "ok": true },
|
||||||
|
{ "item": "QA approved", "ok": true },
|
||||||
|
{ "item": "POS build passed", "ok": true },
|
||||||
|
{ "item": "Runtime acceptance passed", "ok": true },
|
||||||
|
{ "item": "verify.sh passed", "ok": true }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
16
work/artifacts/F-158/qa.json
Normal file
16
work/artifacts/F-158/qa.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-158",
|
||||||
|
"agent": "qa",
|
||||||
|
"stage": "qa_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Flujo LAN completo validado contra 192.168.18.93:3006.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Login through POS proxy", "ok": true, "evidence": "HTTP 200 and mdv_session cookie" },
|
||||||
|
{ "item": "Terminal bind through POS proxy", "ok": true, "evidence": "HTTP 200 and pos_terminal_id cookie" },
|
||||||
|
{ "item": "POS config through proxy", "ok": true, "evidence": "HTTP 200 with store, terminal and payment methods" },
|
||||||
|
{ "item": "No direct browser backend request", "ok": true, "evidence": "api-client uses /api" },
|
||||||
|
{ "item": "Build and types", "ok": true, "evidence": "POS build and both TypeScript checks passed" },
|
||||||
|
{ "item": "Runtime services", "ok": true, "evidence": "Ports 3000, 3004 and 3006 listening" }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
15
work/artifacts/F-158/reviewer.json
Normal file
15
work/artifacts/F-158/reviewer.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-158",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"stage": "review_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "El POS elimina la dependencia de localhost en navegador y completa login, vinculación y config por proxy same-origin.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "No browser localhost URL", "ok": true, "evidence": "api-client base is /api; source search has no NEXT_PUBLIC_API_URL" },
|
||||||
|
{ "item": "Proxy contract", "ok": true, "evidence": "Cookies, terminal header, status and content type forwarded" },
|
||||||
|
{ "item": "Terminal persistence", "ok": true, "evidence": "HttpOnly pos_terminal_id cookie translated to x-terminal-id" },
|
||||||
|
{ "item": "POS production build", "ok": true, "evidence": "Next.js 15 build passed" },
|
||||||
|
{ "item": "Runtime flow", "ok": true, "evidence": "login 200, bind 200, config 200 through :3006" }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
14
work/artifacts/F-158/security.json
Normal file
14
work/artifacts/F-158/security.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-158",
|
||||||
|
"agent": "security",
|
||||||
|
"stage": "security_gate",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "La URL backend queda server-only y las cookies de sesión/terminal se mantienen HttpOnly y same-origin.",
|
||||||
|
"checks": [
|
||||||
|
{ "item": "Backend URL not public", "ok": true, "evidence": "POS_BACKEND_URL is only read in server route handler" },
|
||||||
|
{ "item": "Terminal cookie protection", "ok": true, "evidence": "HttpOnly, SameSite=Lax, Secure in production" },
|
||||||
|
{ "item": "Fixed upstream host", "ok": true, "evidence": "Client controls path only; upstream base comes from trusted env" },
|
||||||
|
{ "item": "No broad header forwarding", "ok": true, "evidence": "Only cookie, content-type and x-terminal-id forwarded" }
|
||||||
|
],
|
||||||
|
"issues": []
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
# Feature activa: F-157 — Reporting sections live on Reporting page
|
# Feature activa: F-158 — POS use same-origin API proxy on LAN
|
||||||
|
|
||||||
## Problema
|
## Problema
|
||||||
El menú principal muestra Dashboard, Ventas y Productos de Reporting como entradas indentadas. El operador requiere una única entrada principal **Reporting**. Al abrirla, sus apartados deben aparecer dentro del área de Reporting, igual que las pestañas internas de Ajustes.
|
El TPV abierto en `http://192.168.18.93:3006` ejecuta peticiones del navegador contra `http://localhost:3000`. En un cliente LAN, `localhost` apunta al propio cliente y produce `ERR_CONNECTION_REFUSED`.
|
||||||
|
|
||||||
## Alcance
|
## Alcance
|
||||||
- El sidebar principal muestra solo `Reporting`.
|
- El cliente POS usa rutas same-origin `/api/...`.
|
||||||
- `/reporting` abre por defecto el apartado Dashboard.
|
- El route handler de Next.js reenvía las peticiones al backend privado (`127.0.0.1:3000`).
|
||||||
- Dashboard, Ventas y Productos se muestran como navegación local dentro de Reporting.
|
- El proxy conserva cookies de sesión y `x-terminal-id`.
|
||||||
- La navegación local permanece visible en las páginas de los tres apartados.
|
- El login propaga `Set-Cookie` al navegador.
|
||||||
- No se cambian APIs ni permisos de Reporting.
|
- Next dev permite el origen LAN `192.168.18.93`.
|
||||||
|
|
||||||
## Aceptación
|
## Aceptación
|
||||||
1. No aparecen `/reporting/dashboard`, `/reporting/sales` ni `/reporting/products` en el menú principal.
|
1. El navegador no solicita directamente `localhost:3000`.
|
||||||
2. Al pulsar Reporting aparece una lista local con Dashboard, Ventas y Productos.
|
2. `GET /api/pos/config` llega al backend mediante el proxy.
|
||||||
3. El apartado activo queda visualmente marcado.
|
3. Login conserva la cookie de sesión.
|
||||||
4. El build del admin y `verify.sh` pasan.
|
4. POS build, TypeScript y `verify.sh` pasan.
|
||||||
|
|||||||
@@ -1,68 +1,68 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-157",
|
"feature_id": "F-158",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close F-157 after approved gates",
|
"action": "Close F-158 after LAN acceptance",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-22T15:42:24Z",
|
"updated_at": "2026-08-22T15:50:42Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:38:27Z",
|
"ts": "2026-08-22T15:42:54Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"stage": "intake",
|
"stage": "intake",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Define Reporting navigation as page-local sections"
|
"message": "Define LAN-safe POS API flow"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:39:01Z",
|
"ts": "2026-08-22T15:43:10Z",
|
||||||
"agent": "architect",
|
"agent": "architect",
|
||||||
"stage": "design",
|
"stage": "design",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Design page-local Reporting navigation"
|
"message": "Design same-origin POS proxy and cookie forwarding"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:39:21Z",
|
"ts": "2026-08-22T15:43:38Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Move Reporting sections from sidebar into Reporting layout"
|
"message": "Implement LAN-safe POS API proxy"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:41:38Z",
|
"ts": "2026-08-22T15:49:47Z",
|
||||||
"agent": "reviewer",
|
"agent": "reviewer",
|
||||||
"stage": "review_gate",
|
"stage": "review_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Review Reporting navigation and build evidence"
|
"message": "Review POS same-origin proxy and binding flow"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:41:52Z",
|
"ts": "2026-08-22T15:49:59Z",
|
||||||
"agent": "security",
|
"agent": "security",
|
||||||
"stage": "security_gate",
|
"stage": "security_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Check navigation RBAC and client URL safety"
|
"message": "Audit POS proxy cookies, headers and backend URL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:42:06Z",
|
"ts": "2026-08-22T15:50:19Z",
|
||||||
"agent": "qa",
|
"agent": "qa",
|
||||||
"stage": "qa_gate",
|
"stage": "qa_gate",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Validate Reporting sidebar and local section acceptance"
|
"message": "Run POS LAN login-bind-config acceptance"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:42:15Z",
|
"ts": "2026-08-22T15:50:31Z",
|
||||||
"agent": "documenter",
|
"agent": "documenter",
|
||||||
"stage": "document",
|
"stage": "document",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Document Reporting navigation behavior"
|
"message": "Document POS LAN and terminal binding behavior"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-22T15:42:24Z",
|
"ts": "2026-08-22T15:50:42Z",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close F-157 after approved gates"
|
"message": "Close F-158 after LAN acceptance"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user