feat: complete phase2 baseline cdr quality rbac
This commit is contained in:
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user