feat(F-048): completed feature
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
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';
|
||||
@@ -43,7 +45,13 @@ export async function registerShippingRoutes(
|
||||
): Promise<void> {
|
||||
const service = new ShippingService(new PgShippingRepository(deps.pool));
|
||||
|
||||
app.post('/shipping/zones', async (request, reply) => {
|
||||
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);
|
||||
@@ -55,7 +63,13 @@ export async function registerShippingRoutes(
|
||||
return reply.code(201).send({ id: result.rows[0]?.id });
|
||||
});
|
||||
|
||||
app.post('/shipping/methods', async (request, reply) => {
|
||||
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);
|
||||
@@ -74,40 +88,94 @@ export async function registerShippingRoutes(
|
||||
});
|
||||
|
||||
// ── Admin management ───────────────────────────────────────────────────────
|
||||
app.get('/admin/shipping/zones', async (request, reply) => {
|
||||
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;
|
||||
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,
|
||||
})) });
|
||||
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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/admin/shipping/zones/:id', async (request, reply) => {
|
||||
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 }); }
|
||||
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 });
|
||||
});
|
||||
|
||||
app.delete('/admin/shipping/zones/:id', async (request, reply) => {
|
||||
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;
|
||||
@@ -116,21 +184,38 @@ export async function registerShippingRoutes(
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/admin/shipping/methods', async (request, reply) => {
|
||||
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;
|
||||
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`
|
||||
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,
|
||||
})) });
|
||||
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) => {
|
||||
@@ -140,42 +225,112 @@ export async function registerShippingRoutes(
|
||||
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],
|
||||
[
|
||||
input.zoneId,
|
||||
input.name,
|
||||
input.baseCostCents,
|
||||
input.freeShippingThresholdCents ?? null,
|
||||
input.active ?? true,
|
||||
],
|
||||
);
|
||||
return reply.code(201).send({ id: result.rows[0]?.id });
|
||||
});
|
||||
|
||||
app.patch('/admin/shipping/methods/:id', 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 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 });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete('/admin/shipping/methods/:id', 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();
|
||||
});
|
||||
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 ───────────────────────────────────────────────────────
|
||||
app.post('/shipping/calculate', async (request, reply) => {
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user