Files
mercadodevida/project/src/modules/orders/api/orders.routes.ts

669 lines
24 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import type { FastifySchema } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { errorSchema } from '../../../shared/swagger.js';
import { OrderService } from '../application/order-service.js';
import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.js';
import type { OrderState } from '../domain/order.js';
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
import { sendOrderStatusEmail, ORDER_STATE_LABELS } from '../infrastructure/order-status-mailer.js';
export interface OrdersRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const orderItemSchema = z.object({
productId: z.uuid(),
variantId: z.uuid(),
sku: z.string().min(1).max(120),
ean: z.string().min(1).max(40).nullable().optional(),
name: z.string().min(1).max(200),
unitPriceCents: z.number().int().min(0),
discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0),
quantity: z.number().int().positive(),
});
const createOrderSchema = z
.object({
idempotencyKey: z.string().min(1).max(120).optional().nullable(),
items: z.array(orderItemSchema).min(1),
totals: z.object({
subtotalCents: z.number().int().min(0),
discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0),
totalCents: z.number().int().min(0),
}),
})
.strip();
const transitionSchema = z.object({
state: z.enum([
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED',
'REFUNDED',
'PARTIALLY_REFUNDED',
]),
});
const orderIdParamSchema = z.object({ id: z.uuid() });
export async function registerOrdersRoutes(
app: FastifyInstance,
deps: OrdersRoutesDeps,
): Promise<void> {
const service = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
const createOrderSwaggerSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Create order',
description: 'Crea un pedido. Requiere autenticación.',
body: { type: 'object' },
response: { 201: { type: 'object' }, 401: errorSchema },
};
app.post('/orders', { schema: createOrderSwaggerSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(createOrderSchema, request.body);
try {
const order = await service.create({
userId: user.id,
idempotencyKey: input.idempotencyKey ?? null,
items: input.items.map((item) => ({ ...item, ean: item.ean ?? null })),
totals: input.totals,
});
return reply.code(201).send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
const transitionOrderSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Transition order state (customer)',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: {
type: 'object',
required: ['state'],
properties: {
state: {
type: 'string',
enum: [
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED',
'REFUNDED',
'PARTIALLY_REFUNDED',
],
},
},
},
response: { 401: errorSchema, 404: errorSchema },
};
app.post('/orders/:id/transitions', { schema: transitionOrderSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(orderIdParamSchema, request.params);
const { state } = parseJson(transitionSchema, request.body);
try {
const order = await service.transition(id, state as OrderState, user.id);
return reply.send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
const getOrderSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Get order',
description: 'Obtiene un pedido. El cliente solo ve los suyos; admin ve cualquiera.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 404: errorSchema },
};
app.get('/orders/:id', { schema: getOrderSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(orderIdParamSchema, request.params);
const order = await service.getOrder(id, user.id);
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
return reply.send(serializeOrder(order));
});
// Admin-only routes
const listOrdersSchema: FastifySchema = {
tags: ['Orders'],
summary: 'List orders (admin)',
querystring: {
type: 'object',
properties: {
status: { type: 'string', description: 'Filtro por estado del pedido' },
q: { type: 'string', maxLength: 120, description: 'Búsqueda por ID o email del cliente' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
offset: { type: 'integer', minimum: 0, default: 0 },
},
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/orders', { schema: listOrdersSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const query = request.query as { status?: string; q?: string; limit?: string; offset?: string };
const limit = Math.min(Math.max(Number(query.limit ?? 20) || 20, 1), 100);
const offset = Math.max(Number(query.offset ?? 0) || 0, 0);
const { items, total } = await service.searchOrders({
state: query.status?.trim() || undefined,
q: query.q?.trim() || undefined,
limit,
offset,
});
return reply.send({ items: items.map(serializeOrder), total });
});
const staleNotificationsSchema: FastifySchema = {
tags: ['Orders'],
summary: 'List stale order notifications (admin)',
querystring: {
type: 'object',
properties: {
hours: { type: 'integer', minimum: 1, maximum: 168, default: 24 },
limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 },
},
},
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/orders/notifications/stale', { schema: staleNotificationsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const query = request.query as { hours?: string; limit?: string };
const hours = Math.min(Math.max(Number(query.hours ?? 24) || 24, 1), 168);
const limit = Math.min(Math.max(Number(query.limit ?? 20) || 20, 1), 50);
const result = await deps.pool.query<{
id: string;
state: 'PENDING' | 'SHIPPED';
total_cents: number;
customer_email: string | null;
state_changed_at: Date;
age_hours: number;
total_count: number;
}>(
`SELECT o.id, o.state, o.total_cents, u.email AS customer_email,
o.state_changed_at,
FLOOR(EXTRACT(EPOCH FROM (now() - o.state_changed_at)) / 3600)::int AS age_hours,
COUNT(*) OVER()::int AS total_count
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
WHERE o.state IN ('PENDING', 'SHIPPED')
AND o.state_changed_at <= now() - ($1::int * interval '1 hour')
ORDER BY o.state_changed_at ASC
LIMIT $2`,
[hours, limit],
);
return reply.send({
total: result.rows[0]?.total_count ?? 0,
thresholdHours: hours,
items: result.rows.map((row) => ({
id: row.id,
state: row.state,
totalCents: row.total_cents,
customerEmail: row.customer_email,
stateChangedAt: row.state_changed_at.toISOString(),
ageHours: row.age_hours,
})),
});
});
const getOrderAdminSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Get order (admin)',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
};
app.get('/orders/:id/admin', { schema: getOrderAdminSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const order = await service.getOrderAdmin(id);
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
return reply.send(serializeOrder(order));
});
const adminTransitionSchema: FastifySchema = {
tags: ['Orders'],
summary: 'Force order transition (admin)',
description: 'Fuerza una transición de estado de pedido.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: {
type: 'object',
required: ['state'],
properties: {
state: {
type: 'string',
enum: [
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED',
'REFUNDED',
'PARTIALLY_REFUNDED',
],
},
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 },
};
app.post(
'/orders/:id/transitions/admin',
{ schema: adminTransitionSchema },
async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
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, courier?.trim() || undefined);
} catch (error) {
throw mapOrderError(error);
}
// Notificar al cliente el cambio de estado (sin bloquear la transición).
let notified = false;
let notificationError = '';
try {
// F-153: email is now associated on the order view via a repository join.
const to = order.email;
if (to) {
await sendOrderStatusEmail(deps.pool, {
to,
orderId: order.id,
state: order.state,
trackingNumber: order.trackingNumber,
courier: order.courier,
});
notified = true;
} else {
notificationError = 'El cliente no tiene email asociado';
}
} catch (error) {
notificationError = error instanceof Error ? error.message : 'No se pudo enviar el email';
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.courier ? ` · Transportista: ${order.courier}` : ''}${order.trackingNumber ? ` · Tracking: ${order.trackingNumber}` : ''}`,
order.trackingNumber ?? null,
user.email,
);
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, 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,
);
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, courier = COALESCE($3, courier), updated_at = now() WHERE id = $1`,
[id, trackingNumber, courier ?? null],
);
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, courier: courier ?? existing.courier }));
});
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 = {
tags: ['Orders'],
summary: 'Edit order items (admin)',
description:
'Reemplaza los artículos del pedido (agregar/quitar productos y ajustar cantidades). Los totales se recalculan con los precios e IVA actuales.',
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string', format: 'uuid' } },
},
body: { type: 'object' },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema, 422: errorSchema },
};
app.put('/orders/:id/items', { schema: editItemsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const body = parseJson(
z.object({
items: z
.array(
z.object({
variantId: z.uuid(),
quantity: z.number().int().positive().max(999),
}),
)
.min(1),
}),
request.body,
);
const existing = await service.getOrderAdmin(id);
if (!existing) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
// Consolidar cantidades por variante.
const byVariant = new Map<string, number>();
for (const item of body.items) {
byVariant.set(item.variantId, (byVariant.get(item.variantId) ?? 0) + item.quantity);
}
const variantIds = [...byVariant.keys()];
// Resolver datos de catálogo y precio vigente de cada variante.
const variantResult = await deps.pool.query<{
variant_id: string;
sku: string;
ean: string | null;
product_id: string;
product_name: string;
net_unit_amount_cents: number | null;
vat_rate: string | null;
}>(
`SELECT v.id AS variant_id, v.sku, v.ean, v.product_id, p.name AS product_name,
pr.net_unit_amount_cents, pr.vat_rate
FROM catalog_product_variants v
JOIN catalog_products p ON p.id = v.product_id
LEFT JOIN pricing_variant_prices pr ON pr.variant_id = v.id
WHERE v.id = ANY($1::uuid[])`,
[variantIds],
);
const found = new Map(variantResult.rows.map((row) => [row.variant_id, row]));
for (const variantId of variantIds) {
if (!found.has(variantId)) {
throw new AppError(422, 'VARIANT_NOT_FOUND', `La variante ${variantId.slice(0, 8)} no existe en el catálogo`);
}
const row = found.get(variantId);
if (row?.net_unit_amount_cents == null) {
throw new AppError(422, 'PRICE_MISSING', `Falta el precio de la variante ${row?.sku ?? variantId.slice(0, 8)}`);
}
}
const VAT_POINTS: Record<string, number> = { general: 2100, reduced: 1000, 'super-reduced': 400 };
const itemInputs = variantIds.map((variantId) => {
const row = found.get(variantId)!;
const quantity = byVariant.get(variantId)!;
const unitPriceCents = row.net_unit_amount_cents!;
const points = VAT_POINTS[row.vat_rate ?? 'general'] ?? 2100;
const taxCents = Math.round((unitPriceCents * quantity * points) / 10_000);
return {
productId: row.product_id,
variantId,
sku: row.sku,
ean: row.ean,
name: row.product_name,
unitPriceCents,
discountCents: 0,
taxCents,
quantity,
};
});
const netSubtotalCents = itemInputs.reduce((acc, item) => acc + item.unitPriceCents * item.quantity, 0);
const taxCents = itemInputs.reduce((acc, item) => acc + item.taxCents, 0);
const subtotalCents = netSubtotalCents + taxCents;
const discountCents = Math.min(existing.discountCents, subtotalCents);
// El coste de envío original se conserva: total - (subtotal - descuento).
const shippingCents = Math.max(0, existing.totalCents - Math.max(0, existing.subtotalCents - existing.discountCents));
const totalCents = Math.max(0, subtotalCents - discountCents) + shippingCents;
try {
const updated = await service.editItems(id, itemInputs, {
subtotalCents,
discountCents,
taxCents,
totalCents,
});
return reply.send(serializeOrder(updated));
} catch (error) {
throw mapOrderError(error);
}
});
// Awaiting payment orders for admin notifications
app.get('/orders/notifications/awaiting-payment', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const result = await deps.pool.query<{
id: string;
total_cents: number;
customer_email: string | null;
created_at: Date;
total_count: number;
}>(
`SELECT o.id, o.total_cents, u.email AS customer_email, o.created_at,
COUNT(*) OVER()::int AS total_count
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
WHERE o.payment_status = 'AWAITING'
AND o.state = 'PENDING'
AND o.deleted_at IS NULL
ORDER BY o.created_at ASC
LIMIT 20`,
);
return reply.send({
total: result.rows[0]?.total_count ?? 0,
items: result.rows.map((row) => ({
id: row.id,
totalCents: row.total_cents,
customerEmail: row.customer_email,
createdAt: row.created_at.toISOString(),
})),
});
});
}
function mapOrderError(error: unknown): Error {
if (error instanceof OrderStateTransitionError)
return new AppError(409, 'ORDER_STATE_TRANSITION_INVALID', error.message);
if (error instanceof OrderNotFoundError)
return new AppError(404, 'ORDER_NOT_FOUND', error.message);
return error instanceof Error ? error : new Error('Unknown order error');
}
function serializeOrder(order: {
id: string;
state: string;
currency: string;
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
trackingNumber?: string | null;
courier?: string | null;
customerName?: string | null;
customerPhone?: string | null;
createdAt: Date;
updatedAt: Date;
idempotencyKey: string | null;
userId?: string;
email?: string | null;
phone?: string | null;
items: Array<{
id: string;
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
createdAt: Date;
}>;
shippingAddress?: { recipientName: string; street: string; city: string; postalCode: string; country: string } | null;
billingAddress?: { recipientName: string; street: string; city: string; postalCode: string; country: string } | null;
payment?: { provider: string; status: string; amountCents: number; last4: string | null; createdAt: Date } | null;
}) {
return {
id: order.id,
userId: order.userId,
email: order.email ?? null,
phone: order.phone ?? null,
customerName: order.customerName ?? null,
customerPhone: order.customerPhone ?? null,
state: order.state,
currency: order.currency,
subtotalCents: order.subtotalCents,
discountCents: order.discountCents,
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,
productId: item.productId,
variantId: item.variantId,
sku: item.sku,
ean: item.ean,
name: item.name,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
taxCents: item.taxCents,
quantity: item.quantity,
createdAt: item.createdAt.toISOString(),
})),
shippingAddress: order.shippingAddress ?? null,
billingAddress: order.billingAddress ?? null,
payment: order.payment ? { ...order.payment, createdAt: order.payment.createdAt.toISOString() } : null,
createdAt: order.createdAt.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.
}
}