import type { FastifyInstance, FastifyReply } 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 { ThirdPartyError, type ThirdPartyProvider } from '../third-party/third-party-client.js'; import type { ThirdPartyService } from '../third-party/third-party-service.js'; const providerSchema = z.enum(['MEITUAN', 'DIANPING', 'DOUYIN', 'KUAISHOU']); const redeemSchema = z.object({ provider: providerSchema, voucherCode: z.string().min(4).max(128), orderId: z.string().regex(/^[1-9]\d{0,19}$/), clientRequestId: z.string().min(8).max(128) }).strict(); const manualSchema = redeemSchema.extend({ amountCents: z.number().int().positive(), note: z.string().min(1).max(512) }).strict(); const notifyParams = z.object({ tenantId: z.string().regex(/^[1-9]\d{0,19}$/), provider: providerSchema }); const bookingSchema = z.object({ eventId: z.string().min(4).max(128), externalBookingNo: z.string().min(4).max(128), externalStoreRef: z.string().min(1).max(128), externalRoomRef: z.string().min(1).max(128), customerRef: z.string().max(255).default(''), startsAt: z.coerce.date(), endsAt: z.coerce.date(), amountCents: z.number().int().nonnegative() }).strict().refine((value) => value.endsAt > value.startsAt); const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) }); const recordsQuery = z.object({ provider: providerSchema.optional(), status: z.string().min(1).max(32).optional() }); const configSchema = z.object({ provider: providerSchema, storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null), mode: z.enum(['MANUAL', 'MOCK', 'API']), enabled: z.boolean().default(true), credentialRef: z.string().max(255).default(''), settings: z.record(z.unknown()).default({}) }).strict(); const mappingSchema = z.object({ provider: providerSchema, resourceType: z.enum(['STORE', 'ROOM']), externalRef: z.string().min(1).max(128), localResourceId: z.string().regex(/^[1-9]\d{0,19}$/) }).strict(); export interface ThirdPartyRouteOptions { service: ThirdPartyService; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise; }; jwtSecret: string; } export async function registerThirdPartyRoutes( app: FastifyInstance, options: ThirdPartyRouteOptions ) { app.post('/app-api/group-vouchers/redeem', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options, false); const body = redeemSchema.safeParse(request.body); if (!auth) return unauthorized(reply, request.traceId); if (!body.success) return invalid(reply, request.traceId); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.service.redeemVoucher({ tenantId: auth.tenantId, userId: auth.userId, ...body.data }), traceId: request.traceId })); }); app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options, true); const body = manualSchema.safeParse(request.body); if (!auth) return unauthorized(reply, request.traceId); if (!body.success) return invalid(reply, request.traceId); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.service.redeemVoucherManually({ tenantId: auth.tenantId, actorId: auth.userId, access: auth.access!, ...body.data }), traceId: request.traceId })); }); app.post( '/app-api/third-party/:provider/tenants/:tenantId/bookings/notify', async (request, reply) => { const params = notifyParams.safeParse(request.params); const body = bookingSchema.safeParse(request.body); const signature = request.headers['x-third-party-signature']; if (!params.success || !body.success || typeof signature !== 'string') { return invalid(reply, request.traceId); } return handle(reply, request.traceId, async () => ({ code: 0, data: await options.service.receiveDirectBooking({ tenantId: params.data.tenantId, provider: params.data.provider, signature, rawBody: JSON.stringify(request.body), payload: request.body as Record, ...body.data }), traceId: request.traceId })); } ); app.post('/app-api/third-party/bookings/:bookingId/claim', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options, false); const params = bookingParams.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.service.claimDirectBooking({ tenantId: auth.tenantId, userId: auth.userId, bookingId: params.data.bookingId }), traceId: request.traceId })); }); app.get('/admin-api/third-party/records', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options, true); const query = recordsQuery.safeParse(request.query); if (!auth) return unauthorized(reply, request.traceId); if (!query.success) return invalid(reply, request.traceId); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.service.listRecords({ tenantId: auth.tenantId, access: auth.access!, provider: query.data.provider as ThirdPartyProvider | undefined, status: query.data.status }), traceId: request.traceId })); }); app.put('/admin-api/third-party/config', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options, true); const body = configSchema.safeParse(request.body); if (!auth) return unauthorized(reply, request.traceId); if (!body.success) return invalid(reply, request.traceId); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.service.saveConfig({ tenantId: auth.tenantId, actorId: auth.userId, access: auth.access!, ...body.data }), traceId: request.traceId })); }); app.put('/admin-api/third-party/mappings', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options, true); const body = mappingSchema.safeParse(request.body); if (!auth) return unauthorized(reply, request.traceId); if (!body.success) return invalid(reply, request.traceId); return handle(reply, request.traceId, async () => ({ code: 0, data: await options.service.saveMapping({ tenantId: auth.tenantId, access: auth.access!, ...body.data }), traceId: request.traceId })); }); } async function authenticate( authorization: string | undefined, options: ThirdPartyRouteOptions, withAccess: boolean ) { 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: withAccess ? await options.accessControl.getAccessProfile(tenantId, userId) : undefined }; } async function handle(reply: FastifyReply, traceId: string, work: () => Promise) { try { return await work(); } catch (error) { if (!(error instanceof ThirdPartyError)) throw error; const status = error.code.includes('NOT_FOUND') ? 404 : error.code.includes('CONFLICT') || error.code.includes('ALREADY') ? 409 : error.code.includes('FORBIDDEN') ? 403 : error.code.includes('SIGNATURE') ? 401 : 400; return reply.status(status).send({ code: error.code, message: 'The third-party request 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_THIRD_PARTY_REQUEST', message: 'The third-party request is invalid.', traceId }); }