import type { FastifyInstance } from 'fastify'; import type { FastifySchema } from 'fastify'; import type pg from 'pg'; import { z } from 'zod'; import { requireRole, type Authenticate } from '../../../shared/auth.js'; import { AppError } from '../../../shared/errors.js'; import { errorSchema } from '../../../shared/swagger.js'; import { parseJson } from '../../../shared/http-input.js'; import { ShippingService } from '../application/shipping-service.js'; import { InvalidShippingAddressError, ShippingZoneNotFoundError } from '../domain/errors.js'; import type { ShippingQuote } from '../domain/shipping.js'; import { PgShippingRepository } from '../infrastructure/pg-shipping-repository.js'; export interface ShippingRoutesDeps { pool: pg.Pool; authenticate: Authenticate; } const zoneBodySchema = z.object({ name: z.string().min(1).max(120), country: z.string().min(2).max(80), postalCodePrefix: z.string().min(1).max(20).optional().nullable(), active: z.boolean().optional(), }); const methodBodySchema = z.object({ zoneId: z.uuid(), name: z.string().min(1).max(120), baseCostCents: z.number().int().min(0), freeShippingThresholdCents: z.number().int().min(0).optional().nullable(), active: z.boolean().optional(), }); const calculateBodySchema = z .object({ cartTotalCents: z.number().int().min(0), country: z.string().min(2).max(80), postalCode: z.string().min(1).max(20), }) .strip(); export async function registerShippingRoutes( app: FastifyInstance, deps: ShippingRoutesDeps, ): Promise { const service = new ShippingService(new PgShippingRepository(deps.pool)); const createZoneSchema: FastifySchema = { tags: ['Shipping'], summary: 'Create shipping zone (admin)', body: { type: 'object' }, response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema }, }; app.post('/shipping/zones', { schema: createZoneSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const input = parseJson(zoneBodySchema, request.body); const result = await deps.pool.query<{ id: string }>( `INSERT INTO shipping_zones (name, country, postal_code_prefix, active) VALUES ($1, $2, $3, $4) RETURNING id`, [input.name, input.country, input.postalCodePrefix ?? null, input.active ?? true], ); return reply.code(201).send({ id: result.rows[0]?.id }); }); const createMethodSchema: FastifySchema = { tags: ['Shipping'], summary: 'Create shipping method (admin)', body: { type: 'object' }, response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema }, }; app.post('/shipping/methods', { schema: createMethodSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const input = parseJson(methodBodySchema, request.body); const result = await deps.pool.query<{ id: string }>( `INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [ input.zoneId, input.name, input.baseCostCents, input.freeShippingThresholdCents ?? null, input.active ?? true, ], ); return reply.code(201).send({ id: result.rows[0]?.id }); }); // ── Admin management ─────────────────────────────────────────────────────── const listZonesSchema: FastifySchema = { tags: ['Shipping'], summary: 'List zones (admin)', response: { 401: errorSchema, 403: errorSchema }, }; app.get('/admin/shipping/zones', { schema: listZonesSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const result = await deps.pool.query<{ id: string; name: string; country: string; postal_code_prefix: string | null; active: boolean; }>('SELECT * FROM shipping_zones ORDER BY created_at DESC'); return reply.send({ items: result.rows.map((r) => ({ id: r.id, name: r.name, country: r.country, postalCodePrefix: r.postal_code_prefix, active: r.active, })), }); }); const patchZoneSchema: FastifySchema = { tags: ['Shipping'], summary: 'Update zone (admin)', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, body: { type: 'object' }, response: { 401: errorSchema, 403: errorSchema }, }; app.patch('/admin/shipping/zones/:id', { schema: patchZoneSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const id = (request.params as { id: string }).id; const patch = parseJson( z.object({ name: z.string().min(1).max(120).optional(), country: z.string().min(2).max(80).optional(), postalCodePrefix: z.string().max(20).optional().nullable(), active: z.boolean().optional(), }), request.body, ); const sets: string[] = []; const values: unknown[] = []; let i = 1; if (patch.name !== undefined) { sets.push(`name = $${i++}`); values.push(patch.name); } if (patch.country !== undefined) { sets.push(`country = $${i++}`); values.push(patch.country); } if (patch.postalCodePrefix !== undefined) { sets.push(`postal_code_prefix = $${i++}`); values.push(patch.postalCodePrefix); } if (patch.active !== undefined) { sets.push(`active = $${i++}`); values.push(patch.active); } if (!sets.length) { return reply.send({ ok: true }); } values.push(id); await deps.pool.query(`UPDATE shipping_zones SET ${sets.join(', ')} WHERE id = $${i}`, values); return reply.send({ ok: true }); }); const deleteZoneSchema: FastifySchema = { tags: ['Shipping'], summary: 'Delete zone (admin)', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema }, }; app.delete('/admin/shipping/zones/:id', { schema: deleteZoneSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const id = (request.params as { id: string }).id; await deps.pool.query('DELETE FROM shipping_methods WHERE zone_id = $1', [id]); await deps.pool.query('DELETE FROM shipping_zones WHERE id = $1', [id]); return reply.code(204).send(); }); const listMethodsSchema: FastifySchema = { tags: ['Shipping'], summary: 'List methods (admin)', response: { 401: errorSchema, 403: errorSchema }, }; app.get('/admin/shipping/methods', { schema: listMethodsSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const result = await deps.pool.query<{ id: string; zone_id: string; zone_name: string; name: string; base_cost_cents: number; free_shipping_threshold_cents: number | null; active: boolean; }>( `SELECT sm.*, sz.name as zone_name FROM shipping_methods sm JOIN shipping_zones sz ON sz.id = sm.zone_id ORDER BY sm.created_at DESC`, ); return reply.send({ items: result.rows.map((r) => ({ id: r.id, zoneId: r.zone_id, zoneName: r.zone_name, name: r.name, baseCostCents: r.base_cost_cents, freeShippingThresholdCents: r.free_shipping_threshold_cents, active: r.active, })), }); }); app.post('/admin/shipping/methods', async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const input = parseJson(methodBodySchema, request.body); const result = await deps.pool.query<{ id: string }>( `INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [ input.zoneId, input.name, input.baseCostCents, input.freeShippingThresholdCents ?? null, input.active ?? true, ], ); return reply.code(201).send({ id: result.rows[0]?.id }); }); const patchMethodSchema: FastifySchema = { tags: ['Shipping'], summary: 'Update method (admin)', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, body: { type: 'object' }, response: { 401: errorSchema, 403: errorSchema }, }; app.patch( '/admin/shipping/methods/:id', { schema: patchMethodSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const id = (request.params as { id: string }).id; const patch = parseJson( z.object({ name: z.string().min(1).max(120).optional(), baseCostCents: z.number().int().min(0).optional(), freeShippingThresholdCents: z.number().int().min(0).optional().nullable(), active: z.boolean().optional(), }), request.body, ); const sets: string[] = []; const values: unknown[] = []; let i = 1; if (patch.name !== undefined) { sets.push(`name = $${i++}`); values.push(patch.name); } if (patch.baseCostCents !== undefined) { sets.push(`base_cost_cents = $${i++}`); values.push(patch.baseCostCents); } if (patch.freeShippingThresholdCents !== undefined) { sets.push(`free_shipping_threshold_cents = $${i++}`); values.push(patch.freeShippingThresholdCents); } if (patch.active !== undefined) { sets.push(`active = $${i++}`); values.push(patch.active); } if (!sets.length) { return reply.send({ ok: true }); } values.push(id); await deps.pool.query( `UPDATE shipping_methods SET ${sets.join(', ')} WHERE id = $${i}`, values, ); return reply.send({ ok: true }); }, ); const deleteMethodSchema: FastifySchema = { tags: ['Shipping'], summary: 'Delete method (admin)', params: { type: 'object', required: ['id'], properties: { id: { type: 'string', format: 'uuid' } }, }, response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema }, }; app.delete( '/admin/shipping/methods/:id', { schema: deleteMethodSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const id = (request.params as { id: string }).id; await deps.pool.query('DELETE FROM shipping_methods WHERE id = $1', [id]); return reply.code(204).send(); }, ); // ── Customer-facing ─────────────────────────────────────────────────────── const calcShippingSchema: FastifySchema = { tags: ['Shipping'], summary: 'Calculate shipping (público)', body: { type: 'object', required: ['cartTotalCents', 'country', 'postalCode'], properties: { cartTotalCents: { type: 'integer', minimum: 0 }, country: { type: 'string' }, postalCode: { type: 'string' }, }, }, }; app.post('/shipping/calculate', { schema: calcShippingSchema }, async (request, reply) => { const input = parseJson(calculateBodySchema, request.body); try { const quote = await service.calculate(input.cartTotalCents, { country: input.country, postalCode: input.postalCode, }); return reply.send(serializeQuote(quote)); } catch (error) { throw mapShippingError(error); } }); } function mapShippingError(error: unknown): Error { if (error instanceof ShippingZoneNotFoundError) return new AppError(422, 'SHIPPING_ZONE_NOT_FOUND', error.message); if (error instanceof InvalidShippingAddressError) return new AppError(400, 'INVALID_SHIPPING_ADDRESS', error.message); return error instanceof Error ? error : new Error('Unknown shipping error'); } function serializeQuote(quote: ShippingQuote) { return { zoneId: quote.zoneId, methodId: quote.methodId, methodName: quote.methodName, costCents: quote.costCents, freeApplied: quote.freeApplied, }; }