364 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|