import { BadRequestException, Body, Controller, Get, Post } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ProtocolLogsService, type ProtocolLogInput } from '../protocol-logs/protocol-logs.service'; import type { GatewayReceiptEventDto, GatewaySubmitDeadLetterDto, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayUplinkEventDto, } from './send-chain.contracts'; import { SendChainService } from './send-chain.service'; @Controller() export class GatewayCallbackController { constructor( private readonly sendChain: SendChainService, private readonly protocolLogs: ProtocolLogsService, private readonly prisma: PrismaService, ) {} @Get('health') async health() { await this.prisma.$queryRaw`SELECT 1`; return { status: 'ok', role: 'gateway-callback', databasePool: this.prisma.getPoolState() }; } @Post('gateway/events/submit-result') submitResult(@Body() body: GatewaySubmitResultDto) { return this.sendChain.handleSubmitResult(body); } @Post('gateway/events/submit-segment-result') submitSegmentResult(@Body() body: GatewaySubmitSegmentResultDto) { return this.sendChain.handleSubmitSegmentResult(body); } @Post('gateway/events/receipt/intake') receiptIntake(@Body() body: GatewayReceiptEventDto) { return this.track('deliver_receipt', body, () => this.sendChain.intakeReceipt(body)); } @Post('gateway/events/receipt') receipt(@Body() body: GatewayReceiptEventDto) { return this.track('deliver_receipt', body, () => this.sendChain.handleReceipt(body)); } @Post('gateway/events/uplink') uplink(@Body() body: GatewayUplinkEventDto) { return this.track('deliver_uplink', body, () => this.sendChain.handleUplink(body)); } @Post('gateway/events/protocol-log') protocolLog(@Body() body: ProtocolLogInput) { const allowedPacket = ( body.direction === 'platform_to_channel' && ['submit', 'deliver_resp'].includes(body.eventType) ) || ( body.direction === 'channel_to_platform' && body.eventType === 'submit_resp' ); if (body.protocol !== 'cmpp' || !allowedPacket || !['success', 'failed'].includes(body.status)) { throw new BadRequestException('Unsupported Gateway callback protocol log event'); } this.protocolLogs.record(body); return { accepted: true }; } @Post('gateway/events/dead-letter') deadLetter(@Body() body: GatewaySubmitDeadLetterDto) { return this.sendChain.recordGatewaySubmitDeadLetter(body); } @Post('gateway/events/batch') async batch(@Body() body: { batchId?: string; gatewayInstanceId?: string; createdAt?: string; events?: Array<{ eventId?: string; type?: string; payload?: Record }>; }) { if (!body.batchId || !body.gatewayInstanceId || !Array.isArray(body.events) || body.events.length < 1 || body.events.length > 100) { throw new BadRequestException('batchId, gatewayInstanceId and 1 to 100 events are required'); } if (Buffer.byteLength(JSON.stringify(body), 'utf8') > 1024 * 1024) { throw new BadRequestException('Gateway callback batch exceeds 1MB'); } const seen = new Set(); const results: Array<{ eventId: string; accepted: boolean; retryable?: boolean; errorCode?: string }> = []; for (let offset = 0; offset < body.events.length; offset += 25) { results.push(...await Promise.all(body.events.slice(offset, offset + 25).map(async (event) => { const eventId = String(event.eventId ?? '').trim(); if (!eventId || seen.has(eventId) || !event.payload || typeof event.payload !== 'object') { return { eventId, accepted: false, retryable: false, errorCode: seen.has(eventId) ? 'DUPLICATE_EVENT_ID' : 'INVALID_EVENT' }; } seen.add(eventId); try { await this.dispatchBatchEvent(String(event.type ?? ''), { ...event.payload, eventId }); return { eventId, accepted: true }; } catch (error) { const invalid = error instanceof BadRequestException; return { eventId, accepted: false, retryable: !invalid, errorCode: invalid ? 'INVALID_EVENT' : 'PROCESSING_FAILED' }; } }))); } return { batchId: body.batchId, results }; } private dispatchBatchEvent(type: string, payload: Record) { switch (type) { case 'submit_result': return this.sendChain.handleSubmitResult(payload as unknown as GatewaySubmitResultDto); case 'submit_segment_result': return this.sendChain.handleSubmitSegmentResult(payload as unknown as GatewaySubmitSegmentResultDto); case 'receipt_intake': return this.track('deliver_receipt', payload, () => this.sendChain.intakeReceipt(payload as unknown as GatewayReceiptEventDto)); case 'uplink': return this.track('deliver_uplink', payload, () => this.sendChain.handleUplink(payload as unknown as GatewayUplinkEventDto)); case 'dead_letter': return this.sendChain.recordGatewaySubmitDeadLetter(payload as unknown as GatewaySubmitDeadLetterDto); default: throw new BadRequestException(`Unsupported batch event type ${type}`); } } private async track(eventType: string, body: object, action: () => Promise | T) { const startedAt = Date.now(); const value = body as Record; const common: Omit = { protocol: 'cmpp', direction: 'channel_to_platform', eventType, tenantId: value.tenantId as string, applicationId: value.applicationId as string, channelId: value.channelId as string, messageId: (value.messageId ?? value.platformMessageId) as string, gatewayMessageId: (value.gatewayMessageId ?? value.msgId ?? value.upstreamMessageId) as string, phone: (value.phoneNumber ?? value.srcTerminalId) as string, resultCode: (value.rawStatus ?? value.status ?? value.stat) as string, }; try { const result = await action(); const resolved = result && typeof result === 'object' ? result as Record : {}; if (process.env.PROTOCOL_LOG_CALLBACK_TRACKING_ENABLED === 'true') this.protocolLogs.record({ ...common, tenantId: (resolved.tenantId ?? common.tenantId) as string, applicationId: (resolved.applicationId ?? common.applicationId) as string, messageId: (resolved.messageId ?? common.messageId) as string, status: 'success', durationMs: Date.now() - startedAt, }); return result; } catch (error) { if (process.env.PROTOCOL_LOG_CALLBACK_TRACKING_ENABLED === 'true') this.protocolLogs.record({ ...common, status: 'failed', durationMs: Date.now() - startedAt, detail: { error: error instanceof Error ? error.message : String(error) }, }); throw error; } } }