feat: complete phase2 baseline cdr quality rbac

This commit is contained in:
hectorzhao
2026-06-24 18:40:21 +08:00
parent 7057fd3c42
commit a86de6545f
63 changed files with 7853 additions and 3678 deletions
@@ -44,6 +44,15 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
name: 'Seed IP Gateway',
authMode: 'IP',
sourceIp: '100.93.185.30',
sourceIps: ['100.93.185.30', '100.93.185.31'],
lineGroupId: 'llg_seed',
lineGroupName: 'Seed Line Group',
billingCycleSec: 6,
cycleRate: '0.010000',
callerMatchMode: 'PREFIXES',
callerPrefixes: ['021'],
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixes: [{ id: 'bp_seed', prefix: '671', name: 'Seed Prefix' }],
hasSipCredential: false,
policyCount: 1
})
@@ -64,9 +73,18 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
customerId: input.customerId,
name: input.name,
authMode: input.authMode,
sourceIp: input.sourceIp ?? null,
sourceIp: input.sourceIps?.[0] ?? null,
sourceIps: input.sourceIps ?? [],
sipUsername: input.sipUsername ?? null,
sipDomain: input.sipDomain ?? null,
lineGroupId: input.lineGroupId,
lineGroupName: 'Created Line Group',
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate,
callerMatchMode: input.callerMatchMode,
callerPrefixes: input.callerPrefixes,
calleeMatchMode: input.calleeMatchMode,
businessPrefixes: input.businessPrefixIds.map((id) => ({ id, prefix: '671', name: 'Seed Prefix' })),
hasSipCredential: Boolean(input.sipHa1)
});
this.gateways.set(gateway.id, gateway);
@@ -80,9 +98,19 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
customerId: input.customerId ?? current.customerId,
name: input.name ?? current.name,
authMode: input.authMode ?? current.authMode,
sourceIp: input.sourceIp === undefined ? current.sourceIp : input.sourceIp,
sourceIp: input.sourceIps === undefined ? current.sourceIp : input.sourceIps[0] ?? null,
sourceIps: input.sourceIps === undefined ? current.sourceIps : input.sourceIps,
sipUsername: input.sipUsername === undefined ? current.sipUsername : input.sipUsername,
sipDomain: input.sipDomain === undefined ? current.sipDomain : input.sipDomain,
lineGroupId: input.lineGroupId ?? current.lineGroupId,
lineGroupName: input.lineGroupId === undefined ? current.lineGroupName : 'Updated Line Group',
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
cycleRate: input.cycleRate ?? current.cycleRate,
callerMatchMode: input.callerMatchMode ?? current.callerMatchMode,
callerPrefixes: input.callerPrefixes ?? current.callerPrefixes,
calleeMatchMode: input.calleeMatchMode ?? current.calleeMatchMode,
businessPrefixes:
input.businessPrefixIds === undefined ? current.businessPrefixes : input.businessPrefixIds.map((id) => ({ id, prefix: '671', name: 'Seed Prefix' })),
hasSipCredential: input.sipHa1 === undefined ? current.hasSipCredential : Boolean(input.sipHa1),
updatedAt: new Date('2026-06-21T03:00:00.000Z')
};
@@ -110,8 +138,17 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
name: string;
authMode?: 'IP' | 'SIP_DIGEST' | 'MIXED';
sourceIp?: string | null;
sourceIps?: string[];
sipUsername?: string | null;
sipDomain?: string | null;
lineGroupId?: string | null;
lineGroupName?: string | null;
billingCycleSec?: number;
cycleRate?: string;
callerMatchMode?: 'ANY' | 'PREFIXES';
callerPrefixes?: string[];
calleeMatchMode?: 'ANY' | 'BUSINESS_PREFIXES';
businessPrefixes?: Array<{ id: string; prefix: string; name: string }>;
hasSipCredential?: boolean;
status?: CustomerGatewayStatus;
policyCount?: number;
@@ -123,8 +160,17 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
name: input.name,
authMode: input.authMode ?? 'IP',
sourceIp: input.sourceIp ?? null,
sourceIps: input.sourceIps ?? (input.sourceIp ? [input.sourceIp] : []),
sipUsername: input.sipUsername ?? null,
sipDomain: input.sipDomain ?? null,
lineGroupId: input.lineGroupId ?? 'llg_seed',
lineGroupName: input.lineGroupName ?? 'Seed Line Group',
billingCycleSec: input.billingCycleSec ?? 60,
cycleRate: input.cycleRate ?? '0.000000',
callerMatchMode: input.callerMatchMode ?? 'ANY',
callerPrefixes: input.callerPrefixes ?? [],
calleeMatchMode: input.calleeMatchMode ?? 'ANY',
businessPrefixes: input.businessPrefixes ?? [],
hasSipCredential: input.hasSipCredential ?? false,
status: input.status ?? 'ENABLED',
policyCount: input.policyCount ?? 0,
@@ -208,6 +254,13 @@ describe('S13 customer gateways API', () => {
id: 'cgw_seed',
authMode: 'IP',
sourceIp: '100.93.185.30',
sourceIps: ['100.93.185.30', '100.93.185.31'],
lineGroupId: 'llg_seed',
billingCycleSec: 6,
cycleRate: '0.010000',
callerMatchMode: 'PREFIXES',
callerPrefixes: ['021'],
calleeMatchMode: 'BUSINESS_PREFIXES',
policyCount: 1
});
expect(JSON.stringify(response.body)).not.toContain('sipPassword');
@@ -232,7 +285,13 @@ describe('S13 customer gateways API', () => {
authMode: 'SIP_DIGEST',
sipUsername: 'alice-gw',
sipDomain: 'SIP.EXAMPLE.LOCAL',
sipPassword: 'change-me-very-strong'
sipPassword: 'change-me-very-strong',
lineGroupId: 'llg_seed',
billingCycleSec: 60,
cycleRate: '0.020000',
callerMatchMode: 'ANY',
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixIds: ['bp_seed']
})
.expect(201);
@@ -242,6 +301,10 @@ describe('S13 customer gateways API', () => {
sipUsername: 'alice-gw',
sipDomain: 'sip.example.local',
sourceIp: null,
sourceIps: [],
lineGroupId: 'llg_seed',
cycleRate: '0.020000',
businessPrefixes: [{ id: 'bp_seed', prefix: '671', name: 'Seed Prefix' }],
hasSipCredential: true
});
expect(response.body.sipPassword).toBeUndefined();
@@ -262,12 +325,13 @@ describe('S13 customer gateways API', () => {
await request(app.getHttpServer())
.patch('/api/v2/customer-gateways/cgw_created')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ authMode: 'IP', sourceIp: '100.93.185.32' })
.send({ authMode: 'IP', sourceIps: ['100.93.185.32', '100.93.185.33'], lineGroupId: 'llg_seed' })
.expect(200)
.expect((response) => {
expect(response.body).toMatchObject({
authMode: 'IP',
sourceIp: '100.93.185.32',
sourceIps: ['100.93.185.32', '100.93.185.33'],
sipUsername: null,
sipDomain: null,
hasSipCredential: false
@@ -5,6 +5,14 @@ import { PrismaService } from '../database/prisma.service.js';
export type CustomerGatewayStatus = 'ENABLED' | 'DISABLED';
export type CustomerGatewayAuthMode = 'IP' | 'SIP_DIGEST' | 'MIXED';
export type CustomerGatewayCallerMatchMode = 'ANY' | 'PREFIXES';
export type CustomerGatewayCalleeMatchMode = 'ANY' | 'BUSINESS_PREFIXES';
export interface CustomerGatewayBusinessPrefixSummary {
id: string;
prefix: string;
name: string;
}
export interface CustomerGatewaySummary {
id: string;
@@ -13,9 +21,18 @@ export interface CustomerGatewaySummary {
name: string;
authMode: CustomerGatewayAuthMode;
sourceIp: string | null;
sourceIps: string[];
sipUsername: string | null;
sipDomain: string | null;
hasSipCredential: boolean;
lineGroupId: string | null;
lineGroupName: string | null;
billingCycleSec: number;
cycleRate: string;
callerMatchMode: CustomerGatewayCallerMatchMode;
callerPrefixes: string[];
calleeMatchMode: CustomerGatewayCalleeMatchMode;
businessPrefixes: CustomerGatewayBusinessPrefixSummary[];
status: CustomerGatewayStatus;
policyCount: number;
createdAt: Date;
@@ -26,10 +43,17 @@ export interface CreateCustomerGatewayInput {
customerId: string;
name: string;
authMode: CustomerGatewayAuthMode;
sourceIp?: string | null;
sourceIps?: string[];
sipUsername?: string | null;
sipDomain?: string | null;
sipHa1?: string;
lineGroupId: string;
billingCycleSec: number;
cycleRate: string;
callerMatchMode: CustomerGatewayCallerMatchMode;
callerPrefixes: string[];
calleeMatchMode: CustomerGatewayCalleeMatchMode;
businessPrefixIds: string[];
actorId?: string;
}
@@ -37,10 +61,17 @@ export interface UpdateCustomerGatewayInput {
customerId?: string;
name?: string;
authMode?: CustomerGatewayAuthMode;
sourceIp?: string | null;
sourceIps?: string[];
sipUsername?: string | null;
sipDomain?: string | null;
sipHa1?: string | null;
lineGroupId?: string;
billingCycleSec?: number;
cycleRate?: string;
callerMatchMode?: CustomerGatewayCallerMatchMode;
callerPrefixes?: string[];
calleeMatchMode?: CustomerGatewayCalleeMatchMode;
businessPrefixIds?: string[];
actorId?: string;
}
@@ -63,6 +94,10 @@ function outboxId(): string {
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
}
function childId(prefix: string): string {
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`;
}
@Injectable()
export class PrismaCustomerGatewaysRepository implements CustomerGatewaysRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
@@ -86,6 +121,8 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
async create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary> {
await this.ensureCustomerExists(input.customerId);
await this.ensureLineGroupExists(input.lineGroupId);
await this.ensureBusinessPrefixesExist(input.businessPrefixIds);
try {
const gateway = await this.prisma.$transaction(async (tx) => {
@@ -95,17 +132,22 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
customerId: input.customerId,
name: input.name,
authMode: input.authMode,
sourceIp: input.sourceIp,
sourceIp: input.sourceIps?.[0] ?? null,
sipUsername: input.sipUsername,
sipDomain: input.sipDomain,
sipHa1: input.sipHa1,
lineGroupId: input.lineGroupId,
billingCycleSec: input.billingCycleSec,
cycleRate: new Prisma.Decimal(input.cycleRate),
callerMatchMode: input.callerMatchMode,
calleeMatchMode: input.calleeMatchMode,
createdBy: input.actorId,
updatedBy: input.actorId
},
include: this.includeSummary()
}
});
await this.replaceChildConfig(tx, created.id, input);
await this.enqueueConfigOutbox(tx, created.id, 'customer_gateway.changed');
return created;
return this.findActiveOrThrowInTx(tx, created.id);
});
return this.toSummary(gateway);
@@ -120,6 +162,12 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
if (input.customerId) {
await this.ensureCustomerExists(input.customerId);
}
if (input.lineGroupId) {
await this.ensureLineGroupExists(input.lineGroupId);
}
if (input.businessPrefixIds) {
await this.ensureBusinessPrefixesExist(input.businessPrefixIds);
}
try {
const gateway = await this.prisma.$transaction(async (tx) => {
@@ -129,17 +177,22 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
customerId: input.customerId,
name: input.name,
authMode: input.authMode,
sourceIp: input.sourceIp,
sourceIp: input.sourceIps === undefined ? undefined : input.sourceIps[0] ?? null,
sipUsername: input.sipUsername,
sipDomain: input.sipDomain,
sipHa1: input.sipHa1,
lineGroupId: input.lineGroupId,
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate),
callerMatchMode: input.callerMatchMode,
calleeMatchMode: input.calleeMatchMode,
updatedBy: input.actorId,
version: { increment: 1 }
},
include: this.includeSummary()
}
});
await this.replaceChildConfig(tx, updated.id, input);
await this.enqueueConfigOutbox(tx, updated.id, 'customer_gateway.changed');
return updated;
return this.findActiveOrThrowInTx(tx, updated.id);
});
return this.toSummary(gateway);
@@ -181,6 +234,14 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
updatedBy: actorId
}
});
await tx.customerGatewayIp.updateMany({
where: { gatewayId: gatewayIdValue, deletedAt: null },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId
}
});
const deleted = await tx.customerGateway.update({
where: { id: gatewayIdValue },
data: {
@@ -224,6 +285,34 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
}
}
private async ensureLineGroupExists(lineGroupId: string): Promise<void> {
const lineGroup = await this.prisma.landingLineGroup.findUnique({
where: { id: lineGroupId },
select: { id: true, deletedAt: true }
});
if (!lineGroup || lineGroup.deletedAt) {
throw new NotFoundException({ code: 'LINE_GROUP_NOT_FOUND', message: 'Landing line group not found.' });
}
}
private async ensureBusinessPrefixesExist(businessPrefixIds: string[]): Promise<void> {
if (businessPrefixIds.length === 0) {
return;
}
const count = await this.prisma.businessPrefix.count({
where: {
id: { in: businessPrefixIds },
deletedAt: null
}
});
if (count !== new Set(businessPrefixIds).size) {
throw new NotFoundException({ code: 'BUSINESS_PREFIX_NOT_FOUND', message: 'Business prefix not found.' });
}
}
private async findActiveOrThrow(gatewayIdValue: string) {
const gateway = await this.prisma.customerGateway.findUnique({
where: { id: gatewayIdValue },
@@ -237,6 +326,74 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
return gateway;
}
private async findActiveOrThrowInTx(tx: Prisma.TransactionClient, gatewayIdValue: string) {
const gateway = await tx.customerGateway.findUnique({
where: { id: gatewayIdValue },
include: this.includeSummary()
});
if (!gateway || gateway.deletedAt) {
throw new NotFoundException({ code: 'CUSTOMER_GATEWAY_NOT_FOUND', message: 'Customer gateway not found.' });
}
return gateway;
}
private async replaceChildConfig(
tx: Prisma.TransactionClient,
gatewayIdValue: string,
input: {
sourceIps?: string[];
callerPrefixes?: string[];
businessPrefixIds?: string[];
actorId?: string;
}
): Promise<void> {
if (input.sourceIps !== undefined) {
await tx.customerGatewayIp.deleteMany({ where: { gatewayId: gatewayIdValue } });
if (input.sourceIps.length) {
await tx.customerGatewayIp.createMany({
data: input.sourceIps.map((sourceIp) => ({
id: childId('cgip'),
gatewayId: gatewayIdValue,
sourceIp,
createdBy: input.actorId,
updatedBy: input.actorId
}))
});
}
}
if (input.callerPrefixes !== undefined) {
await tx.customerGatewayCallerPrefix.deleteMany({ where: { gatewayId: gatewayIdValue } });
if (input.callerPrefixes.length) {
await tx.customerGatewayCallerPrefix.createMany({
data: input.callerPrefixes.map((prefix, index) => ({
id: childId('cgcp'),
gatewayId: gatewayIdValue,
prefix,
priority: (index + 1) * 10,
createdBy: input.actorId
}))
});
}
}
if (input.businessPrefixIds !== undefined) {
await tx.customerGatewayBusinessPrefix.deleteMany({ where: { gatewayId: gatewayIdValue } });
if (input.businessPrefixIds.length) {
await tx.customerGatewayBusinessPrefix.createMany({
data: input.businessPrefixIds.map((businessPrefixId) => ({
id: childId('cgbp'),
gatewayId: gatewayIdValue,
businessPrefixId,
createdBy: input.actorId
}))
});
}
}
}
private includeSummary() {
return {
customer: {
@@ -245,6 +402,31 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
name: true
}
},
lineGroup: {
select: {
id: true,
name: true
}
},
ips: {
where: { deletedAt: null },
orderBy: [{ createdAt: 'asc' }]
},
callerPrefixes: {
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }]
},
businessPrefixes: {
include: {
businessPrefix: {
select: {
id: true,
prefix: true,
name: true
}
}
},
orderBy: [{ createdAt: 'asc' }]
},
_count: {
select: {
policies: {
@@ -261,9 +443,18 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
name: string;
authMode: CustomerGatewayAuthMode;
sourceIp: string | null;
ips: Array<{ sourceIp: string }>;
sipUsername: string | null;
sipDomain: string | null;
sipHa1: string | null;
lineGroupId: string | null;
lineGroup: { name: string } | null;
billingCycleSec: number;
cycleRate: Prisma.Decimal;
callerMatchMode: CustomerGatewayCallerMatchMode;
callerPrefixes: Array<{ prefix: string }>;
calleeMatchMode: CustomerGatewayCalleeMatchMode;
businessPrefixes: Array<{ businessPrefix: CustomerGatewayBusinessPrefixSummary }>;
status: CustomerGatewayStatus;
createdAt: Date;
updatedAt: Date;
@@ -277,9 +468,18 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
name: gateway.name,
authMode: gateway.authMode,
sourceIp: gateway.sourceIp,
sourceIps: gateway.ips.length ? gateway.ips.map((item) => item.sourceIp) : gateway.sourceIp ? [gateway.sourceIp] : [],
sipUsername: gateway.sipUsername,
sipDomain: gateway.sipDomain,
hasSipCredential: Boolean(gateway.sipHa1),
lineGroupId: gateway.lineGroupId,
lineGroupName: gateway.lineGroup?.name ?? null,
billingCycleSec: gateway.billingCycleSec,
cycleRate: gateway.cycleRate.toFixed(6),
callerMatchMode: gateway.callerMatchMode,
callerPrefixes: gateway.callerPrefixes.map((item) => item.prefix),
calleeMatchMode: gateway.calleeMatchMode,
businessPrefixes: gateway.businessPrefixes.map((item) => item.businessPrefix),
status: gateway.status,
policyCount: gateway._count.policies,
createdAt: gateway.createdAt,
@@ -5,6 +5,8 @@ import {
CUSTOMER_GATEWAYS_REPOSITORY,
type CreateCustomerGatewayInput,
type CustomerGatewayAuthMode,
type CustomerGatewayCalleeMatchMode,
type CustomerGatewayCallerMatchMode,
type CustomerGatewaySummary,
type CustomerGatewaysRepository,
type UpdateCustomerGatewayInput
@@ -15,9 +17,17 @@ interface CreateCustomerGatewayDto {
name?: unknown;
authMode?: unknown;
sourceIp?: unknown;
sourceIps?: unknown;
sipUsername?: unknown;
sipDomain?: unknown;
sipPassword?: unknown;
lineGroupId?: unknown;
billingCycleSec?: unknown;
cycleRate?: unknown;
callerMatchMode?: unknown;
callerPrefixes?: unknown;
calleeMatchMode?: unknown;
businessPrefixIds?: unknown;
}
interface UpdateCustomerGatewayDto {
@@ -25,9 +35,17 @@ interface UpdateCustomerGatewayDto {
name?: unknown;
authMode?: unknown;
sourceIp?: unknown;
sourceIps?: unknown;
sipUsername?: unknown;
sipDomain?: unknown;
sipPassword?: unknown;
lineGroupId?: unknown;
billingCycleSec?: unknown;
cycleRate?: unknown;
callerMatchMode?: unknown;
callerPrefixes?: unknown;
calleeMatchMode?: unknown;
businessPrefixIds?: unknown;
}
@Injectable()
@@ -47,14 +65,26 @@ export class CustomerGatewaysService {
const authMode = this.authMode(body.authMode);
const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain);
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
const callerMatchMode = body.callerMatchMode === undefined ? 'ANY' : this.callerMatchMode(body.callerMatchMode);
const calleeMatchMode = body.calleeMatchMode === undefined ? 'ANY' : this.calleeMatchMode(body.calleeMatchMode);
const sourceIps = this.normalizeSourceIps(authMode, body.sourceIps ?? body.sourceIp);
const callerPrefixes = this.normalizeCallerPrefixes(callerMatchMode, body.callerPrefixes);
const businessPrefixIds = this.normalizeBusinessPrefixIds(calleeMatchMode, body.businessPrefixIds);
const input: CreateCustomerGatewayInput = {
customerId: this.limitedString(body.customerId, 'customerId', 32),
name: this.limitedString(body.name, 'name', 120),
authMode,
sourceIp: this.normalizeSourceIp(authMode, body.sourceIp),
sourceIps,
sipUsername: sipIdentity.sipUsername,
sipDomain: sipIdentity.sipDomain,
sipHa1: sipPassword ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, sipPassword) : undefined,
lineGroupId: this.limitedString(body.lineGroupId, 'lineGroupId', 32),
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 86_400),
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds,
actorId
};
@@ -67,9 +97,19 @@ export class CustomerGatewaysService {
const requestedSipUsername = body.sipUsername === undefined ? current.sipUsername : this.nullableString(body.sipUsername, 'sipUsername', 120);
const requestedSipDomain = body.sipDomain === undefined ? current.sipDomain : this.nullableString(body.sipDomain, 'sipDomain', 160);
const sipIdentity = this.normalizeSipIdentity(authMode, requestedSipUsername, requestedSipDomain);
const sourceIp = body.sourceIp === undefined ? current.sourceIp : this.nullableString(body.sourceIp, 'sourceIp', 45);
const normalizedSourceIp = this.normalizeSourceIp(authMode, sourceIp);
const sourceIps =
body.sourceIps === undefined && body.sourceIp === undefined
? current.sourceIps
: this.normalizeSourceIps(authMode, body.sourceIps ?? body.sourceIp);
const password = body.sipPassword === undefined ? undefined : this.requiredSipPassword(authMode, body.sipPassword);
const callerMatchMode = body.callerMatchMode === undefined ? current.callerMatchMode : this.callerMatchMode(body.callerMatchMode);
const calleeMatchMode = body.calleeMatchMode === undefined ? current.calleeMatchMode : this.calleeMatchMode(body.calleeMatchMode);
const callerPrefixes =
body.callerPrefixes === undefined ? current.callerPrefixes : this.normalizeCallerPrefixes(callerMatchMode, body.callerPrefixes);
const businessPrefixIds =
body.businessPrefixIds === undefined
? current.businessPrefixes.map((item) => item.id)
: this.normalizeBusinessPrefixIds(calleeMatchMode, body.businessPrefixIds);
if (this.requiresSip(authMode)) {
const identityChanged = sipIdentity.sipUsername !== current.sipUsername || sipIdentity.sipDomain !== current.sipDomain;
@@ -85,10 +125,18 @@ export class CustomerGatewaysService {
customerId: body.customerId === undefined ? undefined : this.limitedString(body.customerId, 'customerId', 32),
name: body.name === undefined ? undefined : this.limitedString(body.name, 'name', 120),
authMode,
sourceIp: normalizedSourceIp,
sourceIps,
sipUsername: sipIdentity.sipUsername,
sipDomain: sipIdentity.sipDomain,
sipHa1: password ? this.sipHa1(sipIdentity.sipUsername, sipIdentity.sipDomain, password) : authMode === 'IP' ? null : undefined,
lineGroupId: body.lineGroupId === undefined ? undefined : this.limitedString(body.lineGroupId, 'lineGroupId', 32),
billingCycleSec:
body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 86_400),
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
callerMatchMode,
callerPrefixes,
calleeMatchMode,
businessPrefixIds,
actorId
};
@@ -136,17 +184,26 @@ export class CustomerGatewaysService {
return value;
}
private normalizeSourceIp(authMode: CustomerGatewayAuthMode, value: unknown): string | null {
private normalizeSourceIps(authMode: CustomerGatewayAuthMode, value: unknown): string[] {
if (!this.requiresIp(authMode)) {
return null;
return [];
}
const sourceIp = this.limitedString(value, 'sourceIp', 45);
if (net.isIP(sourceIp) === 0) {
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIp must be an IPv4 or IPv6 address.' });
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,\s]+/) : [];
const sourceIps = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
if (sourceIps.length === 0) {
throw new BadRequestException({ code: 'SOURCE_IP_REQUIRED', message: 'At least one source IP is required.' });
}
if (sourceIps.length > 20) {
throw new BadRequestException({ code: 'SOURCE_IP_TOO_MANY', message: 'sourceIps supports up to 20 IP addresses.' });
}
for (const sourceIp of sourceIps) {
if (sourceIp.length > 45 || net.isIP(sourceIp) === 0) {
throw new BadRequestException({ code: 'SOURCE_IP_INVALID', message: 'sourceIps must be IPv4 or IPv6 addresses.' });
}
}
return sourceIp;
return sourceIps;
}
private normalizeSipIdentity(authMode: CustomerGatewayAuthMode, usernameValue: unknown, domainValue: unknown) {
@@ -197,4 +254,76 @@ export class CustomerGatewaysService {
private requiresSip(authMode: CustomerGatewayAuthMode): boolean {
return authMode === 'SIP_DIGEST' || authMode === 'MIXED';
}
private callerMatchMode(value: unknown): CustomerGatewayCallerMatchMode {
if (value !== 'ANY' && value !== 'PREFIXES') {
throw new BadRequestException({ code: 'CALLER_MATCH_MODE_INVALID', message: 'callerMatchMode is invalid.' });
}
return value;
}
private calleeMatchMode(value: unknown): CustomerGatewayCalleeMatchMode {
if (value !== 'ANY' && value !== 'BUSINESS_PREFIXES') {
throw new BadRequestException({ code: 'CALLEE_MATCH_MODE_INVALID', message: 'calleeMatchMode is invalid.' });
}
return value;
}
private normalizeCallerPrefixes(mode: CustomerGatewayCallerMatchMode, value: unknown): string[] {
if (mode === 'ANY') {
return [];
}
const prefixes = this.stringList(value, 'callerPrefixes', 20, 64);
if (prefixes.length === 0) {
throw new BadRequestException({ code: 'CALLER_PREFIX_REQUIRED', message: 'callerPrefixes is required.' });
}
for (const prefix of prefixes) {
if (!/^[A-Za-z0-9+*#._-]+$/.test(prefix)) {
throw new BadRequestException({ code: 'CALLER_PREFIX_INVALID', message: 'callerPrefixes contains invalid characters.' });
}
}
return prefixes;
}
private normalizeBusinessPrefixIds(mode: CustomerGatewayCalleeMatchMode, value: unknown): string[] {
if (mode === 'ANY') {
return [];
}
const ids = this.stringList(value, 'businessPrefixIds', 50, 32);
if (ids.length === 0) {
throw new BadRequestException({ code: 'BUSINESS_PREFIX_REQUIRED', message: 'businessPrefixIds is required.' });
}
return ids;
}
private stringList(value: unknown, field: string, maxItems: number, maxLength: number): string[] {
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,\s]+/) : [];
const items = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
if (items.length > maxItems) {
throw new BadRequestException({ code: 'LIST_TOO_LARGE', message: `${field} supports up to ${maxItems} items.` });
}
for (const item of items) {
if (item.length > maxLength) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} item is too long.` });
}
}
return items;
}
private integer(value: unknown, field: string, min: number, max: number): number {
const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} must be an integer between ${min} and ${max}.` });
}
return parsed;
}
private money(value: unknown, field: string): string {
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-negative decimal with up to 6 places.` });
}
const [integerPart, fractionPart = ''] = raw.split('.');
return `${integerPart}.${fractionPart.padEnd(6, '0')}`;
}
}