import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import type { AuthRepository } from '../auth/auth-repository.js'; import { authenticateAccessToken } from '../auth/authenticate.js'; import type { AccessProfile } from '../auth/rbac-repository.js'; import { OrderManagementError, type OrderManagementRepository } from '../orders/order-management-repository.js'; import type { OrderActor } from '../orders/order-state-repository.js'; const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) }); const renewSchema = z.object({ endAt: z.coerce.date(), pricingPolicy: z.enum(['CURRENT', 'LOCKED']).default('CURRENT'), reason: z.string().min(1).max(512) }).strict(); const roomSchema = z.object({ roomId: z.string().regex(/^[1-9]\d{0,19}$/), reason: z.string().min(1).max(512) }).strict(); const timeSchema = z.object({ startAt: z.coerce.date().optional(), endAt: z.coerce.date().optional(), reason: z.string().min(1).max(512) }).strict().refine((value) => value.startAt || value.endAt); const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict(); export interface OrderManagementRouteOptions { repository: Pick; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; } export async function registerOrderManagementRoutes( app: FastifyInstance, options: OrderManagementRouteOptions ) { app.post('/app-api/orders/:orderId/renew', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options); const params = paramsSchema.safeParse(request.params); const body = renewSchema.safeParse(request.body); if (!auth) return unauthorized(reply, request.traceId); if (!params.success || !body.success) return invalid(reply, request.traceId); const actor = customerActor(auth, request); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.repository.customerRenew(actor, params.data.orderId, body.data), traceId: request.traceId })); }); app.post('/app-api/orders/:orderId/change-room', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options); const params = paramsSchema.safeParse(request.params); const body = roomSchema.safeParse(request.body); if (!auth) return unauthorized(reply, request.traceId); if (!params.success || !body.success) return invalid(reply, request.traceId); const actor = customerActor(auth, request); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.repository.customerChangeRoom(actor, params.data.orderId, body.data), traceId: request.traceId })); }); app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options); const params = paramsSchema.safeParse(request.params); if (!auth) return unauthorized(reply, request.traceId); if (!params.success) return invalid(reply, request.traceId); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.repository.cancellationQuote( auth.tenantId, auth.userId, params.data.orderId ), traceId: request.traceId })); }); const adminActions = [ ['renew', renewSchema, (actor: OrderActor, orderId: string, body: z.infer) => options.repository.renew(actor, orderId, body)], ['change-room', roomSchema, (actor: OrderActor, orderId: string, body: z.infer) => options.repository.changeRoom(actor, orderId, body)], ['adjust-time', timeSchema, (actor: OrderActor, orderId: string, body: z.infer) => options.repository.adjustTime(actor, orderId, body)], ['note', noteSchema, (actor: OrderActor, orderId: string, body: z.infer) => options.repository.note(actor, orderId, body.note)] ] as const; for (const [path, schema, execute] of adminActions) { app.post(`/admin-api/orders/:orderId/${path}`, async (request, reply) => { const auth = await authenticate(request.headers.authorization, options); const params = paramsSchema.safeParse(request.params); const body = schema.safeParse(request.body); if (!auth) return unauthorized(reply, request.traceId); if (!params.success || !body.success) return invalid(reply, request.traceId); const actor = { tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const, source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '', access: auth.access }; return handle(reply, request.traceId, async () => ({ code: 0, data: await execute(actor, params.data.orderId, body.data as never), traceId: request.traceId })); }); } } function customerActor( auth: { tenantId: string; userId: string; access: AccessProfile }, request: FastifyRequest ): OrderActor { return { tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER', source: 'APP', traceId: request.traceId, ip: request.ip, userAgent: String(request.headers['user-agent'] ?? ''), access: auth.access }; } async function authenticate( authorization: string | undefined, options: OrderManagementRouteOptions ) { const result = await authenticateAccessToken( authorization, options.authRepository, options.jwtSecret ); if (!result) return null; const tenantId = result.session.tenantId; const userId = result.session.user.id; return { tenantId, userId, access: await options.accessControl.getAccessProfile(tenantId, userId) }; } async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { try { return await work(); } catch (error) { if (!(error instanceof OrderManagementError)) throw error; const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'ROOM_NOT_FOUND' ? 404 : error.code === 'TIME_SLOT_CONFLICT' ? 409 : error.code.includes('FORBIDDEN') ? 403 : 400; return reply.status(status).send({ code: error.code, message: 'The requested order adjustment is not available.', traceId }); } } function unauthorized(reply: FastifyReply, traceId: string) { return reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId }); } function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({ code: 'INVALID_ORDER_ADJUSTMENT', message: 'The order adjustment is invalid.', traceId }); }