feat: add access number routing and admin list improvements
This commit is contained in:
@@ -571,6 +571,67 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts only the filled client Src_Id and snapshots the real application extension', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
cmppEnterpriseCode: 'SP0001',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
cmppClientSrcId: '000001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
templateMismatchMode: 'reject',
|
||||
customerUnitPrice: 3,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
srcId: '000001',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true }));
|
||||
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ clientSrcId: '000001', applicationExtension: '0001' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a client Src_Id that does not match the configured fill prefix and extension', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppApplicationExtension: '0001',
|
||||
cmppAccessNumberFillEnabled: true,
|
||||
cmppAccessNumberFillPrefix: '00',
|
||||
cmppClientSrcId: '000001',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
srcId: '0001',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP Src_Id must equal the access number assigned to this application: 000001');
|
||||
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records an unreported CMPP message and returns success before delivering the template failure receipt', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||
@@ -888,6 +949,26 @@ describe('SendChainService', () => {
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
|
||||
});
|
||||
|
||||
it('appends the real application extension to the upstream channel base number', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const queuedMessage = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
|
||||
prisma.smsMessageRecord.findUnique.mockResolvedValue({
|
||||
...queuedMessage,
|
||||
applicationExtension: '0001',
|
||||
clientSrcId: '000001',
|
||||
});
|
||||
const gatewayAdd = jest.fn().mockResolvedValue(undefined);
|
||||
service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined);
|
||||
service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd });
|
||||
|
||||
await service.processSendJob({ messageRecordId: 'record-1' });
|
||||
|
||||
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||
'submit-command',
|
||||
expect.objectContaining({ cmpp: expect.objectContaining({ srcId: '106900000001' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('routes a partially reported signature only through its approved backup channel', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const baseRoute = await prisma.channelRouteRule.findFirst();
|
||||
|
||||
@@ -282,9 +282,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const phones = [...new Set(data.phones ?? [])];
|
||||
const schedule = parseSchedule(data);
|
||||
await this.validateSendResources(data.tenantId, data.applicationId, data.templateId);
|
||||
const messageClassification = await this.resolveTemplateMessageClassification(data.templateId, data.content);
|
||||
const unitPrice = await this.resolveUnitPrice(data.tenantId, data.applicationId);
|
||||
const queuePriority = await this.resolveQueuePriority(data.tenantId, data.applicationId);
|
||||
const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([
|
||||
this.resolveTemplateMessageClassification(data.templateId, data.content),
|
||||
this.resolveUnitPrice(data.tenantId, data.applicationId),
|
||||
this.resolveQueuePriority(data.tenantId, data.applicationId),
|
||||
this.resolveApplicationAccessNumber(data.tenantId, data.applicationId),
|
||||
]);
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
@@ -376,6 +379,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
unitPrice: billing.unitPrice,
|
||||
amountCents: billing.billingUnitsPerMessage * billing.unitPrice,
|
||||
queuePriority,
|
||||
clientSrcId: accessNumber.clientSrcId,
|
||||
applicationExtension: accessNumber.applicationExtension,
|
||||
status: batchStatus === 'ready' ? 'queued' : batchStatus === 'scheduled' ? 'scheduled' : batchStatus,
|
||||
errorMessage: risk.status === 'rejected' ? risk.reason ?? undefined : undefined,
|
||||
})),
|
||||
@@ -1686,6 +1691,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
|
||||
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
const unitPrice = application.customerUnitPrice ?? 0;
|
||||
@@ -1735,6 +1741,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
amountCents: billing.amountCents,
|
||||
queuePriority,
|
||||
cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId),
|
||||
clientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
status: 'validating',
|
||||
},
|
||||
});
|
||||
@@ -1966,6 +1974,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuePriority?: string | null;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
template?: { signature?: { id?: string | null; name?: string | null } | null } | null;
|
||||
signature?: { id?: string | null; name?: string | null } | null;
|
||||
},
|
||||
@@ -1973,6 +1983,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
attempt: number,
|
||||
) {
|
||||
const channel = routed.channel;
|
||||
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
|
||||
await this.ensureSignatureReportedForChannel(message, channel.id);
|
||||
await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond);
|
||||
const submitId = `SUB-${randomUUID()}`;
|
||||
@@ -2039,7 +2050,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
serviceId: channel.config && typeof channel.config === 'object' && 'serviceId' in channel.config
|
||||
? String(channel.config.serviceId)
|
||||
: 'SMS',
|
||||
srcId: channel.srcId,
|
||||
srcId: upstreamSrcId,
|
||||
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
|
||||
registeredDelivery: 1,
|
||||
msgFmt: 8,
|
||||
@@ -2073,6 +2084,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
content: string;
|
||||
billingUnits: number;
|
||||
queuedAt?: Date;
|
||||
clientSrcId?: string | null;
|
||||
applicationExtension?: string | null;
|
||||
},
|
||||
reason: string,
|
||||
) {
|
||||
@@ -2238,6 +2251,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
return normalizeQueuePriority(application.queuePriority);
|
||||
}
|
||||
|
||||
private async resolveApplicationAccessNumber(tenantId: string, applicationId?: string) {
|
||||
if (!applicationId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: { tenantId: true, cmppClientSrcId: true, cmppApplicationExtension: true },
|
||||
});
|
||||
if (!application || application.tenantId !== tenantId) {
|
||||
return { clientSrcId: null, applicationExtension: null };
|
||||
}
|
||||
return {
|
||||
clientSrcId: application.cmppClientSrcId,
|
||||
applicationExtension: application.cmppApplicationExtension,
|
||||
};
|
||||
}
|
||||
|
||||
private findInboundApplication(account: string) {
|
||||
return this.prisma.smsApplication.findFirst({
|
||||
where: { cmppAccount: account },
|
||||
@@ -3096,6 +3126,39 @@ function isProvinceChannel(item: { province?: string | null; channel: { sendRegi
|
||||
return itemProvince === target || sendRegion === target;
|
||||
}
|
||||
|
||||
function validateInboundApplicationSrcId(
|
||||
srcId: string | undefined,
|
||||
application: {
|
||||
cmppApplicationExtension?: string | null;
|
||||
cmppAccessNumberFillEnabled?: boolean | null;
|
||||
cmppAccessNumberFillPrefix?: string | null;
|
||||
cmppClientSrcId?: string | null;
|
||||
},
|
||||
) {
|
||||
const submittedSrcId = srcId?.trim() ?? '';
|
||||
const applicationExtension = application.cmppApplicationExtension?.trim() ?? '';
|
||||
if (!applicationExtension) {
|
||||
return submittedSrcId || null;
|
||||
}
|
||||
|
||||
const fillPrefix = application.cmppAccessNumberFillEnabled
|
||||
? application.cmppAccessNumberFillPrefix?.trim() ?? ''
|
||||
: '';
|
||||
const expectedSrcId = application.cmppClientSrcId?.trim() || `${fillPrefix}${applicationExtension}`;
|
||||
if (!submittedSrcId || submittedSrcId !== expectedSrcId) {
|
||||
throw new BadRequestException(`CMPP Src_Id must equal the access number assigned to this application: ${expectedSrcId}`);
|
||||
}
|
||||
return submittedSrcId;
|
||||
}
|
||||
|
||||
function composeUpstreamSrcId(baseSrcId: string, applicationExtension?: string | null) {
|
||||
const upstreamSrcId = `${baseSrcId.trim()}${applicationExtension?.trim() ?? ''}`;
|
||||
if (upstreamSrcId.length > 21) {
|
||||
throw new BadRequestException('channel base access number plus application extension must not exceed 21 digits');
|
||||
}
|
||||
return upstreamSrcId;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
|
||||
Reference in New Issue
Block a user