Files
mercadodevida/project/src/modules/shipping/api/shipping.routes.ts
2026-08-19 07:17:14 +02:00

364 lines
12 KiB
TypeScript

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<void> {
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,
};
}