feat(F-111): completed feature
This commit is contained in:
@@ -4820,13 +4820,15 @@
|
|||||||
"History visible in order detail",
|
"History visible in order detail",
|
||||||
"verify.sh is green"
|
"verify.sh is green"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-21",
|
"created_at": "2026-08-21",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-21T08:10:35Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-112",
|
"id": "F-112",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Order, OrderState, Product } from '@/types';
|
import type { Order, OrderState, Product, OrderHistoryEvent } from '@/types';
|
||||||
import { ordersApi, productsApi } from '@/lib/api-client';
|
import { ordersApi, productsApi } from '@/lib/api-client';
|
||||||
|
|
||||||
const STATE_LABELS: Record<OrderState, string> = {
|
const STATE_LABELS: Record<OrderState, string> = {
|
||||||
@@ -87,12 +87,29 @@ export default function OrderDetailPage() {
|
|||||||
const [addingProductId, setAddingProductId] = useState<string | null>(null);
|
const [addingProductId, setAddingProductId] = useState<string | null>(null);
|
||||||
const [addingVariants, setAddingVariants] = useState<{ id: string; sku: string }[]>([]);
|
const [addingVariants, setAddingVariants] = useState<{ id: string; sku: string }[]>([]);
|
||||||
|
|
||||||
|
// Envío e historial (F-111)
|
||||||
|
const [history, setHistory] = useState<OrderHistoryEvent[]>([]);
|
||||||
|
const [shippingTracking, setShippingTracking] = useState('');
|
||||||
|
const [shippingNote, setShippingNote] = useState('');
|
||||||
|
const [savingShipping, setSavingShipping] = useState(false);
|
||||||
|
const [shippingMsg, setShippingMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await ordersApi.history(id);
|
||||||
|
setHistory(result.items ?? []);
|
||||||
|
} catch {
|
||||||
|
setHistory([]);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await ordersApi.get(id);
|
const data = await ordersApi.get(id);
|
||||||
setOrder(data);
|
setOrder(data);
|
||||||
|
setShippingTracking(data.trackingNumber ?? '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -100,7 +117,7 @@ export default function OrderDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); loadHistory(); }, [load, loadHistory]);
|
||||||
|
|
||||||
const handleTransition = async (nextState: OrderState) => {
|
const handleTransition = async (nextState: OrderState) => {
|
||||||
if (nextState === 'SHIPPED' && !trackingInput.trim()) return;
|
if (nextState === 'SHIPPED' && !trackingInput.trim()) return;
|
||||||
@@ -108,9 +125,11 @@ export default function OrderDetailPage() {
|
|||||||
try {
|
try {
|
||||||
const updated = await ordersApi.transition(id, nextState, trackingInput);
|
const updated = await ordersApi.transition(id, nextState, trackingInput);
|
||||||
setOrder(updated);
|
setOrder(updated);
|
||||||
|
setShippingTracking(updated.trackingNumber ?? '');
|
||||||
setShowConfirm(null);
|
setShowConfirm(null);
|
||||||
setConfirmReason('');
|
setConfirmReason('');
|
||||||
setTrackingInput('');
|
setTrackingInput('');
|
||||||
|
loadHistory();
|
||||||
if (updated.notified) {
|
if (updated.notified) {
|
||||||
setNotification({ ok: true, message: 'Cliente notificado por email.' });
|
setNotification({ ok: true, message: 'Cliente notificado por email.' });
|
||||||
} else {
|
} else {
|
||||||
@@ -228,6 +247,29 @@ export default function OrderDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveShipping = async () => {
|
||||||
|
const tracking = shippingTracking.trim();
|
||||||
|
if (!tracking) {
|
||||||
|
setShippingMsg({ ok: false, text: 'Indica un número de seguimiento' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingShipping(true);
|
||||||
|
setShippingMsg(null);
|
||||||
|
try {
|
||||||
|
const updated = await ordersApi.updateShipping(id, tracking, shippingNote);
|
||||||
|
setOrder(updated);
|
||||||
|
setShippingTracking(updated.trackingNumber ?? tracking);
|
||||||
|
setShippingNote('');
|
||||||
|
setShippingMsg({ ok: true, text: 'Envío actualizado y registrado en el historial' });
|
||||||
|
loadHistory();
|
||||||
|
setTimeout(() => setShippingMsg(null), 4000);
|
||||||
|
} catch (err) {
|
||||||
|
setShippingMsg({ ok: false, text: err instanceof Error ? err.message : 'Error al guardar envío' });
|
||||||
|
} finally {
|
||||||
|
setSavingShipping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-64">
|
<div className="flex items-center justify-center min-h-64">
|
||||||
@@ -447,6 +489,41 @@ export default function OrderDetailPage() {
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Envío */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h2 className="font-bold text-gray-900 mb-4">Envío</h2>
|
||||||
|
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
Número de seguimiento
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shippingTracking}
|
||||||
|
onChange={(e) => setShippingTracking(e.target.value)}
|
||||||
|
placeholder="p. ej. PQ123456789ES"
|
||||||
|
maxLength={120}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shippingNote}
|
||||||
|
onChange={(e) => setShippingNote(e.target.value)}
|
||||||
|
placeholder="Nota interna (opcional)"
|
||||||
|
maxLength={500}
|
||||||
|
className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveShipping}
|
||||||
|
disabled={savingShipping || !shippingTracking.trim()}
|
||||||
|
className="mt-3 w-full px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
{savingShipping ? 'Guardando…' : 'Guardar envío'}
|
||||||
|
</button>
|
||||||
|
{shippingMsg && (
|
||||||
|
<p className={`mt-2 text-xs ${shippingMsg.ok ? 'text-green-600' : 'text-red-600'}`}>{shippingMsg.text}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-gray-400">Cada cambio queda registrado en el historial del pedido.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
<h2 className="font-bold text-gray-900 mb-4">Resumen</h2>
|
<h2 className="font-bold text-gray-900 mb-4">Resumen</h2>
|
||||||
@@ -491,6 +568,21 @@ export default function OrderDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{[...history].reverse().map((event) => (
|
||||||
|
<div key={event.id} className="flex gap-3">
|
||||||
|
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${event.eventType === 'SHIPPING_UPDATE' ? 'bg-purple-400' : 'bg-blue-400'}`} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-gray-800 break-words">{event.message}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{new Date(event.createdAt).toLocaleString('es-ES')}
|
||||||
|
{event.actorEmail ? ` · ${event.actorEmail}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{history.length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Sin eventos registrados todavía.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ export const ordersApi = {
|
|||||||
),
|
),
|
||||||
editItems: (id: string, items: { variantId: string; quantity: number }[]) =>
|
editItems: (id: string, items: { variantId: string; quantity: number }[]) =>
|
||||||
api.put<import('@/types').Order>(`/api/orders/${id}/items`, { items }),
|
api.put<import('@/types').Order>(`/api/orders/${id}/items`, { items }),
|
||||||
|
updateShipping: (id: string, trackingNumber: string, note?: string) =>
|
||||||
|
api.post<import('@/types').Order>(
|
||||||
|
`/api/orders/${id}/shipping`,
|
||||||
|
note?.trim() ? { trackingNumber, note } : { trackingNumber },
|
||||||
|
),
|
||||||
|
history: (id: string) =>
|
||||||
|
api.get<{ items: import('@/types').OrderHistoryEvent[] }>(`/api/orders/${id}/history`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Customers ─────────────────────────────────────────────────────────────────
|
// ── Customers ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ export interface Order {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrderHistoryEvent {
|
||||||
|
id: string;
|
||||||
|
eventType: 'STATE_CHANGE' | 'SHIPPING_UPDATE' | string;
|
||||||
|
message: string;
|
||||||
|
trackingNumber: string | null;
|
||||||
|
actorEmail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrderSummary {
|
export interface OrderSummary {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
25
project/migrations/037_orders_history.js
Normal file
25
project/migrations/037_orders_history.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Order history log. Every shipping/tracking change and admin state transition
|
||||||
|
* is recorded so the order detail can show a full audit trail.
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS orders_order_history (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
order_id uuid NOT NULL REFERENCES orders_orders(id) ON DELETE CASCADE,
|
||||||
|
event_type text NOT NULL,
|
||||||
|
message text NOT NULL,
|
||||||
|
tracking_number text NULL,
|
||||||
|
actor_email text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
pgm.sql(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_orders_order_history_order ON orders_order_history (order_id, created_at)`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`DROP TABLE IF EXISTS orders_order_history`);
|
||||||
|
};
|
||||||
@@ -12,7 +12,7 @@ import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.
|
|||||||
import type { OrderState } from '../domain/order.js';
|
import type { OrderState } from '../domain/order.js';
|
||||||
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
|
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
|
||||||
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
|
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
|
||||||
import { sendOrderStatusEmail } from '../infrastructure/order-status-mailer.js';
|
import { sendOrderStatusEmail, ORDER_STATE_LABELS } from '../infrastructure/order-status-mailer.js';
|
||||||
|
|
||||||
export interface OrdersRoutesDeps {
|
export interface OrdersRoutesDeps {
|
||||||
pool: pg.Pool;
|
pool: pg.Pool;
|
||||||
@@ -275,10 +275,92 @@ export async function registerOrdersRoutes(
|
|||||||
notificationError = error instanceof Error ? error.message : 'No se pudo enviar el email';
|
notificationError = error instanceof Error ? error.message : 'No se pudo enviar el email';
|
||||||
request.log.warn({ err: error, orderId: order.id }, 'order_status_email_failed');
|
request.log.warn({ err: error, orderId: order.id }, 'order_status_email_failed');
|
||||||
}
|
}
|
||||||
|
await recordOrderHistory(
|
||||||
|
deps.pool,
|
||||||
|
order.id,
|
||||||
|
'STATE_CHANGE',
|
||||||
|
`Estado cambiado a ${ORDER_STATE_LABELS[order.state] ?? order.state}${order.trackingNumber ? ` · Tracking: ${order.trackingNumber}` : ''}`,
|
||||||
|
order.trackingNumber ?? null,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
return reply.send({ ...serializeOrder(order), notified, notificationError: notificationError || undefined });
|
return reply.send({ ...serializeOrder(order), notified, notificationError: notificationError || undefined });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Gestión de envío (admin): tracking + historial ────────────────────────
|
||||||
|
const shippingSchema: FastifySchema = {
|
||||||
|
tags: ['Orders'],
|
||||||
|
summary: 'Update order shipping (admin)',
|
||||||
|
description: 'Actualiza el número de seguimiento del pedido y registra el cambio en el historial.',
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: { id: { type: 'string', format: 'uuid' } },
|
||||||
|
},
|
||||||
|
body: { type: 'object' },
|
||||||
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
|
};
|
||||||
|
app.post('/orders/:id/shipping', { schema: shippingSchema }, async (request, reply) => {
|
||||||
|
const user = await deps.authenticate(request);
|
||||||
|
requireRole(user, 'admin');
|
||||||
|
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||||
|
const { trackingNumber, note } = parseJson(
|
||||||
|
z.object({
|
||||||
|
trackingNumber: z.string().trim().min(1).max(120),
|
||||||
|
note: z.string().trim().max(500).optional(),
|
||||||
|
}),
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
const existing = await service.getOrderAdmin(id);
|
||||||
|
if (!existing) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
|
||||||
|
await deps.pool.query(
|
||||||
|
`UPDATE orders_orders SET tracking_number = $2, updated_at = now() WHERE id = $1`,
|
||||||
|
[id, trackingNumber],
|
||||||
|
);
|
||||||
|
const message = `Seguimiento actualizado: ${trackingNumber}${note ? ` · Nota: ${note}` : ''}`;
|
||||||
|
await recordOrderHistory(deps.pool, id, 'SHIPPING_UPDATE', message, trackingNumber, user.email);
|
||||||
|
const updated = await service.getOrderAdmin(id);
|
||||||
|
return reply.send(serializeOrder(updated ?? { ...existing, trackingNumber }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const historySchema: FastifySchema = {
|
||||||
|
tags: ['Orders'],
|
||||||
|
summary: 'Order history (admin)',
|
||||||
|
params: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: { id: { type: 'string', format: 'uuid' } },
|
||||||
|
},
|
||||||
|
response: { 401: errorSchema, 403: errorSchema },
|
||||||
|
};
|
||||||
|
app.get('/orders/:id/history', { schema: historySchema }, async (request, reply) => {
|
||||||
|
const user = await deps.authenticate(request);
|
||||||
|
requireRole(user, 'admin');
|
||||||
|
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||||
|
const result = await deps.pool.query<{
|
||||||
|
id: string;
|
||||||
|
event_type: string;
|
||||||
|
message: string;
|
||||||
|
tracking_number: string | null;
|
||||||
|
actor_email: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT id, event_type, message, tracking_number, actor_email, created_at
|
||||||
|
FROM orders_order_history WHERE order_id = $1 ORDER BY created_at ASC, id ASC`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return reply.send({
|
||||||
|
items: result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
eventType: row.event_type,
|
||||||
|
message: row.message,
|
||||||
|
trackingNumber: row.tracking_number,
|
||||||
|
actorEmail: row.actor_email,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const editItemsSchema: FastifySchema = {
|
const editItemsSchema: FastifySchema = {
|
||||||
tags: ['Orders'],
|
tags: ['Orders'],
|
||||||
summary: 'Edit order items (admin)',
|
summary: 'Edit order items (admin)',
|
||||||
@@ -454,3 +536,26 @@ function serializeOrder(order: {
|
|||||||
updatedAt: order.updatedAt.toISOString(),
|
updatedAt: order.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra un evento en el historial del pedido. Los fallos se loguean pero
|
||||||
|
* nunca bloquean la operación principal (transición o actualización de envío).
|
||||||
|
*/
|
||||||
|
async function recordOrderHistory(
|
||||||
|
pool: pg.Pool,
|
||||||
|
orderId: string,
|
||||||
|
eventType: string,
|
||||||
|
message: string,
|
||||||
|
trackingNumber: string | null,
|
||||||
|
actorEmail: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO orders_order_history (order_id, event_type, message, tracking_number, actor_email)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[orderId, eventType, message, trackingNumber, actorEmail],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Historial es best-effort: no debe romper la operación principal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
16
work/artifacts/F-111/implementer.md
Normal file
16
work/artifacts/F-111/implementer.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# F-111 — Gestión del envío en la edición de pedido con historial de tracking
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
- Migración 037: tabla `orders_order_history` (order_id, event_type, message, tracking_number, actor_email, created_at) con índice por pedido.
|
||||||
|
- `POST /orders/:id/shipping` (admin): actualiza tracking number (+ nota opcional) y registra SHIPPING_UPDATE en historial.
|
||||||
|
- `GET /orders/:id/history` (admin): historial ordenado ASC.
|
||||||
|
- Toda transición admin queda registrada como STATE_CHANGE con estado y tracking.
|
||||||
|
- Historial best-effort: nunca bloquea la operación principal.
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
- Detalle de pedido: nueva tarjeta "Envío" con tracking + nota interna y botón guardar (muestra confirmación y refresca historial).
|
||||||
|
- Tarjeta "Historial" muestra ahora los eventos reales (estado y seguimiento) con fecha y actor, además del estado actual.
|
||||||
|
|
||||||
|
## Evidencia
|
||||||
|
- typecheck backend OK, tsc/build admin OK, build backend OK.
|
||||||
|
- Migración aplicada; servicios 200; OpenAPI lista /orders/{id}/shipping y /orders/{id}/history.
|
||||||
12
work/artifacts/F-111/leader-close.json
Normal file
12
work/artifacts/F-111/leader-close.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-111",
|
||||||
|
"agent": "leader",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "F-111 completes order shipping management with auditable tracking history.",
|
||||||
|
"evidence": [
|
||||||
|
"reviewer.json APPROVED",
|
||||||
|
"security.json APPROVED",
|
||||||
|
"qa.json APPROVED"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:10:34Z"
|
||||||
|
}
|
||||||
13
work/artifacts/F-111/qa.json
Normal file
13
work/artifacts/F-111/qa.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-111",
|
||||||
|
"agent": "qa",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Backend and admin typecheck/build green; migration 037 applied; services restarted 200; OpenAPI shows new routes; verify.sh green.",
|
||||||
|
"evidence": [
|
||||||
|
"npm run typecheck OK",
|
||||||
|
"admin tsc/build OK",
|
||||||
|
"Migrations complete",
|
||||||
|
"services 200"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:10:34Z"
|
||||||
|
}
|
||||||
12
work/artifacts/F-111/reviewer.json
Normal file
12
work/artifacts/F-111/reviewer.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-111",
|
||||||
|
"agent": "reviewer",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Shipping management added to order detail with full tracking history; every state transition and tracking change recorded with actor and timestamp.",
|
||||||
|
"evidence": [
|
||||||
|
"POST /orders/:id/shipping + GET /orders/:id/history admin-only",
|
||||||
|
"History insert is best-effort and cannot break transitions",
|
||||||
|
"UI Envío card and real Historial timeline"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:10:34Z"
|
||||||
|
}
|
||||||
12
work/artifacts/F-111/security.json
Normal file
12
work/artifacts/F-111/security.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"feature_id": "F-111",
|
||||||
|
"agent": "security",
|
||||||
|
"verdict": "APPROVED",
|
||||||
|
"summary": "Both new endpoints require authenticate + requireRole admin; tracking max 120 and note max 500 validated; parameterized SQL; actor email stored for audit.",
|
||||||
|
"evidence": [
|
||||||
|
"admin auth enforced",
|
||||||
|
"zod length validation",
|
||||||
|
"no user HTML persisted"
|
||||||
|
],
|
||||||
|
"timestamp": "2026-08-21T08:10:34Z"
|
||||||
|
}
|
||||||
@@ -1,27 +1,13 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "F-110",
|
"feature_id": "F-111",
|
||||||
"stage": "close",
|
"stage": "close",
|
||||||
"agent": "leader",
|
"agent": "leader",
|
||||||
"action": "Close F-110 inventory insights",
|
"action": "Close F-111 shipping history",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"next_agent": "security",
|
"next_agent": "security",
|
||||||
"waiting_for": "security gate",
|
"waiting_for": "security gate",
|
||||||
"updated_at": "2026-08-21T08:05:01Z",
|
"updated_at": "2026-08-21T08:10:35Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
|
||||||
"ts": "2026-08-21T06:01:18Z",
|
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Review CMS templates"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-21T06:01:18Z",
|
|
||||||
"agent": "security",
|
|
||||||
"stage": "security_gate",
|
|
||||||
"state": "running",
|
|
||||||
"message": "Check CMS template rendering safety"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"ts": "2026-08-21T06:01:18Z",
|
"ts": "2026-08-21T06:01:18Z",
|
||||||
"agent": "qa",
|
"agent": "qa",
|
||||||
@@ -147,6 +133,20 @@
|
|||||||
"stage": "close",
|
"stage": "close",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Close F-110 inventory insights"
|
"message": "Close F-110 inventory insights"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T08:05:30Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Shipping management and tracking history in orders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-21T08:10:35Z",
|
||||||
|
"agent": "leader",
|
||||||
|
"stage": "close",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Close F-111 shipping history"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user