feat(F-113): completed feature

This commit is contained in:
chattie
2026-08-21 12:27:13 +02:00
parent 027cacd871
commit c05c0b0582
25 changed files with 581 additions and 74 deletions

View File

@@ -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,

View File

@@ -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 });

View File

@@ -38,6 +38,7 @@ export interface Order {
taxCents: number;
totalCents: number;
trackingNumber?: string | null;
courier?: string | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -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>;
}

View File

@@ -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(''),
});
};
}

View File

@@ -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,
};

View File

@@ -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');
});
});

View 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 &amp; Co &lt;b&gt;');
expect(email.html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});
});