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 { 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(); 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 = { 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); } }); } 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 { 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. } }