feat(F-113): completed feature
This commit is contained in:
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { Order, OrderState, Product, OrderHistoryEvent } from '@/types';
|
||||
import { ordersApi, productsApi } from '@/lib/api-client';
|
||||
import { ordersApi, productsApi, settingsApi } from '@/lib/api-client';
|
||||
|
||||
const STATE_LABELS: Record<OrderState, string> = {
|
||||
PENDING: 'Pendiente',
|
||||
@@ -74,6 +74,8 @@ export default function OrderDetailPage() {
|
||||
const [showConfirm, setShowConfirm] = useState<OrderState | null>(null);
|
||||
const [confirmReason, setConfirmReason] = useState('');
|
||||
const [trackingInput, setTrackingInput] = useState('');
|
||||
const [courierInput, setCourierInput] = useState('');
|
||||
const [couriers, setCouriers] = useState<string[]>([]);
|
||||
const [notification, setNotification] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
// Edición de artículos
|
||||
@@ -90,6 +92,7 @@ export default function OrderDetailPage() {
|
||||
// Envío e historial (F-111)
|
||||
const [history, setHistory] = useState<OrderHistoryEvent[]>([]);
|
||||
const [shippingTracking, setShippingTracking] = useState('');
|
||||
const [shippingCourier, setShippingCourier] = useState('');
|
||||
const [shippingNote, setShippingNote] = useState('');
|
||||
const [savingShipping, setSavingShipping] = useState(false);
|
||||
const [shippingMsg, setShippingMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
@@ -110,6 +113,7 @@ export default function OrderDetailPage() {
|
||||
const data = await ordersApi.get(id);
|
||||
setOrder(data);
|
||||
setShippingTracking(data.trackingNumber ?? '');
|
||||
setShippingCourier(data.courier ?? '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
@@ -119,16 +123,30 @@ export default function OrderDetailPage() {
|
||||
|
||||
useEffect(() => { load(); loadHistory(); }, [load, loadHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi
|
||||
.get()
|
||||
.then((settings) => setCouriers(settings.couriers ?? []))
|
||||
.catch(() => setCouriers([]));
|
||||
}, []);
|
||||
|
||||
const handleTransition = async (nextState: OrderState) => {
|
||||
if (nextState === 'SHIPPED' && !trackingInput.trim()) return;
|
||||
if (nextState === 'SHIPPED' && (!trackingInput.trim() || !courierInput.trim())) return;
|
||||
setTransitioning(true);
|
||||
try {
|
||||
const updated = await ordersApi.transition(id, nextState, trackingInput);
|
||||
const updated = await ordersApi.transition(
|
||||
id,
|
||||
nextState,
|
||||
nextState === 'SHIPPED' ? trackingInput : undefined,
|
||||
nextState === 'SHIPPED' ? courierInput : undefined,
|
||||
);
|
||||
setOrder(updated);
|
||||
setShippingTracking(updated.trackingNumber ?? '');
|
||||
setShippingCourier(updated.courier ?? '');
|
||||
setShowConfirm(null);
|
||||
setConfirmReason('');
|
||||
setTrackingInput('');
|
||||
setCourierInput('');
|
||||
loadHistory();
|
||||
if (updated.notified) {
|
||||
setNotification({ ok: true, message: 'Cliente notificado por email.' });
|
||||
@@ -256,9 +274,10 @@ export default function OrderDetailPage() {
|
||||
setSavingShipping(true);
|
||||
setShippingMsg(null);
|
||||
try {
|
||||
const updated = await ordersApi.updateShipping(id, tracking, shippingNote);
|
||||
const updated = await ordersApi.updateShipping(id, tracking, shippingNote, shippingCourier);
|
||||
setOrder(updated);
|
||||
setShippingTracking(updated.trackingNumber ?? tracking);
|
||||
setShippingCourier(updated.courier ?? '');
|
||||
setShippingNote('');
|
||||
setShippingMsg({ ok: true, text: 'Envío actualizado y registrado en el historial' });
|
||||
loadHistory();
|
||||
@@ -342,7 +361,11 @@ export default function OrderDetailPage() {
|
||||
{allowed.map((next) => (
|
||||
<button
|
||||
key={next}
|
||||
onClick={() => { setShowConfirm(next); setNotification(null); }}
|
||||
onClick={() => {
|
||||
setShowConfirm(next);
|
||||
setNotification(null);
|
||||
if (next === 'SHIPPED') setCourierInput((prev) => prev || couriers[0] || '');
|
||||
}}
|
||||
disabled={transitioning}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
@@ -492,6 +515,22 @@ export default function OrderDetailPage() {
|
||||
{/* 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">
|
||||
Transportista
|
||||
</label>
|
||||
<select
|
||||
value={shippingCourier}
|
||||
onChange={(e) => setShippingCourier(e.target.value)}
|
||||
className="mb-2 w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
<option value="">Sin transportista</option>
|
||||
{couriers.map((courier) => (
|
||||
<option key={courier} value={courier}>{courier}</option>
|
||||
))}
|
||||
{shippingCourier && !couriers.includes(shippingCourier) && (
|
||||
<option value={shippingCourier}>{shippingCourier}</option>
|
||||
)}
|
||||
</select>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">
|
||||
Número de seguimiento
|
||||
</label>
|
||||
@@ -600,6 +639,24 @@ export default function OrderDetailPage() {
|
||||
</p>
|
||||
{showConfirm === 'SHIPPED' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Transportista <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={courierInput}
|
||||
onChange={(e) => setCourierInput(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white mb-3"
|
||||
>
|
||||
<option value="">Selecciona transportista</option>
|
||||
{couriers.map((courier) => (
|
||||
<option key={courier} value={courier}>{courier}</option>
|
||||
))}
|
||||
</select>
|
||||
{couriers.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mb-2">
|
||||
No hay transportistas configurados. Añádelos en Ajustes → Transportistas.
|
||||
</p>
|
||||
)}
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Número de seguimiento <span className="text-red-500">*</span>
|
||||
</label>
|
||||
@@ -624,14 +681,14 @@ export default function OrderDetailPage() {
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setShowConfirm(null); setConfirmReason(''); setTrackingInput(''); }}
|
||||
onClick={() => { setShowConfirm(null); setConfirmReason(''); setTrackingInput(''); setCourierInput(''); }}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTransition(showConfirm)}
|
||||
disabled={transitioning || (showConfirm === 'SHIPPED' && !trackingInput.trim())}
|
||||
disabled={transitioning || (showConfirm === 'SHIPPED' && (!trackingInput.trim() || !courierInput.trim()))}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{transitioning ? 'Guardando...' : 'Confirmar'}
|
||||
|
||||
@@ -10,6 +10,7 @@ const TABS = [
|
||||
{ id: 'footer', label: 'Footer', icon: '📄' },
|
||||
{ id: 'ai', label: 'IA para SEO', icon: '✨' },
|
||||
{ id: 'smtp', label: 'SMTP / Email', icon: '✉️' },
|
||||
{ id: 'couriers', label: 'Transportistas', icon: '🚚' },
|
||||
] as const;
|
||||
type TabId = (typeof TABS)[number]['id'];
|
||||
|
||||
@@ -21,13 +22,21 @@ export default function SettingsPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [tab, setTab] = useState<TabId>('general');
|
||||
const [couriersText, setCouriersText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.get().then(d => {
|
||||
setData(d); setForm(d);
|
||||
setCouriersText((d.couriers ?? []).join('\n'));
|
||||
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCouriersChange = (text: string) => {
|
||||
setCouriersText(text);
|
||||
const list = text.split('\n').map(c => c.trim()).filter(Boolean).slice(0, 30);
|
||||
setForm(f => f ? { ...f, couriers: list } : f);
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
@@ -181,6 +190,25 @@ export default function SettingsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'couriers' && (
|
||||
<>
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Transportistas</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Lista editable de transportistas. Se usa al marcar un pedido como enviado y aparece en el email al cliente. Uno por línea.</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
<textarea
|
||||
value={couriersText}
|
||||
onChange={e => handleCouriersChange(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={'Correos\nSEUR\nMRW'}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">Máximo 30 transportistas, cada nombre de hasta 60 caracteres.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'footer' && (
|
||||
<>
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
|
||||
@@ -106,17 +106,25 @@ export const ordersApi = {
|
||||
).then((data) => (Array.isArray(data) ? { items: data, total: data.length } : data));
|
||||
},
|
||||
get: (id: string) => api.get<import('@/types').Order>(`/api/orders/${id}`),
|
||||
transition: (id: string, state: string, trackingNumber?: string) =>
|
||||
transition: (id: string, state: string, trackingNumber?: string, courier?: string) =>
|
||||
api.post<import('@/types').Order & { notified?: boolean; notificationError?: string }>(
|
||||
`/api/orders/${id}/transitions`,
|
||||
trackingNumber?.trim() ? { state, trackingNumber: trackingNumber.trim() } : { state },
|
||||
`/api/orders/${id}/transitions/admin`,
|
||||
{
|
||||
state,
|
||||
...(trackingNumber?.trim() ? { trackingNumber: trackingNumber.trim() } : {}),
|
||||
...(courier?.trim() ? { courier: courier.trim() } : {}),
|
||||
},
|
||||
),
|
||||
editItems: (id: string, items: { variantId: string; quantity: number }[]) =>
|
||||
api.put<import('@/types').Order>(`/api/orders/${id}/items`, { items }),
|
||||
updateShipping: (id: string, trackingNumber: string, note?: string) =>
|
||||
updateShipping: (id: string, trackingNumber: string, note?: string, courier?: string) =>
|
||||
api.post<import('@/types').Order>(
|
||||
`/api/orders/${id}/shipping`,
|
||||
note?.trim() ? { trackingNumber, note } : { trackingNumber },
|
||||
{
|
||||
trackingNumber,
|
||||
...(courier?.trim() ? { courier: courier.trim() } : {}),
|
||||
...(note?.trim() ? { note } : {}),
|
||||
},
|
||||
),
|
||||
history: (id: string) =>
|
||||
api.get<{ items: import('@/types').OrderHistoryEvent[] }>(`/api/orders/${id}/history`),
|
||||
@@ -362,6 +370,7 @@ export interface StoreSettings {
|
||||
smtpPass: string;
|
||||
smtpPassConfigured?: boolean;
|
||||
smtpFrom: string;
|
||||
couriers?: string[];
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface Order {
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
trackingNumber?: string | null;
|
||||
courier?: string | null;
|
||||
idempotencyKey: string | null;
|
||||
items: OrderItem[];
|
||||
createdAt: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
12
project/migrations/039_order_courier.js
Normal file
12
project/migrations/039_order_courier.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Courier used to ship an order. Populated when an admin marks the order as
|
||||
* SHIPPED; shown in the customer "Enviado" email alongside the tracking number.
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE orders_orders ADD COLUMN IF NOT EXISTS courier varchar(120) NULL`);
|
||||
};
|
||||
|
||||
export const down = (pgm) => {
|
||||
pgm.sql(`ALTER TABLE orders_orders DROP COLUMN IF EXISTS courier`);
|
||||
};
|
||||
@@ -227,6 +227,7 @@ export async function registerOrdersRoutes(
|
||||
],
|
||||
},
|
||||
trackingNumber: { type: 'string', maxLength: 120, description: 'Número de seguimiento del envío (obligatorio al marcar como Enviado)' },
|
||||
courier: { type: 'string', maxLength: 120, description: 'Transportista del envío, de la lista editable en Ajustes (obligatorio al marcar como Enviado)' },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
@@ -238,16 +239,22 @@ export async function registerOrdersRoutes(
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
const { state, trackingNumber } = parseJson(
|
||||
transitionSchema.extend({ trackingNumber: z.string().min(1).max(120).optional() }),
|
||||
const { state, trackingNumber, courier } = parseJson(
|
||||
transitionSchema.extend({
|
||||
trackingNumber: z.string().min(1).max(120).optional(),
|
||||
courier: z.string().min(1).max(120).optional(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
if (state === 'SHIPPED' && !trackingNumber?.trim()) {
|
||||
throw new AppError(422, 'TRACKING_NUMBER_REQUIRED', 'Indica el número de seguimiento para marcar el pedido como enviado');
|
||||
}
|
||||
if (state === 'SHIPPED' && !courier?.trim()) {
|
||||
throw new AppError(422, 'COURIER_REQUIRED', 'Indica el transportista para marcar el pedido como enviado');
|
||||
}
|
||||
let order;
|
||||
try {
|
||||
order = await service.transitionAdmin(id, state as OrderState, trackingNumber?.trim() || undefined);
|
||||
order = await service.transitionAdmin(id, state as OrderState, trackingNumber?.trim() || undefined, courier?.trim() || undefined);
|
||||
} catch (error) {
|
||||
throw mapOrderError(error);
|
||||
}
|
||||
@@ -266,6 +273,7 @@ export async function registerOrdersRoutes(
|
||||
orderId: order.id,
|
||||
state: order.state,
|
||||
trackingNumber: order.trackingNumber,
|
||||
courier: order.courier,
|
||||
});
|
||||
notified = true;
|
||||
} else {
|
||||
@@ -279,7 +287,7 @@ export async function registerOrdersRoutes(
|
||||
deps.pool,
|
||||
order.id,
|
||||
'STATE_CHANGE',
|
||||
`Estado cambiado a ${ORDER_STATE_LABELS[order.state] ?? order.state}${order.trackingNumber ? ` · Tracking: ${order.trackingNumber}` : ''}`,
|
||||
`Estado cambiado a ${ORDER_STATE_LABELS[order.state] ?? order.state}${order.courier ? ` · Transportista: ${order.courier}` : ''}${order.trackingNumber ? ` · Tracking: ${order.trackingNumber}` : ''}`,
|
||||
order.trackingNumber ?? null,
|
||||
user.email,
|
||||
);
|
||||
@@ -304,9 +312,10 @@ export async function registerOrdersRoutes(
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
const { trackingNumber, note } = parseJson(
|
||||
const { trackingNumber, courier, note } = parseJson(
|
||||
z.object({
|
||||
trackingNumber: z.string().trim().min(1).max(120),
|
||||
courier: z.string().trim().min(1).max(120).optional(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
}),
|
||||
request.body,
|
||||
@@ -314,13 +323,13 @@ export async function registerOrdersRoutes(
|
||||
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],
|
||||
`UPDATE orders_orders SET tracking_number = $2, courier = COALESCE($3, courier), updated_at = now() WHERE id = $1`,
|
||||
[id, trackingNumber, courier ?? null],
|
||||
);
|
||||
const message = `Seguimiento actualizado: ${trackingNumber}${note ? ` · Nota: ${note}` : ''}`;
|
||||
const message = `Seguimiento actualizado: ${trackingNumber}${courier ? ` · Transportista: ${courier}` : ''}${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 }));
|
||||
return reply.send(serializeOrder(updated ?? { ...existing, trackingNumber, courier: courier ?? existing.courier }));
|
||||
});
|
||||
|
||||
const historySchema: FastifySchema = {
|
||||
@@ -490,6 +499,7 @@ function serializeOrder(order: {
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
trackingNumber?: string | null;
|
||||
courier?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
idempotencyKey: string | null;
|
||||
@@ -518,6 +528,7 @@ function serializeOrder(order: {
|
||||
taxCents: order.taxCents,
|
||||
totalCents: order.totalCents,
|
||||
trackingNumber: order.trackingNumber ?? null,
|
||||
courier: order.courier ?? null,
|
||||
idempotencyKey: order.idempotencyKey,
|
||||
items: order.items.map((item) => ({
|
||||
id: item.id,
|
||||
|
||||
@@ -74,12 +74,12 @@ export class OrderService implements OrderServicePort {
|
||||
return this.repo.findById(id);
|
||||
}
|
||||
|
||||
async transitionAdmin(id: string, next: OrderState, trackingNumber?: string): Promise<OrderView> {
|
||||
async transitionAdmin(id: string, next: OrderState, trackingNumber?: string, courier?: string): Promise<OrderView> {
|
||||
const existing = await this.repo.findById(id);
|
||||
if (!existing) throw new OrderNotFoundError();
|
||||
if (!isTransitionAllowed(existing.state, next))
|
||||
throw new OrderStateTransitionError(existing.state, next);
|
||||
const updated = await this.repo.updateState(id, next, trackingNumber);
|
||||
const updated = await this.repo.updateState(id, next, trackingNumber, courier);
|
||||
if (!updated) throw new OrderNotFoundError();
|
||||
if (next === 'PAID')
|
||||
await this.events.emit({ type: 'OrderPaid', orderId: id, userId: existing.userId });
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface Order {
|
||||
taxCents: number;
|
||||
totalCents: number;
|
||||
trackingNumber?: string | null;
|
||||
courier?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface OrderRepository {
|
||||
}): Promise<{ items: OrderView[]; total: number }>;
|
||||
findById(id: string): Promise<OrderView | undefined>;
|
||||
findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined>;
|
||||
updateState(id: string, state: OrderState, trackingNumber?: string): Promise<OrderView | undefined>;
|
||||
updateState(id: string, state: OrderState, trackingNumber?: string, courier?: string): Promise<OrderView | undefined>;
|
||||
/** Reemplaza los artículos y totales del pedido (edición admin). */
|
||||
replaceItems?(
|
||||
id: string,
|
||||
@@ -44,7 +44,7 @@ export interface OrderServicePort {
|
||||
offset: number;
|
||||
}): Promise<{ items: OrderView[]; total: number }>;
|
||||
transition(id: string, next: OrderState, userId: string): Promise<OrderView>;
|
||||
transitionAdmin(id: string, next: OrderState, trackingNumber?: string): Promise<OrderView>;
|
||||
transitionAdmin(id: string, next: OrderState, trackingNumber?: string, courier?: string): Promise<OrderView>;
|
||||
getOrder(id: string, userId: string): Promise<OrderView | undefined>;
|
||||
getOrderAdmin(id: string): Promise<OrderView | undefined>;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface OrderStatusNotificationInput {
|
||||
orderId: string;
|
||||
state: string;
|
||||
trackingNumber?: string | null;
|
||||
courier?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,20 +66,36 @@ export async function sendOrderStatusEmail(
|
||||
auth: { user, pass: password },
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to: input.to,
|
||||
...buildOrderStatusEmail(input),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the status-change email body. Pure function so the courier/tracking
|
||||
* content can be unit tested without SMTP.
|
||||
*/
|
||||
export function buildOrderStatusEmail(input: OrderStatusNotificationInput): {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
} {
|
||||
const shortId = input.orderId.slice(0, 8);
|
||||
const stateLabel = ORDER_STATE_LABELS[input.state] ?? input.state;
|
||||
const courierLine = input.courier?.trim() ? `Transportista: ${input.courier.trim()}` : '';
|
||||
const trackingLine = input.trackingNumber
|
||||
? `Número de seguimiento: ${input.trackingNumber}`
|
||||
: '';
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to: input.to,
|
||||
return {
|
||||
subject: `Tu pedido #${shortId} ahora está: ${stateLabel} — Mercado de Vida`,
|
||||
text: [
|
||||
`Hola,`,
|
||||
'',
|
||||
`Tu pedido #${shortId} ha cambiado de estado: ${stateLabel}.`,
|
||||
courierLine,
|
||||
trackingLine,
|
||||
'',
|
||||
'Puedes consultar el detalle desde tu cuenta en mercadodevida.',
|
||||
@@ -90,11 +107,12 @@ export async function sendOrderStatusEmail(
|
||||
html: [
|
||||
`<p>Hola,</p>`,
|
||||
`<p>Tu pedido <strong>#${escapeHtml(shortId)}</strong> ha cambiado de estado: <strong>${escapeHtml(stateLabel)}</strong>.</p>`,
|
||||
courierLine ? `<p>Transportista: <strong>${escapeHtml(input.courier ?? '')}</strong></p>` : '',
|
||||
trackingLine ? `<p>Número de seguimiento: <strong>${escapeHtml(input.trackingNumber ?? '')}</strong></p>` : '',
|
||||
`<p>Puedes consultar el detalle desde tu cuenta en mercadodevida.</p>`,
|
||||
`<p>Gracias por tu compra.</p>`,
|
||||
]
|
||||
.filter((line) => line !== '')
|
||||
.join(''),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface OrderRow {
|
||||
tax_cents: number;
|
||||
total_cents: number;
|
||||
tracking_number: string | null;
|
||||
courier: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -178,11 +179,11 @@ export class PgOrderRepository implements OrderRepository {
|
||||
return { ...toOrder(order), items: items.rows.map(toItem) };
|
||||
}
|
||||
|
||||
async updateState(id: string, state: OrderState, trackingNumber?: string): Promise<OrderView | undefined> {
|
||||
async updateState(id: string, state: OrderState, trackingNumber?: string, courier?: string): Promise<OrderView | undefined> {
|
||||
const result = await this.pool.query<OrderRow>(
|
||||
`UPDATE orders_orders SET state = $2, tracking_number = COALESCE($3, tracking_number), updated_at = now()
|
||||
`UPDATE orders_orders SET state = $2, tracking_number = COALESCE($3, tracking_number), courier = COALESCE($4, courier), updated_at = now()
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[id, state, trackingNumber ?? null],
|
||||
[id, state, trackingNumber ?? null, courier ?? null],
|
||||
);
|
||||
const order = result.rows[0];
|
||||
if (!order) return undefined;
|
||||
@@ -249,6 +250,7 @@ function toOrder(row: OrderRow): Order {
|
||||
taxCents: row.tax_cents,
|
||||
totalCents: row.total_cents,
|
||||
trackingNumber: row.tracking_number,
|
||||
courier: row.courier,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
@@ -90,4 +90,21 @@ describe('OrderService', () => {
|
||||
OrderStateTransitionError,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes tracking number and courier to the repository on admin transition', async () => {
|
||||
const calls: Array<{ state: OrderState; trackingNumber?: string; courier?: string }> = [];
|
||||
const repoCapturing: OrderRepository = repo({
|
||||
findById: async () => ({ ...ORDER_VIEW, state: 'PROCESSING' }),
|
||||
updateState: async (_id, state, trackingNumber, courier) => {
|
||||
calls.push({ state, trackingNumber, courier });
|
||||
return { ...ORDER_VIEW, state, trackingNumber: trackingNumber ?? null, courier: courier ?? null };
|
||||
},
|
||||
});
|
||||
const service = new OrderService(repoCapturing, new CapturePublisher());
|
||||
const updated = await service.transitionAdmin('order-1', 'SHIPPED', 'TRK-1', 'SEUR');
|
||||
expect(calls).toEqual([{ state: 'SHIPPED', trackingNumber: 'TRK-1', courier: 'SEUR' }]);
|
||||
expect(updated.state).toBe('SHIPPED');
|
||||
expect(updated.trackingNumber).toBe('TRK-1');
|
||||
expect(updated.courier).toBe('SEUR');
|
||||
});
|
||||
});
|
||||
|
||||
55
project/src/modules/orders/tests/order-status-mailer.test.ts
Normal file
55
project/src/modules/orders/tests/order-status-mailer.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildOrderStatusEmail, ORDER_STATE_LABELS } from '../infrastructure/order-status-mailer.js';
|
||||
|
||||
const BASE = {
|
||||
to: 'customer@example.com',
|
||||
orderId: 'abcdef12-3456-7890-abcd-ef1234567890',
|
||||
};
|
||||
|
||||
describe('buildOrderStatusEmail', () => {
|
||||
it('includes tracking number and courier in the SHIPPED email', () => {
|
||||
const email = buildOrderStatusEmail({
|
||||
...BASE,
|
||||
state: 'SHIPPED',
|
||||
trackingNumber: 'TRK-998877',
|
||||
courier: 'SEUR',
|
||||
});
|
||||
expect(email.subject).toContain(ORDER_STATE_LABELS.SHIPPED);
|
||||
expect(email.text).toContain('Número de seguimiento: TRK-998877');
|
||||
expect(email.text).toContain('Transportista: SEUR');
|
||||
expect(email.html).toContain('Número de seguimiento: <strong>TRK-998877</strong>');
|
||||
expect(email.html).toContain('Transportista: <strong>SEUR</strong>');
|
||||
});
|
||||
|
||||
it('does not mention tracking or courier in the PROCESSING email', () => {
|
||||
const email = buildOrderStatusEmail({ ...BASE, state: 'PROCESSING' });
|
||||
expect(email.subject).toContain(ORDER_STATE_LABELS.PROCESSING);
|
||||
expect(email.text).not.toContain('Número de seguimiento');
|
||||
expect(email.text).not.toContain('Transportista');
|
||||
expect(email.html).not.toContain('Transportista');
|
||||
});
|
||||
|
||||
it('omits the courier line when the courier is blank', () => {
|
||||
const email = buildOrderStatusEmail({
|
||||
...BASE,
|
||||
state: 'SHIPPED',
|
||||
trackingNumber: 'TRK-1',
|
||||
courier: ' ',
|
||||
});
|
||||
expect(email.text).not.toContain('Transportista');
|
||||
expect(email.html).not.toContain('Transportista');
|
||||
});
|
||||
|
||||
it('escapes HTML in courier and tracking values', () => {
|
||||
const email = buildOrderStatusEmail({
|
||||
...BASE,
|
||||
state: 'SHIPPED',
|
||||
trackingNumber: '<script>alert(1)</script>',
|
||||
courier: 'ACME & Co <b>',
|
||||
});
|
||||
expect(email.html).not.toContain('<script>');
|
||||
expect(email.html).not.toContain('<b>');
|
||||
expect(email.html).toContain('ACME & Co <b>');
|
||||
expect(email.html).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
@@ -37,8 +37,28 @@ const updateSettingsSchema = z.object({
|
||||
smtpUser: z.string().max(255).optional(),
|
||||
smtpPass: z.string().max(500).optional(),
|
||||
smtpFrom: z.string().email().optional().or(z.literal('')),
|
||||
couriers: z.array(z.string().trim().min(1).max(60)).max(30).optional(),
|
||||
});
|
||||
|
||||
/** Lista de transportistas por defecto hasta que el admin la edite. */
|
||||
export const DEFAULT_COURIERS = ['Correos', 'SEUR', 'MRW', 'GLS', 'DHL', 'UPS'];
|
||||
|
||||
export function parseCouriers(raw: string | undefined | null): string[] {
|
||||
if (!raw?.trim()) return [...DEFAULT_COURIERS];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [...DEFAULT_COURIERS];
|
||||
const list = parsed
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 30);
|
||||
return list.length > 0 ? list : [...DEFAULT_COURIERS];
|
||||
} catch {
|
||||
return [...DEFAULT_COURIERS];
|
||||
}
|
||||
}
|
||||
|
||||
const SETTING_KEYS: Record<string, string> = {
|
||||
storeName: 'store_name',
|
||||
storeTagline: 'store_tagline',
|
||||
@@ -113,6 +133,7 @@ export async function registerStoreSettingsRoutes(
|
||||
smtpPass: '',
|
||||
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
||||
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
||||
couriers: parseCouriers(map['shipping_couriers']),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +179,13 @@ export async function registerStoreSettingsRoutes(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.couriers !== undefined) {
|
||||
await deps.pool.query(
|
||||
`INSERT INTO store_settings (key, value, updated_by) VALUES ('shipping_couriers', $1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW(), updated_by = $2`,
|
||||
[JSON.stringify(input.couriers), updatedBy],
|
||||
);
|
||||
}
|
||||
// Return updated settings
|
||||
const result = await deps.pool.query<{ key: string; value: string }>(
|
||||
`SELECT key, value FROM store_settings`,
|
||||
@@ -193,6 +221,7 @@ export async function registerStoreSettingsRoutes(
|
||||
smtpPass: '',
|
||||
smtpPassConfigured: Boolean(map['smtp_pass'] || process.env.SMTP_PASS),
|
||||
smtpFrom: map['smtp_from'] ?? process.env.SMTP_FROM ?? '',
|
||||
couriers: parseCouriers(map['shipping_couriers']),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_COURIERS, parseCouriers } from '../api/settings.routes.js';
|
||||
|
||||
describe('parseCouriers', () => {
|
||||
it('returns the default list when the setting is missing or blank', () => {
|
||||
expect(parseCouriers(undefined)).toEqual(DEFAULT_COURIERS);
|
||||
expect(parseCouriers('')).toEqual(DEFAULT_COURIERS);
|
||||
expect(parseCouriers(' ')).toEqual(DEFAULT_COURIERS);
|
||||
});
|
||||
|
||||
it('parses a stored JSON array and trims entries', () => {
|
||||
expect(parseCouriers(JSON.stringify([' Correos ', 'SEUR']))).toEqual(['Correos', 'SEUR']);
|
||||
});
|
||||
|
||||
it('drops non-string and empty entries', () => {
|
||||
expect(parseCouriers(JSON.stringify(['MRW', 42, '', null, 'GLS']))).toEqual(['MRW', 'GLS']);
|
||||
});
|
||||
|
||||
it('falls back to defaults on invalid JSON or empty results', () => {
|
||||
expect(parseCouriers('not json')).toEqual(DEFAULT_COURIERS);
|
||||
expect(parseCouriers('{"a":1}')).toEqual(DEFAULT_COURIERS);
|
||||
expect(parseCouriers(JSON.stringify(['', ' ']))).toEqual(DEFAULT_COURIERS);
|
||||
});
|
||||
|
||||
it('caps the list at 30 couriers', () => {
|
||||
const many = Array.from({ length: 40 }, (_, index) => `Courier ${index}`);
|
||||
expect(parseCouriers(JSON.stringify(many))).toHaveLength(30);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user