feat(ADM-018): completed feature
This commit is contained in:
193
project/src/modules/orders/api/orders.routes.ts
Normal file
193
project/src/modules/orders/api/orders.routes.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.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';
|
||||
|
||||
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<void> {
|
||||
const service = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
|
||||
|
||||
app.post('/orders', 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);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/orders/:id/transitions', 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);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/orders/:id', 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
|
||||
app.get('/orders', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const orders = await service.listOrders();
|
||||
return reply.send(orders.map(serializeOrder));
|
||||
});
|
||||
|
||||
app.get('/orders/:id/admin', 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));
|
||||
});
|
||||
|
||||
app.post('/orders/:id/transitions/admin', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
const { state } = parseJson(transitionSchema, request.body);
|
||||
try {
|
||||
const order = await service.transitionAdmin(id, state as OrderState);
|
||||
return reply.send(serializeOrder(order));
|
||||
} 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;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
idempotencyKey: string | null;
|
||||
userId?: string;
|
||||
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;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
id: order.id,
|
||||
userId: order.userId,
|
||||
state: order.state,
|
||||
currency: order.currency,
|
||||
subtotalCents: order.subtotalCents,
|
||||
discountCents: order.discountCents,
|
||||
taxCents: order.taxCents,
|
||||
totalCents: order.totalCents,
|
||||
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(),
|
||||
})),
|
||||
createdAt: order.createdAt.toISOString(),
|
||||
updatedAt: order.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user