fix: reconcile shared-channel receipts and protocol logs
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { GatewayEventsController } from './gateway-events.controller';
|
||||
|
||||
describe('GatewayEventsController protocol logging', () => {
|
||||
const sendChain = {
|
||||
handleSubmitResult: jest.fn(),
|
||||
handleReceipt: jest.fn(),
|
||||
};
|
||||
const protocolLogs = {
|
||||
record: jest.fn(),
|
||||
};
|
||||
const controller = new GatewayEventsController(sendChain as never, {} as never, protocolLogs as never);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not duplicate a supplier SubmitResp from the internal aggregate callback', async () => {
|
||||
sendChain.handleSubmitResult.mockResolvedValue({ accepted: true });
|
||||
const body = {
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: '123',
|
||||
sequenceId: 7,
|
||||
submitStatus: 'accepted' as const,
|
||||
};
|
||||
|
||||
await controller.submitResult(body);
|
||||
|
||||
expect(protocolLogs.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts only safe outbound Gateway packet events', () => {
|
||||
expect(controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
direction: 'platform_to_channel',
|
||||
eventType: 'submit',
|
||||
status: 'success',
|
||||
messageId: 'MSG-1',
|
||||
})).toEqual({ accepted: true });
|
||||
expect(protocolLogs.record).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(() => controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
direction: 'client_to_platform',
|
||||
eventType: 'submit',
|
||||
status: 'success',
|
||||
})).toThrow(BadRequestException);
|
||||
|
||||
expect(controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
direction: 'channel_to_platform',
|
||||
eventType: 'submit_resp',
|
||||
status: 'success',
|
||||
messageId: 'MSG-1',
|
||||
})).toEqual({ accepted: true });
|
||||
expect(controller.protocolLog({
|
||||
protocol: 'cmpp',
|
||||
direction: 'platform_to_client',
|
||||
eventType: 'submit_resp',
|
||||
status: 'success',
|
||||
messageId: 'MSG-1',
|
||||
})).toEqual({ accepted: true });
|
||||
});
|
||||
|
||||
it('enriches an enterprise Submit packet with identifiers returned by the real service', async () => {
|
||||
(sendChain as Record<string, jest.Mock>).submitInboundMessage = jest.fn().mockResolvedValue({
|
||||
accepted: true,
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
status: 'fragment_pending',
|
||||
});
|
||||
|
||||
await controller.submitInbound({
|
||||
account: '607532',
|
||||
phoneNumber: '13127620092',
|
||||
content: 'fragment',
|
||||
sequenceId: 141,
|
||||
});
|
||||
|
||||
expect(protocolLogs.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
direction: 'client_to_platform',
|
||||
eventType: 'submit',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
status: 'success',
|
||||
}));
|
||||
});
|
||||
|
||||
it('replaces a fallback receipt identifier with the resolved main message identifier', async () => {
|
||||
sendChain.handleReceipt.mockResolvedValue({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-LONG-1',
|
||||
});
|
||||
|
||||
await controller.receipt({
|
||||
messageId: 'receipt-736070230367350788',
|
||||
channelId: 'channel-copy',
|
||||
gatewayMessageId: '736070230367350788',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
});
|
||||
|
||||
expect(protocolLogs.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
direction: 'channel_to_platform',
|
||||
eventType: 'deliver_receipt',
|
||||
channelId: 'channel-copy',
|
||||
messageId: 'MSG-LONG-1',
|
||||
gatewayMessageId: '736070230367350788',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
status: 'success',
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
GatewayInboundAuthDto,
|
||||
@@ -28,7 +28,7 @@ export class GatewayEventsController {
|
||||
|
||||
@Post('submit-result')
|
||||
submitResult(@Body() body: GatewaySubmitResultDto) {
|
||||
return this.trackGatewayEvent('submit_resp', body, () => this.sendChain.handleSubmitResult(body));
|
||||
return this.sendChain.handleSubmitResult(body);
|
||||
}
|
||||
|
||||
@Post('receipt')
|
||||
@@ -41,6 +41,25 @@ export class GatewayEventsController {
|
||||
return this.trackGatewayEvent('deliver_uplink', body, () => this.sendChain.handleUplink(body));
|
||||
}
|
||||
|
||||
@Post('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'
|
||||
) || (
|
||||
body.direction === 'platform_to_client'
|
||||
&& body.eventType === 'submit_resp'
|
||||
);
|
||||
if (body.protocol !== 'cmpp' || !allowedPacket || !['success', 'failed'].includes(body.status)) {
|
||||
throw new BadRequestException('Unsupported Gateway protocol log event');
|
||||
}
|
||||
this.protocolLogs.record(body);
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
@Post('dead-letter')
|
||||
deadLetter(@Body() body: GatewaySubmitDeadLetterDto) {
|
||||
return this.sendChain.recordGatewaySubmitDeadLetter(body);
|
||||
@@ -110,14 +129,25 @@ export class GatewayEventsController {
|
||||
messageId: (value.messageId ?? value.platformMessageId) as string,
|
||||
gatewayMessageId: (value.gatewayMessageId ?? value.msgId ?? value.upstreamMessageId) as string,
|
||||
phone: (value.phoneNumber ?? value.srcTerminalId ?? value.destinationId) as string,
|
||||
resultCode: (value.result ?? value.status ?? value.stat) as string,
|
||||
resultCode: (value.result ?? value.submitStatus ?? value.rawStatus ?? value.status ?? value.stat) as string,
|
||||
detail: { sequenceId: value.sequenceId, connectionId: value.connectionId },
|
||||
};
|
||||
this.protocolLogs.record({ ...common, status: 'received', durationMs: 0 });
|
||||
try {
|
||||
const result = await action();
|
||||
const resultValue = result && typeof result === 'object'
|
||||
? result as Record<string, unknown>
|
||||
: {};
|
||||
this.protocolLogs.record({
|
||||
...common,
|
||||
tenantId: (resultValue.tenantId ?? common.tenantId) as string,
|
||||
applicationId: (resultValue.applicationId ?? common.applicationId) as string,
|
||||
channelId: common.channelId ?? resultValue.channelId as string,
|
||||
account: common.account ?? resultValue.account as string,
|
||||
messageId: (resultValue.messageId ?? common.messageId) as string,
|
||||
gatewayMessageId: common.gatewayMessageId
|
||||
?? (resultValue.gatewayMessageId ?? resultValue.msgId) as string,
|
||||
resultCode: common.resultCode
|
||||
?? (resultValue.result ?? resultValue.status) as string,
|
||||
status: 'success',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
|
||||
@@ -160,6 +160,7 @@ function createPrismaMock() {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'segment-1' }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
cmppInboundLongMessage: {
|
||||
create: jest.fn(),
|
||||
@@ -1917,6 +1918,7 @@ describe('SendChainService', () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
||||
prisma.smsSubmitRecord.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -2020,6 +2022,183 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches a receipt from another connection only when it is the unique channel of the same supplier', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
||||
prisma.smsChannel.findUnique.mockResolvedValue({
|
||||
id: 'channel-copy',
|
||||
account: 'C59748',
|
||||
gatewayHost: 'supplier.example.com',
|
||||
gatewayPort: 7890,
|
||||
protocol: 'CMPP',
|
||||
cmppVersion: '2.0',
|
||||
});
|
||||
prisma.smsSubmitRecord.findMany
|
||||
.mockResolvedValueOnce([]);
|
||||
prisma.smsMessageSegmentAudit.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'segment-2',
|
||||
submitId: 'SUB-LONG-1',
|
||||
submitRecordId: 'submit-original',
|
||||
channelId: 'channel-original',
|
||||
gatewayMessageId: '736070230367350788',
|
||||
submitRecord: { id: 'submit-original', submitId: 'SUB-LONG-1' },
|
||||
channel: {
|
||||
id: 'channel-original',
|
||||
account: 'C59748',
|
||||
gatewayHost: 'supplier.example.com',
|
||||
gatewayPort: 7890,
|
||||
protocol: 'CMPP',
|
||||
cmppVersion: '2.0',
|
||||
},
|
||||
messageRecord: {
|
||||
id: 'record-long',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-LONG-1',
|
||||
submitId: 'SUB-LONG-1',
|
||||
phoneNumber: '13127620092',
|
||||
channelId: 'channel-original',
|
||||
gatewayMessageId: '736070227905294338',
|
||||
status: 'submitted',
|
||||
billingUnits: 2,
|
||||
},
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'receipt-736070230367350788',
|
||||
channelId: 'channel-copy',
|
||||
gatewayMessageId: '736070230367350788',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
});
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
channelId: 'channel-original',
|
||||
messageRecordId: 'record-long',
|
||||
messageId: 'MSG-LONG-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not match the same Msg_Id across channels belonging to different suppliers', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
||||
prisma.smsChannel.findUnique.mockResolvedValue({
|
||||
id: 'channel-other',
|
||||
account: 'OTHER',
|
||||
gatewayHost: 'other.example.com',
|
||||
gatewayPort: 7890,
|
||||
protocol: 'CMPP',
|
||||
cmppVersion: '2.0',
|
||||
});
|
||||
prisma.smsSubmitRecord.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
prisma.smsMessageSegmentAudit.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'segment-original',
|
||||
submitRecordId: 'submit-original',
|
||||
submitId: 'SUB-ORIGINAL',
|
||||
channelId: 'channel-original',
|
||||
gatewayMessageId: 'SHARED-ID',
|
||||
submitRecord: { id: 'submit-original', submitId: 'SUB-ORIGINAL' },
|
||||
channel: {
|
||||
id: 'channel-original',
|
||||
account: 'C59748',
|
||||
gatewayHost: 'supplier.example.com',
|
||||
gatewayPort: 7890,
|
||||
protocol: 'CMPP',
|
||||
cmppVersion: '2.0',
|
||||
},
|
||||
messageRecord: {
|
||||
id: 'record-original',
|
||||
messageId: 'MSG-ORIGINAL',
|
||||
phoneNumber: '13127620092',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.handleReceipt({
|
||||
messageId: 'receipt-SHARED-ID',
|
||||
channelId: 'channel-other',
|
||||
gatewayMessageId: 'SHARED-ID',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
})).rejects.toThrow('SMS message record not found');
|
||||
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for every long-message segment before marking the main message delivered', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
id: 'record-long',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-LONG-1',
|
||||
submitId: 'SUB-LONG-1',
|
||||
phoneNumber: '13127620092',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-SEG-1',
|
||||
status: 'submitted',
|
||||
billingUnits: 2,
|
||||
});
|
||||
prisma.smsSubmitRecord.findFirst.mockResolvedValue({
|
||||
id: 'submit-long',
|
||||
submitId: 'SUB-LONG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-SEG-1',
|
||||
});
|
||||
prisma.smsMessageSegmentAudit.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
||||
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: null, rawStatus: null, deliveredAt: null },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ segmentIndex: 1, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
||||
{ segmentIndex: 2, segmentTotal: 2, receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date() },
|
||||
]);
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-LONG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-SEG-1',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'delivered' }),
|
||||
}));
|
||||
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
||||
|
||||
prisma.smsReceiptRecord.findUnique.mockResolvedValue(null);
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-LONG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-SEG-2',
|
||||
phoneNumber: '13127620092',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: 'DELIVRD',
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'record-long' },
|
||||
data: expect.objectContaining({ status: 'delivered', receiptStatus: 'delivered' }),
|
||||
}));
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsReceiptRecord.findUnique
|
||||
@@ -2050,6 +2229,7 @@ describe('SendChainService', () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue(null);
|
||||
prisma.smsSubmitRecord.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -982,7 +982,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async handleReceipt(data: GatewayReceiptEventDto) {
|
||||
const receiptKey = this.receiptEventKey(data);
|
||||
const resolved = await this.resolveReceiptMessage(data);
|
||||
const logicalChannelId = resolved.channelId ?? data.channelId;
|
||||
const receiptKey = this.receiptEventKey(data, logicalChannelId);
|
||||
const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({
|
||||
where: { receiptKey },
|
||||
include: { messageRecord: true },
|
||||
@@ -990,11 +992,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (existingReceipt?.messageRecord) {
|
||||
return existingReceipt.messageRecord;
|
||||
}
|
||||
const resolved = await this.resolveReceiptMessage(data);
|
||||
const message = resolved.message;
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const status =
|
||||
data.receiptStatus === 'delivered' ? 'delivered' : data.receiptStatus === 'unknown' ? 'unknown' : 'failed';
|
||||
if (resolved.submitRecordId) {
|
||||
await this.prisma.smsSubmitRecord.updateMany({
|
||||
where: {
|
||||
@@ -1014,7 +1013,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
receiptKey,
|
||||
channelId: data.channelId,
|
||||
channelId: logicalChannelId,
|
||||
messageId: resolved.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: data.phoneNumber?.trim() || message.phoneNumber,
|
||||
@@ -1036,10 +1035,26 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.recordReceiptSegment(message, data, deliveredAt, resolved.submitRecordId);
|
||||
const logicalReceipt = { ...data, channelId: logicalChannelId };
|
||||
await this.recordReceiptSegment(message, logicalReceipt, deliveredAt, resolved.submitRecordId);
|
||||
const aggregate = await this.aggregateReceiptSegments(
|
||||
message,
|
||||
logicalReceipt,
|
||||
deliveredAt,
|
||||
resolved.submitRecordId,
|
||||
resolved.submitId,
|
||||
);
|
||||
if (!aggregate.terminal) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
const status = aggregate.status;
|
||||
const isCurrentAttempt =
|
||||
(!message.channelId || message.channelId === data.channelId)
|
||||
&& (!message.gatewayMessageId || message.gatewayMessageId === data.gatewayMessageId);
|
||||
(!message.channelId || message.channelId === logicalChannelId)
|
||||
&& (
|
||||
!message.gatewayMessageId
|
||||
|| message.gatewayMessageId === data.gatewayMessageId
|
||||
|| (aggregate.segmentTotal > 1 && (!message.submitId || message.submitId === resolved.submitId))
|
||||
);
|
||||
if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) {
|
||||
return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } });
|
||||
}
|
||||
@@ -1056,14 +1071,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: {
|
||||
channelId: data.channelId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
receiptStatus: data.receiptStatus,
|
||||
receiptRawStatus: data.rawStatus,
|
||||
channelId: logicalChannelId,
|
||||
gatewayMessageId: message.gatewayMessageId ?? data.gatewayMessageId,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
receiptRawStatus: aggregate.rawStatus,
|
||||
status,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage ?? (status === 'delivered' ? null : data.rawStatus),
|
||||
deliveredAt,
|
||||
errorCode: aggregate.errorCode,
|
||||
errorMessage: aggregate.errorMessage ?? (status === 'delivered' ? null : aggregate.rawStatus),
|
||||
deliveredAt: aggregate.deliveredAt,
|
||||
},
|
||||
});
|
||||
if (!isStandaloneChannelTest && message.tenantId && message.applicationId) {
|
||||
@@ -1077,12 +1092,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
receiptStatus: aggregate.receiptStatus,
|
||||
rawStatus: aggregate.rawStatus,
|
||||
errorCode: aggregate.errorCode,
|
||||
submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined,
|
||||
submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined,
|
||||
deliveredAt: deliveredAt.toISOString(),
|
||||
deliveredAt: aggregate.deliveredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2071,6 +2086,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!collection.complete) {
|
||||
return {
|
||||
accepted: true,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
messageId: collection.messageId,
|
||||
status: 'fragment_pending',
|
||||
fragmentPending: true,
|
||||
@@ -3522,6 +3539,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
updateMany: (args: Record<string, unknown>) => Promise<{ count: number }>;
|
||||
findFirst: (args: Record<string, unknown>) => Promise<any | null>;
|
||||
findMany: (args: Record<string, unknown>) => Promise<any[]>;
|
||||
};
|
||||
}).smsMessageSegmentAudit;
|
||||
}
|
||||
@@ -3690,6 +3708,101 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private async aggregateReceiptSegments(
|
||||
message: {
|
||||
id: string;
|
||||
billingUnits?: number | null;
|
||||
},
|
||||
data: GatewayReceiptEventDto,
|
||||
deliveredAt: Date,
|
||||
submitRecordId?: string,
|
||||
submitId?: string,
|
||||
) {
|
||||
const audits = await this.smsMessageSegmentAuditDelegate().findMany({
|
||||
where: submitRecordId
|
||||
? { messageRecordId: message.id, submitRecordId }
|
||||
: submitId
|
||||
? { messageRecordId: message.id, submitId }
|
||||
: { messageRecordId: message.id, gatewayMessageId: data.gatewayMessageId },
|
||||
orderBy: { segmentIndex: 'asc' },
|
||||
});
|
||||
if (audits.length === 0) {
|
||||
const status = data.receiptStatus === 'delivered'
|
||||
? 'delivered'
|
||||
: data.receiptStatus === 'unknown'
|
||||
? 'unknown'
|
||||
: 'failed';
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal: 1,
|
||||
status,
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
const segmentTotal = Math.max(
|
||||
1,
|
||||
Number(message.billingUnits ?? 1),
|
||||
...audits.map((audit) => Number(audit.segmentTotal ?? 1)),
|
||||
);
|
||||
const received = audits.filter((audit) => Boolean(audit.receiptStatus));
|
||||
const failed = received.find((audit) => !['delivered', 'unknown'].includes(audit.receiptStatus ?? ''));
|
||||
if (failed) {
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'failed',
|
||||
receiptStatus: failed.receiptStatus ?? 'undelivered',
|
||||
rawStatus: failed.rawStatus ?? data.rawStatus,
|
||||
errorCode: failed.errorCode ?? data.errorCode,
|
||||
errorMessage: failed.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: failed.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
const delivered = received.filter((audit) => audit.receiptStatus === 'delivered');
|
||||
if (delivered.length >= segmentTotal) {
|
||||
const latest = delivered.reduce((current, audit) =>
|
||||
(audit.deliveredAt?.getTime() ?? 0) > (current.deliveredAt?.getTime() ?? 0) ? audit : current);
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'delivered',
|
||||
receiptStatus: 'delivered',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? undefined,
|
||||
errorMessage: undefined,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
if (received.length >= segmentTotal) {
|
||||
const latest = received[received.length - 1];
|
||||
return {
|
||||
terminal: true,
|
||||
segmentTotal,
|
||||
status: 'unknown',
|
||||
receiptStatus: 'unknown',
|
||||
rawStatus: latest.rawStatus ?? data.rawStatus,
|
||||
errorCode: latest.errorCode ?? data.errorCode,
|
||||
errorMessage: latest.errorMessage ?? data.errorMessage,
|
||||
deliveredAt: latest.deliveredAt ?? deliveredAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
terminal: false,
|
||||
segmentTotal,
|
||||
status: 'submitted',
|
||||
receiptStatus: data.receiptStatus,
|
||||
rawStatus: data.rawStatus,
|
||||
errorCode: data.errorCode,
|
||||
errorMessage: data.errorMessage,
|
||||
deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async findMessageByGatewayEvent(messageId?: string, gatewayMessageId?: string) {
|
||||
const conditions = [{ messageId }, gatewayMessageId ? { gatewayMessageId } : undefined].filter(
|
||||
Boolean,
|
||||
@@ -3732,6 +3845,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
message: exactMessage,
|
||||
messageId: exactMessage.messageId,
|
||||
submitRecordId: submitRecord?.id,
|
||||
submitId: submitRecord?.submitId,
|
||||
channelId: submitRecord?.channelId ?? data.channelId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3751,6 +3866,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
message: exactSubmits[0].messageRecord,
|
||||
messageId: exactSubmits[0].messageRecord.messageId,
|
||||
submitRecordId: exactSubmits[0].id,
|
||||
submitId: exactSubmits[0].submitId,
|
||||
channelId: exactSubmits[0].channelId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3758,6 +3875,61 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
|
||||
const incomingChannel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
|
||||
if (!incomingChannel) {
|
||||
throw new NotFoundException('SMS message record not found');
|
||||
}
|
||||
const segmentMatches = await this.smsMessageSegmentAuditDelegate().findMany({
|
||||
where: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
messageRecord: { phoneNumber },
|
||||
},
|
||||
include: { messageRecord: true, submitRecord: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const exactSegmentMatches = segmentMatches.filter((candidate) => candidate.channelId === data.channelId);
|
||||
if (exactSegmentMatches.length === 1 && exactSegmentMatches[0]?.messageRecord) {
|
||||
return {
|
||||
message: exactSegmentMatches[0].messageRecord,
|
||||
messageId: exactSegmentMatches[0].messageRecord.messageId,
|
||||
submitRecordId: exactSegmentMatches[0].submitRecordId ?? undefined,
|
||||
submitId: exactSegmentMatches[0].submitRecord?.submitId ?? exactSegmentMatches[0].submitId,
|
||||
channelId: exactSegmentMatches[0].channelId,
|
||||
};
|
||||
}
|
||||
const sameSupplierSegments = segmentMatches.filter((candidate) =>
|
||||
candidate.channel && this.isSameSupplierConnection(incomingChannel, candidate.channel));
|
||||
if (sameSupplierSegments.length === 1 && sameSupplierSegments[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSegments[0].messageRecord,
|
||||
messageId: sameSupplierSegments[0].messageRecord.messageId,
|
||||
submitRecordId: sameSupplierSegments[0].submitRecordId ?? undefined,
|
||||
submitId: sameSupplierSegments[0].submitRecord?.submitId ?? sameSupplierSegments[0].submitId,
|
||||
channelId: sameSupplierSegments[0].channelId,
|
||||
};
|
||||
}
|
||||
const crossConnectionSubmits = await this.prisma.smsSubmitRecord.findMany({
|
||||
where: {
|
||||
gatewayMessageId: data.gatewayMessageId,
|
||||
messageRecord: { phoneNumber },
|
||||
},
|
||||
include: { messageRecord: true, channel: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
const sameSupplierSubmits = crossConnectionSubmits.filter((candidate) =>
|
||||
candidate.channel && this.isSameSupplierConnection(incomingChannel, candidate.channel));
|
||||
if (sameSupplierSubmits.length === 1 && sameSupplierSubmits[0]?.messageRecord) {
|
||||
return {
|
||||
message: sameSupplierSubmits[0].messageRecord,
|
||||
messageId: sameSupplierSubmits[0].messageRecord.messageId,
|
||||
submitRecordId: sameSupplierSubmits[0].id,
|
||||
submitId: sameSupplierSubmits[0].submitId,
|
||||
channelId: sameSupplierSubmits[0].channelId,
|
||||
};
|
||||
}
|
||||
|
||||
const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date();
|
||||
const submittedAfter = new Date(deliveredAt.getTime() - 72 * 60 * 60 * 1000);
|
||||
const candidates = await this.prisma.smsSubmitRecord.findMany({
|
||||
@@ -3790,12 +3962,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
message: candidates[0].messageRecord,
|
||||
messageId: candidates[0].messageRecord.messageId,
|
||||
submitRecordId: candidates[0].id,
|
||||
submitId: candidates[0].submitId,
|
||||
channelId: candidates[0].channelId,
|
||||
};
|
||||
}
|
||||
|
||||
private receiptEventKey(data: GatewayReceiptEventDto) {
|
||||
private isSameSupplierConnection(
|
||||
left: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
right: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
|
||||
) {
|
||||
return left.account.trim() === right.account.trim()
|
||||
&& left.gatewayHost.trim().toLowerCase() === right.gatewayHost.trim().toLowerCase()
|
||||
&& left.gatewayPort === right.gatewayPort
|
||||
&& left.protocol.trim().toUpperCase() === right.protocol.trim().toUpperCase()
|
||||
&& left.cmppVersion.trim() === right.cmppVersion.trim();
|
||||
}
|
||||
|
||||
private receiptEventKey(data: GatewayReceiptEventDto, channelId = data.channelId) {
|
||||
return createHash('sha256').update([
|
||||
data.channelId,
|
||||
channelId,
|
||||
data.gatewayMessageId,
|
||||
data.phoneNumber?.trim() ?? '',
|
||||
data.receiptStatus,
|
||||
|
||||
Reference in New Issue
Block a user