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
+2
View File
@@ -9,6 +9,7 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
import { AuditModule } from './audit/audit.module.js';
import { ActiveCallsModule } from './active-calls/active-calls.module.js';
import { AuthModule } from './auth/auth.module.js';
import { BusinessPrefixesModule } from './business-prefixes/business-prefixes.module.js';
import { CdrsModule } from './cdrs/cdrs.module.js';
import { CustomerGatewayPoliciesModule } from './customer-gateway-policies/customer-gateway-policies.module.js';
import { CustomerGatewaysModule } from './customer-gateways/customer-gateways.module.js';
@@ -59,6 +60,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
AuditModule,
AuthModule,
ActiveCallsModule,
BusinessPrefixesModule,
CdrsModule,
DashboardModule,
CustomersModule,
+6 -3
View File
@@ -23,7 +23,8 @@ class E2eAuthRepository implements AuthRepository {
failedLoginCount: 0,
lockedUntil: null,
requirePasswordChange: false,
roles: ['admin']
roles: ['admin'],
permissions: ['dashboard.view', 'customers.view']
};
}
@@ -32,7 +33,7 @@ class E2eAuthRepository implements AuthRepository {
}
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
return username === this.user.username ? { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] } : null;
}
async markLoginSuccess(): Promise<void> {
@@ -52,7 +53,7 @@ class E2eAuthRepository implements AuthRepository {
refreshTokenHash: input.refreshTokenHash,
expiresAt: input.expiresAt,
revokedAt: null,
user: { ...this.user, roles: [...this.user.roles] }
user: { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] }
};
this.sessions.set(session.id, session);
@@ -130,6 +131,7 @@ describe('LisgloSIPS Auth API', () => {
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
expect(login.body.accessToken).toBeTypeOf('string');
expect(login.body.user.permissions).toEqual(['dashboard.view', 'customers.view']);
expect(loginCookie).toContain('HttpOnly');
expect([...repo.sessions.values()][0].refreshTokenHash).toBe(sha256Token(decodeURIComponent(firstRefreshToken)));
@@ -137,6 +139,7 @@ describe('LisgloSIPS Auth API', () => {
const refreshCookie = refresh.headers['set-cookie'][0];
expect(refresh.body.accessToken).toBeTypeOf('string');
expect(refresh.body.user.permissions).toEqual(['dashboard.view', 'customers.view']);
expect([...repo.sessions.values()][0].revokedAt).toBeInstanceOf(Date);
await request(app.getHttpServer()).post('/api/v2/auth/refresh').set('Cookie', loginCookie).expect(401);
+58 -39
View File
@@ -1,5 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../database/prisma.service.js';
import type { AuthRepository, AuthSessionRecord, AuthUserRecord, CreateSessionInput } from './auth.types.js';
@@ -7,6 +8,45 @@ function sessionId(): string {
return `ses_${crypto.randomUUID().replaceAll('-', '')}`;
}
type AuthUserWithRoles = Prisma.UserGetPayload<{
include: {
userRoles: {
include: {
role: {
include: {
permissions: true;
};
};
};
};
};
}>;
function toAuthUserRecord(user: AuthUserWithRoles): AuthUserRecord {
const enabledRoles = user.userRoles.map((userRole) => userRole.role).filter((role) => role.status === 'ENABLED' && !role.deletedAt);
const permissions = new Set<string>();
for (const role of enabledRoles) {
for (const rolePermission of role.permissions) {
permissions.add(rolePermission.permissionId);
}
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
passwordHash: user.passwordHash,
passwordAlgo: user.passwordAlgo,
status: user.status,
failedLoginCount: user.failedLoginCount,
lockedUntil: user.lockedUntil,
requirePasswordChange: user.requirePasswordChange,
roles: enabledRoles.map((role) => role.name),
permissions: [...permissions].sort()
};
}
@Injectable()
export class PrismaAuthRepository implements AuthRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
@@ -17,7 +57,11 @@ export class PrismaAuthRepository implements AuthRepository {
include: {
userRoles: {
include: {
role: true
role: {
include: {
permissions: true
}
}
}
}
}
@@ -27,18 +71,7 @@ export class PrismaAuthRepository implements AuthRepository {
return null;
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
passwordHash: user.passwordHash,
passwordAlgo: user.passwordAlgo,
status: user.status,
failedLoginCount: user.failedLoginCount,
lockedUntil: user.lockedUntil,
requirePasswordChange: user.requirePasswordChange,
roles: user.userRoles.map((userRole) => userRole.role.name)
};
return toAuthUserRecord(user);
}
async markLoginSuccess(userId: string, ip?: string): Promise<void> {
@@ -79,7 +112,11 @@ export class PrismaAuthRepository implements AuthRepository {
include: {
userRoles: {
include: {
role: true
role: {
include: {
permissions: true
}
}
}
}
}
@@ -93,18 +130,7 @@ export class PrismaAuthRepository implements AuthRepository {
refreshTokenHash: session.refreshTokenHash,
expiresAt: session.expiresAt,
revokedAt: session.revokedAt,
user: {
id: session.user.id,
username: session.user.username,
displayName: session.user.displayName,
passwordHash: session.user.passwordHash,
passwordAlgo: session.user.passwordAlgo,
status: session.user.status,
failedLoginCount: session.user.failedLoginCount,
lockedUntil: session.user.lockedUntil,
requirePasswordChange: session.user.requirePasswordChange,
roles: session.user.userRoles.map((userRole) => userRole.role.name)
}
user: toAuthUserRecord(session.user)
};
}
@@ -116,7 +142,11 @@ export class PrismaAuthRepository implements AuthRepository {
include: {
userRoles: {
include: {
role: true
role: {
include: {
permissions: true
}
}
}
}
}
@@ -134,18 +164,7 @@ export class PrismaAuthRepository implements AuthRepository {
refreshTokenHash: session.refreshTokenHash,
expiresAt: session.expiresAt,
revokedAt: session.revokedAt,
user: {
id: session.user.id,
username: session.user.username,
displayName: session.user.displayName,
passwordHash: session.user.passwordHash,
passwordAlgo: session.user.passwordAlgo,
status: session.user.status,
failedLoginCount: session.user.failedLoginCount,
lockedUntil: session.user.lockedUntil,
requirePasswordChange: session.user.requirePasswordChange,
roles: session.user.userRoles.map((userRole) => userRole.role.name)
}
user: toAuthUserRecord(session.user)
};
}
@@ -20,7 +20,8 @@ class MemoryAuthRepository implements AuthRepository {
failedLoginCount: 0,
lockedUntil: null,
requirePasswordChange: false,
roles: ['admin']
roles: ['admin'],
permissions: ['dashboard.view', 'customers.view']
};
}
@@ -34,7 +35,7 @@ class MemoryAuthRepository implements AuthRepository {
}
async findUserByUsername(username: string): Promise<AuthUserRecord | null> {
return username === this.user.username ? { ...this.user, roles: [...this.user.roles] } : null;
return username === this.user.username ? { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] } : null;
}
async markLoginSuccess(_userId: string, _ip?: string): Promise<void> {
@@ -54,7 +55,7 @@ class MemoryAuthRepository implements AuthRepository {
refreshTokenHash: input.refreshTokenHash,
expiresAt: input.expiresAt,
revokedAt: null,
user: { ...this.user, roles: [...this.user.roles] }
user: { ...this.user, roles: [...this.user.roles], permissions: [...this.user.permissions] }
};
this.sessions.set(session.id, session);
@@ -112,6 +113,7 @@ describe('AuthService', () => {
const session = [...repo.sessions.values()][0];
expect(result.response.user.username).toBe('operator');
expect(result.response.user.permissions).toEqual(['dashboard.view', 'customers.view']);
expect(result.response.accessToken.split('.')).toHaveLength(3);
expect(session.refreshTokenHash).toBe(sha256Token(result.tokens.refreshToken));
expect(session.refreshTokenHash).not.toBe(result.tokens.refreshToken);
@@ -32,6 +32,7 @@ export interface AuthResponse {
username: string;
displayName: string;
roles: string[];
permissions: string[];
requirePasswordChange: boolean;
};
}
@@ -171,6 +172,7 @@ export class AuthService {
username: user.username,
displayName: user.displayName,
roles: user.roles,
permissions: user.permissions,
requirePasswordChange: user.requirePasswordChange
}
};
+1
View File
@@ -9,6 +9,7 @@ export interface AuthUserRecord {
lockedUntil: Date | null;
requirePasswordChange: boolean;
roles: string[];
permissions: string[];
}
export interface AuthSessionRecord {
@@ -0,0 +1,58 @@
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuditAction } from '../audit/audit.metadata.js';
import { CurrentUserParam, RequirePermissions, type CurrentUser } from '../security/security.metadata.js';
import { BusinessPrefixesService } from './business-prefixes.service.js';
@ApiTags('business-prefixes')
@Controller('business-prefixes')
export class BusinessPrefixesController {
constructor(@Inject(BusinessPrefixesService) private readonly businessPrefixesService: BusinessPrefixesService) {}
@Get()
@RequirePermissions('customer_gateways.view')
list(@Query() query: unknown) {
return this.businessPrefixesService.list(query as never);
}
@Get(':id')
@RequirePermissions('customer_gateways.view')
get(@Param('id') id: string) {
return this.businessPrefixesService.get(id);
}
@Post()
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'create', objectType: 'business-prefix' })
create(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.create(body as never, currentUser?.id);
}
@Patch(':id')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'update', objectType: 'business-prefix', objectIdParam: 'id' })
update(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.update(id, body as never, currentUser?.id);
}
@Post(':id/enable')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'enable', objectType: 'business-prefix', objectIdParam: 'id' })
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.enable(id, currentUser?.id);
}
@Post(':id/disable')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'disable', objectType: 'business-prefix', objectIdParam: 'id' })
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.disable(id, currentUser?.id);
}
@Delete(':id')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'business-prefixes', action: 'delete', objectType: 'business-prefix', objectIdParam: 'id' })
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.businessPrefixesService.remove(id, currentUser?.id);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { BusinessPrefixesController } from './business-prefixes.controller.js';
import { BUSINESS_PREFIXES_REPOSITORY, PrismaBusinessPrefixesRepository } from './business-prefixes.repository.js';
import { BusinessPrefixesService } from './business-prefixes.service.js';
@Module({
controllers: [BusinessPrefixesController],
providers: [
BusinessPrefixesService,
{
provide: BUSINESS_PREFIXES_REPOSITORY,
useClass: PrismaBusinessPrefixesRepository
}
],
exports: [BusinessPrefixesService]
})
export class BusinessPrefixesModule {}
@@ -0,0 +1,217 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import crypto from 'node:crypto';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export type BusinessPrefixStatus = 'ENABLED' | 'DISABLED';
export interface BusinessPrefixSummary {
id: string;
prefix: string;
name: string;
description: string | null;
priority: number;
status: BusinessPrefixStatus;
gatewayCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface CreateBusinessPrefixInput {
prefix: string;
name: string;
description?: string | null;
priority: number;
status?: BusinessPrefixStatus;
actorId?: string;
}
export type UpdateBusinessPrefixInput = Partial<Omit<CreateBusinessPrefixInput, 'actorId'>> & {
actorId?: string;
};
export interface BusinessPrefixesRepository {
list(query?: { keyword?: string; status?: BusinessPrefixStatus }): Promise<BusinessPrefixSummary[]>;
get(prefixId: string): Promise<BusinessPrefixSummary>;
create(input: CreateBusinessPrefixInput): Promise<BusinessPrefixSummary>;
update(prefixId: string, input: UpdateBusinessPrefixInput): Promise<BusinessPrefixSummary>;
setStatus(prefixId: string, status: BusinessPrefixStatus, actorId?: string): Promise<BusinessPrefixSummary>;
softDelete(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary>;
}
export const BUSINESS_PREFIXES_REPOSITORY = Symbol('BUSINESS_PREFIXES_REPOSITORY');
function businessPrefixId(): string {
return `bp_${crypto.randomUUID().replaceAll('-', '').slice(0, 29)}`;
}
function outboxId(): string {
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
}
@Injectable()
export class PrismaBusinessPrefixesRepository implements BusinessPrefixesRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: { keyword?: string; status?: BusinessPrefixStatus } = {}): Promise<BusinessPrefixSummary[]> {
const where: Prisma.BusinessPrefixWhereInput = {
deletedAt: null,
status: query.status,
OR: query.keyword
? [{ prefix: { contains: query.keyword } }, { name: { contains: query.keyword } }, { description: { contains: query.keyword } }]
: undefined
};
const items = await this.prisma.businessPrefix.findMany({
where,
orderBy: [{ priority: 'asc' }, { prefix: 'asc' }],
include: this.includeSummary()
});
return items.map((item) => this.toSummary(item));
}
async get(prefixId: string): Promise<BusinessPrefixSummary> {
return this.toSummary(await this.findActiveOrThrow(prefixId));
}
async create(input: CreateBusinessPrefixInput): Promise<BusinessPrefixSummary> {
const item = await this.prisma.$transaction(async (tx) => {
const created = await tx.businessPrefix.create({
data: {
id: businessPrefixId(),
prefix: input.prefix,
name: input.name,
description: input.description,
priority: input.priority,
status: input.status ?? 'ENABLED',
createdBy: input.actorId,
updatedBy: input.actorId
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, created.id, 'business_prefix.created');
return created;
});
return this.toSummary(item);
}
async update(prefixId: string, input: UpdateBusinessPrefixInput): Promise<BusinessPrefixSummary> {
await this.findActiveOrThrow(prefixId);
const item = await this.prisma.$transaction(async (tx) => {
const updated = await tx.businessPrefix.update({
where: { id: prefixId },
data: {
prefix: input.prefix,
name: input.name,
description: input.description,
priority: input.priority,
status: input.status,
updatedBy: input.actorId,
version: { increment: 1 }
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, updated.id, 'business_prefix.updated');
return updated;
});
return this.toSummary(item);
}
async setStatus(prefixId: string, status: BusinessPrefixStatus, actorId?: string): Promise<BusinessPrefixSummary> {
return this.update(prefixId, { status, actorId });
}
async softDelete(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
const existing = await this.findActiveOrThrow(prefixId);
const linkedGateways = await this.prisma.customerGatewayBusinessPrefix.count({
where: { businessPrefixId: prefixId }
});
if (linkedGateways > 0) {
throw new BadRequestException({
code: 'BUSINESS_PREFIX_IN_USE',
message: 'Business prefix in use by customer gateways cannot be deleted.'
});
}
const item = await this.prisma.$transaction(async (tx) => {
const updated = await tx.businessPrefix.update({
where: { id: existing.id },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId,
version: { increment: 1 }
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, updated.id, 'business_prefix.deleted');
return updated;
});
return this.toSummary(item);
}
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
await tx.outboxEvent.create({
data: {
id: outboxId(),
aggregateType: 'business_prefix_config',
aggregateId,
eventType,
payload: Prisma.JsonNull
}
});
}
private includeSummary() {
return {
_count: {
select: {
customerGateways: true
}
}
} satisfies Prisma.BusinessPrefixInclude;
}
private async findActiveOrThrow(prefixId: string) {
const item = await this.prisma.businessPrefix.findUnique({
where: { id: prefixId },
include: this.includeSummary()
});
if (!item || item.deletedAt) {
throw new NotFoundException({ code: 'BUSINESS_PREFIX_NOT_FOUND', message: 'Business prefix not found.' });
}
return item;
}
private toSummary(item: {
id: string;
prefix: string;
name: string;
description: string | null;
priority: number;
status: BusinessPrefixStatus;
createdAt: Date;
updatedAt: Date;
_count: { customerGateways: number };
}): BusinessPrefixSummary {
return {
id: item.id,
prefix: item.prefix,
name: item.name,
description: item.description,
priority: item.priority,
status: item.status,
gatewayCount: item._count.customerGateways,
createdAt: item.createdAt,
updatedAt: item.updatedAt
};
}
}
@@ -0,0 +1,124 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import {
BUSINESS_PREFIXES_REPOSITORY,
type BusinessPrefixesRepository,
type BusinessPrefixStatus,
type BusinessPrefixSummary,
type CreateBusinessPrefixInput,
type UpdateBusinessPrefixInput
} from './business-prefixes.repository.js';
interface BusinessPrefixDto {
prefix?: unknown;
name?: unknown;
description?: unknown;
priority?: unknown;
status?: unknown;
}
interface ListQuery {
keyword?: unknown;
status?: unknown;
}
@Injectable()
export class BusinessPrefixesService {
constructor(@Inject(BUSINESS_PREFIXES_REPOSITORY) private readonly businessPrefixes: BusinessPrefixesRepository) {}
list(query: ListQuery = {}): Promise<BusinessPrefixSummary[]> {
return this.businessPrefixes.list({
keyword: this.optionalTrimmed(query.keyword, 80),
status: query.status === undefined || query.status === 'all' ? undefined : this.status(query.status)
});
}
get(prefixId: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.get(prefixId);
}
create(body: BusinessPrefixDto, actorId?: string): Promise<BusinessPrefixSummary> {
const input: CreateBusinessPrefixInput = {
prefix: this.prefix(body.prefix),
name: this.requiredString(body.name, 'name', 120),
description: this.nullableString(body.description, 'description', 500),
priority: body.priority === undefined ? 100 : this.priority(body.priority),
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
actorId
};
return this.businessPrefixes.create(input);
}
update(prefixId: string, body: BusinessPrefixDto, actorId?: string): Promise<BusinessPrefixSummary> {
const input: UpdateBusinessPrefixInput = {
prefix: body.prefix === undefined ? undefined : this.prefix(body.prefix),
name: body.name === undefined ? undefined : this.requiredString(body.name, 'name', 120),
description: body.description === undefined ? undefined : this.nullableString(body.description, 'description', 500),
priority: body.priority === undefined ? undefined : this.priority(body.priority),
status: body.status === undefined ? undefined : this.status(body.status),
actorId
};
return this.businessPrefixes.update(prefixId, input);
}
enable(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.setStatus(prefixId, 'ENABLED', actorId);
}
disable(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.setStatus(prefixId, 'DISABLED', actorId);
}
remove(prefixId: string, actorId?: string): Promise<BusinessPrefixSummary> {
return this.businessPrefixes.softDelete(prefixId, actorId);
}
private prefix(value: unknown): string {
const trimmed = this.requiredString(value, 'prefix', 32);
if (!/^[A-Za-z0-9]{1,32}$/.test(trimmed)) {
throw new BadRequestException({ code: 'BUSINESS_PREFIX_INVALID', message: 'Business prefix must be 1-32 letters or digits.' });
}
return trimmed;
}
private requiredString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
}
const trimmed = value.trim();
if (trimmed.length > maxLength) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is too long.` });
}
return trimmed;
}
private nullableString(value: unknown, field: string, maxLength: number): string | null {
if (value === undefined || value === null || value === '') {
return null;
}
return this.requiredString(value, field, maxLength);
}
private optionalTrimmed(value: unknown, maxLength: number): string | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
return this.requiredString(value, 'keyword', maxLength);
}
private status(value: unknown): BusinessPrefixStatus {
if (value !== 'ENABLED' && value !== 'DISABLED') {
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
}
return value;
}
private priority(value: unknown): number {
const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : Number.NaN;
if (!Number.isInteger(numeric) || numeric < 1 || numeric > 9999) {
throw new BadRequestException({ code: 'PRIORITY_INVALID', message: 'Priority must be an integer between 1 and 9999.' });
}
return numeric;
}
}
+277 -10
View File
@@ -11,10 +11,19 @@ export interface CdrQuery {
vendorGatewayId?: string;
cityCode?: string;
carrier?: CdrCarrier;
startedFrom?: Date;
startedTo?: Date;
take: number;
skip: number;
}
export interface CdrPageMeta {
total: number;
take: number;
skip: number;
hasMore: boolean;
}
export interface CdrListItem {
id: string;
eventId: string;
@@ -22,6 +31,9 @@ export interface CdrListItem {
sourceIp: string | null;
caller: string;
callee: string;
rawCallee: string | null;
businessPrefixId: string | null;
businessPrefix: string | null;
calleeCityCode: string | null;
calleeCityName: string | null;
calleeProvinceName: string | null;
@@ -39,6 +51,8 @@ export interface CdrListItem {
vendorGatewayPort: number | null;
lineGroupId: string | null;
lineGroupName: string | null;
landingCaller: string | null;
landingCallee: string | null;
startedAt: Date;
answeredAt: Date | null;
endedAt: Date;
@@ -52,11 +66,73 @@ export interface CdrListItem {
vendorCost: string | null;
grossProfit: string | null;
billSec: number | null;
hasRecording: boolean;
recordingId: string | null;
recordingStatus: string | null;
}
export interface CdrEntityRef {
id: string;
name: string;
status?: string;
}
export interface CdrGatewayRef extends CdrEntityRef {
authMode?: string;
sourceIp?: string | null;
host?: string;
port?: number;
transport?: string;
}
export interface CdrPolicyRef extends CdrEntityRef {
priority: number;
callerMode: string;
callerValue: string | null;
calleeMode: string;
calleeValue: string | null;
}
export interface CdrRatedDetail {
id: string;
billSec: number;
customerFee: string;
vendorCost: string;
grossProfit: string;
customerRate: Prisma.JsonValue | null;
vendorRate: Prisma.JsonValue | null;
ratedAt: Date;
}
export interface CdrRecordingDetail {
id: string;
storageKey: string;
sha256: string | null;
bytes: string;
durationSec: number;
status: string;
movedAt: Date | null;
createdAt: Date;
}
export interface CdrDetail extends CdrListItem {
customer: CdrEntityRef | null;
customerGateway: CdrGatewayRef | null;
customerGatewayPolicy: CdrPolicyRef | null;
businessPrefixRef: CdrEntityRef & { prefix: string; priority: number } | null;
vendor: CdrEntityRef | null;
vendorGateway: CdrGatewayRef | null;
lineGroup: CdrEntityRef | null;
rated: CdrRatedDetail | null;
recording: CdrRecordingDetail | null;
payload: Prisma.JsonValue | null;
receivedAt: Date;
createdAt: Date;
}
export interface CdrsRepository {
list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }>;
get(id: string): Promise<CdrListItem>;
list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }>;
get(id: string): Promise<CdrDetail>;
}
export const CDRS_REPOSITORY = Symbol('CDRS_REPOSITORY');
@@ -69,6 +145,54 @@ type RawCdrRecord = Prisma.RawCdrGetPayload<{
vendorGateway: { select: { name: true; host: true; port: true } };
lineGroup: { select: { name: true } };
ratedCdr: { select: { customerFee: true; vendorCost: true; grossProfit: true; billSec: true } };
recording: { select: { id: true; status: true } };
};
}>;
type RawCdrDetailRecord = Prisma.RawCdrGetPayload<{
include: {
customer: { select: { id: true; name: true; status: true } };
customerGateway: { select: { id: true; name: true; status: true; authMode: true; sourceIp: true } };
customerGatewayPolicy: {
select: {
id: true;
name: true;
status: true;
priority: true;
callerMode: true;
callerValue: true;
calleeMode: true;
calleeValue: true;
};
};
businessPrefixRef: { select: { id: true; name: true; prefix: true; priority: true; status: true } };
vendor: { select: { id: true; name: true; status: true } };
vendorGateway: { select: { id: true; name: true; status: true; authMode: true; host: true; port: true; transport: true } };
lineGroup: { select: { id: true; name: true; status: true } };
ratedCdr: {
select: {
id: true;
billSec: true;
customerFee: true;
vendorCost: true;
grossProfit: true;
customerRate: true;
vendorRate: true;
ratedAt: true;
};
};
recording: {
select: {
id: true;
storageKey: true;
sha256: true;
bytes: true;
durationSec: true;
status: true;
movedAt: true;
createdAt: true;
};
};
};
}>;
@@ -76,7 +200,7 @@ type RawCdrRecord = Prisma.RawCdrGetPayload<{
export class PrismaCdrsRepository implements CdrsRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }> {
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> {
const where = this.where(query);
const [items, total] = await this.prisma.$transaction([
this.prisma.rawCdr.findMany({
@@ -88,18 +212,26 @@ export class PrismaCdrsRepository implements CdrsRepository {
}),
this.prisma.rawCdr.count({ where })
]);
return { items: items.map((item) => this.toItem(item)), total };
return {
items: items.map((item) => this.toItem(item)),
meta: {
total,
take: query.take,
skip: query.skip,
hasMore: query.skip + items.length < total
}
};
}
async get(id: string): Promise<CdrListItem> {
async get(id: string): Promise<CdrDetail> {
const item = await this.prisma.rawCdr.findUnique({
where: { id },
include: this.includeCdr()
include: this.includeDetail()
});
if (!item) {
throw new NotFoundException({ code: 'CDR_NOT_FOUND', message: 'CDR not found.' });
}
return this.toItem(item);
return this.toDetail(item);
}
private where(query: CdrQuery): Prisma.RawCdrWhereInput {
@@ -109,7 +241,8 @@ export class PrismaCdrsRepository implements CdrsRepository {
customerGatewayId: query.customerGatewayId,
vendorGatewayId: query.vendorGatewayId,
calleeCityCode: query.cityCode,
calleeOperator: query.carrier
calleeOperator: query.carrier,
startedAt: query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined
};
}
@@ -120,7 +253,55 @@ export class PrismaCdrsRepository implements CdrsRepository {
vendor: { select: { name: true } },
vendorGateway: { select: { name: true, host: true, port: true } },
lineGroup: { select: { name: true } },
ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } }
ratedCdr: { select: { customerFee: true, vendorCost: true, grossProfit: true, billSec: true } },
recording: { select: { id: true, status: true } }
} satisfies Prisma.RawCdrInclude;
}
private includeDetail() {
return {
customer: { select: { id: true, name: true, status: true } },
customerGateway: { select: { id: true, name: true, status: true, authMode: true, sourceIp: true } },
customerGatewayPolicy: {
select: {
id: true,
name: true,
status: true,
priority: true,
callerMode: true,
callerValue: true,
calleeMode: true,
calleeValue: true
}
},
businessPrefixRef: { select: { id: true, name: true, prefix: true, priority: true, status: true } },
vendor: { select: { id: true, name: true, status: true } },
vendorGateway: { select: { id: true, name: true, status: true, authMode: true, host: true, port: true, transport: true } },
lineGroup: { select: { id: true, name: true, status: true } },
ratedCdr: {
select: {
id: true,
billSec: true,
customerFee: true,
vendorCost: true,
grossProfit: true,
customerRate: true,
vendorRate: true,
ratedAt: true
}
},
recording: {
select: {
id: true,
storageKey: true,
sha256: true,
bytes: true,
durationSec: true,
status: true,
movedAt: true,
createdAt: true
}
}
} satisfies Prisma.RawCdrInclude;
}
@@ -132,6 +313,9 @@ export class PrismaCdrsRepository implements CdrsRepository {
sourceIp: item.sourceIp,
caller: item.caller,
callee: item.callee,
rawCallee: item.rawCallee,
businessPrefixId: item.businessPrefixId,
businessPrefix: item.businessPrefix,
calleeCityCode: item.calleeCityCode,
calleeCityName: item.calleeCityName,
calleeProvinceName: item.calleeProvinceName,
@@ -149,6 +333,8 @@ export class PrismaCdrsRepository implements CdrsRepository {
vendorGatewayPort: item.vendorGateway?.port ?? null,
lineGroupId: item.lineGroupId,
lineGroupName: item.lineGroup?.name ?? null,
landingCaller: item.landingCaller,
landingCallee: item.landingCallee,
startedAt: item.startedAt,
answeredAt: item.answeredAt,
endedAt: item.endedAt,
@@ -161,7 +347,88 @@ export class PrismaCdrsRepository implements CdrsRepository {
customerFee: item.ratedCdr?.customerFee.toFixed(6) ?? null,
vendorCost: item.ratedCdr?.vendorCost.toFixed(6) ?? null,
grossProfit: item.ratedCdr?.grossProfit.toFixed(6) ?? null,
billSec: item.ratedCdr?.billSec ?? null
billSec: item.ratedCdr?.billSec ?? null,
hasRecording: Boolean(item.recording),
recordingId: item.recording?.id ?? null,
recordingStatus: item.recording?.status ?? null
};
}
private toDetail(item: RawCdrDetailRecord): CdrDetail {
const base = this.toItem(item);
return {
...base,
customer: item.customer ? { id: item.customer.id, name: item.customer.name, status: item.customer.status } : null,
customerGateway: item.customerGateway
? {
id: item.customerGateway.id,
name: item.customerGateway.name,
status: item.customerGateway.status,
authMode: item.customerGateway.authMode,
sourceIp: item.customerGateway.sourceIp
}
: null,
customerGatewayPolicy: item.customerGatewayPolicy
? {
id: item.customerGatewayPolicy.id,
name: item.customerGatewayPolicy.name,
status: item.customerGatewayPolicy.status,
priority: item.customerGatewayPolicy.priority,
callerMode: item.customerGatewayPolicy.callerMode,
callerValue: item.customerGatewayPolicy.callerValue,
calleeMode: item.customerGatewayPolicy.calleeMode,
calleeValue: item.customerGatewayPolicy.calleeValue
}
: null,
businessPrefixRef: item.businessPrefixRef
? {
id: item.businessPrefixRef.id,
name: item.businessPrefixRef.name,
prefix: item.businessPrefixRef.prefix,
priority: item.businessPrefixRef.priority,
status: item.businessPrefixRef.status
}
: null,
vendor: item.vendor ? { id: item.vendor.id, name: item.vendor.name, status: item.vendor.status } : null,
vendorGateway: item.vendorGateway
? {
id: item.vendorGateway.id,
name: item.vendorGateway.name,
status: item.vendorGateway.status,
authMode: item.vendorGateway.authMode,
host: item.vendorGateway.host,
port: item.vendorGateway.port,
transport: item.vendorGateway.transport
}
: null,
lineGroup: item.lineGroup ? { id: item.lineGroup.id, name: item.lineGroup.name, status: item.lineGroup.status } : null,
rated: item.ratedCdr
? {
id: item.ratedCdr.id,
billSec: item.ratedCdr.billSec,
customerFee: item.ratedCdr.customerFee.toFixed(6),
vendorCost: item.ratedCdr.vendorCost.toFixed(6),
grossProfit: item.ratedCdr.grossProfit.toFixed(6),
customerRate: item.ratedCdr.customerRate,
vendorRate: item.ratedCdr.vendorRate,
ratedAt: item.ratedCdr.ratedAt
}
: null,
recording: item.recording
? {
id: item.recording.id,
storageKey: item.recording.storageKey,
sha256: item.recording.sha256,
bytes: item.recording.bytes.toString(),
durationSec: item.recording.durationSec,
status: item.recording.status,
movedAt: item.recording.movedAt,
createdAt: item.recording.createdAt
}
: null,
payload: item.payload,
receivedAt: item.receivedAt,
createdAt: item.createdAt
};
}
}
+45 -8
View File
@@ -1,17 +1,18 @@
import { describe, expect, it } from 'vitest';
import { BadRequestException } from '@nestjs/common';
import { CdrsService } from './cdrs.service.js';
import type { CdrListItem, CdrQuery, CdrsRepository } from './cdrs.repository.js';
import type { CdrDetail, CdrQuery, CdrsRepository } from './cdrs.repository.js';
class MemoryCdrsRepository implements CdrsRepository {
lastQuery: CdrQuery | null = null;
async list(query: CdrQuery) {
this.lastQuery = query;
return { items: [], total: 0 };
return { items: [], meta: { total: 0, take: query.take, skip: query.skip, hasMore: false } };
}
async get(id: string): Promise<CdrListItem> {
async get(id: string): Promise<CdrDetail> {
const now = new Date();
return {
id,
eventId: 'evt_1',
@@ -19,6 +20,9 @@ class MemoryCdrsRepository implements CdrsRepository {
sourceIp: null,
caller: '1001',
callee: '13800138000',
rawCallee: '67113800138000',
businessPrefixId: 'bp_seed',
businessPrefix: '671',
calleeCityCode: '340100',
calleeCityName: '合肥市',
calleeProvinceName: '安徽省',
@@ -36,9 +40,11 @@ class MemoryCdrsRepository implements CdrsRepository {
vendorGatewayPort: null,
lineGroupId: null,
lineGroupName: null,
startedAt: new Date(),
landingCaller: '02160010001',
landingCallee: '8613800138000',
startedAt: now,
answeredAt: null,
endedAt: new Date(),
endedAt: now,
durationSec: 0,
sipCode: 503,
hangupReason: 'NO_VENDOR_ROUTE_REGION_BLOCKED',
@@ -48,22 +54,47 @@ class MemoryCdrsRepository implements CdrsRepository {
customerFee: null,
vendorCost: null,
grossProfit: null,
billSec: null
billSec: null,
hasRecording: false,
recordingId: null,
recordingStatus: null,
customer: null,
customerGateway: null,
customerGatewayPolicy: null,
businessPrefixRef: null,
vendor: null,
vendorGateway: null,
lineGroup: null,
rated: null,
recording: null,
payload: null,
receivedAt: now,
createdAt: now
};
}
}
describe('CDR service', () => {
it('normalizes filters for city and carrier queries', async () => {
it('normalizes filters for city, carrier and time range queries', async () => {
const repository = new MemoryCdrsRepository();
const service = new CdrsService(repository);
await service.list({ caller: '1001', cityCode: '340100', carrier: 'MOBILE', take: '20', skip: '5' });
await service.list({
caller: '1001',
cityCode: '340100',
carrier: 'MOBILE',
startedFrom: '2026-06-24T00:00:00.000Z',
startedTo: '2026-06-25T00:00:00.000Z',
take: '20',
skip: '5'
});
expect(repository.lastQuery).toMatchObject({
caller: '1001',
cityCode: '340100',
carrier: 'MOBILE',
startedFrom: new Date('2026-06-24T00:00:00.000Z'),
startedTo: new Date('2026-06-25T00:00:00.000Z'),
take: 20,
skip: 5
});
@@ -74,4 +105,10 @@ describe('CDR service', () => {
expect(() => service.list({ carrier: 'BAD' })).toThrow(BadRequestException);
});
it('rejects inverted time ranges', () => {
const service = new CdrsService(new MemoryCdrsRepository());
expect(() => service.list({ from: '2026-06-25T00:00:00.000Z', to: '2026-06-24T00:00:00.000Z' })).toThrow(BadRequestException);
});
});
+19
View File
@@ -13,9 +13,14 @@ export class CdrsService {
vendorGatewayId: this.optionalString(rawQuery.vendorGatewayId, 32),
cityCode: this.optionalString(rawQuery.cityCode, 12),
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to),
take: this.int(rawQuery.take, 100, 1, 500),
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
};
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: 'Time range is invalid.' });
}
return this.cdrs.list(query);
}
@@ -44,6 +49,20 @@ export class CdrsService {
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
}
private optionalDate(value: unknown): Date | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: 'Time range is invalid.' });
}
return date;
}
private int(value: unknown, defaultValue: number, min: number, max: number): number {
if (value === undefined) return defaultValue;
const parsed = Number(value);
@@ -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')}`;
}
}
@@ -47,7 +47,9 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
cpsLimit: 20,
concurrencyLimit: 200,
cycleRate: '0.120000',
billingCycleSec: 60
billingCycleSec: 60,
landingCalleePrefix: '86',
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
})
);
}
@@ -75,6 +77,8 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
concurrencyLimit: input.concurrencyLimit,
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate,
landingCalleePrefix: input.landingCalleePrefix ?? null,
callerRewritePool: input.callerRewritePool.map((item, index) => ({ id: `caller_${index}`, ...item })),
forbiddenPeriods: input.forbiddenPeriods.map((period, index) => ({ id: `period_${index}`, ...period })),
codecs: input.codecs.map((codec, index) => ({ id: `codec_${index}`, ...codec })),
prefixRules: input.prefixRules.map((rule, index) => ({ id: `rule_${index}`, ...rule }))
@@ -100,6 +104,9 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
concurrencyLimit: input.concurrencyLimit ?? current.concurrencyLimit,
billingCycleSec: input.billingCycleSec ?? current.billingCycleSec,
cycleRate: input.cycleRate ?? current.cycleRate,
landingCalleePrefix: input.landingCalleePrefix === undefined ? current.landingCalleePrefix : input.landingCalleePrefix,
callerRewritePool:
input.callerRewritePool === undefined ? current.callerRewritePool : input.callerRewritePool.map((item, index) => ({ id: `caller_updated_${index}`, ...item })),
status: input.status ?? current.status,
forbiddenPeriods: input.forbiddenPeriods === undefined ? current.forbiddenPeriods : input.forbiddenPeriods.map((period, index) => ({ id: `period_updated_${index}`, ...period })),
codecs: input.codecs === undefined ? current.codecs : input.codecs.map((codec, index) => ({ id: `codec_updated_${index}`, ...codec })),
@@ -141,6 +148,8 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
billingCycleSec,
cycleRate,
minuteRate: (Number(cycleRate) * 60 / billingCycleSec).toFixed(6),
landingCalleePrefix: input.landingCalleePrefix ?? null,
callerRewritePool: input.callerRewritePool ?? [],
status: input.status ?? 'ENABLED',
forbiddenPeriods: input.forbiddenPeriods ?? [],
codecs: input.codecs ?? [],
@@ -226,7 +235,9 @@ describe('S16 vendor gateways API', () => {
cpsLimit: 20,
concurrencyLimit: 200,
cycleRate: '0.120000',
minuteRate: '0.120000'
minuteRate: '0.120000',
landingCalleePrefix: '86',
callerRewritePool: [{ id: 'cr_seed', caller: '02160010001', weight: 10, status: 'ENABLED' }]
});
expect(JSON.stringify(response.body)).not.toContain('sipHa1');
});
@@ -256,6 +267,11 @@ describe('S16 vendor gateways API', () => {
concurrencyLimit: 300,
billingCycleSec: 6,
cycleRate: '0.012',
landingCalleePrefix: '86',
callerRewritePool: [
{ caller: '02160010001', weight: 80 },
{ caller: '02160010002', weight: 20 }
],
forbiddenPeriods: [{ weekdayMask: 62, startTime: '23:00:00', endTime: '23:59:59' }],
codecs: [
{ codec: 'PCMA', priority: 1 },
@@ -272,8 +288,10 @@ describe('S16 vendor gateways API', () => {
hasSipCredential: true,
billingCycleSec: 6,
cycleRate: '0.012000',
minuteRate: '0.120000'
minuteRate: '0.120000',
landingCalleePrefix: '86'
});
expect(response.body.callerRewritePool).toHaveLength(2);
expect(response.body.sipHa1).toBeUndefined();
});
@@ -283,8 +301,10 @@ describe('S16 vendor gateways API', () => {
.send({
authMode: 'IP',
host: '203.0.113.10',
landingCalleePrefix: 'ABC',
callerRewritePool: [{ caller: '02170020001', weight: 100 }],
codecs: [{ codec: 'G729', priority: 1 }],
prefixRules: [{ direction: 'CALLER', matchPrefix: '+86', replacePrefix: '0', priority: 1 }]
prefixRules: []
})
.expect(200)
.expect((response) => {
@@ -295,7 +315,9 @@ describe('S16 vendor gateways API', () => {
hasSipCredential: false
});
expect(response.body.codecs).toHaveLength(1);
expect(response.body.prefixRules[0]).toMatchObject({ direction: 'CALLER', matchPrefix: '+86' });
expect(response.body.prefixRules).toHaveLength(0);
expect(response.body).toMatchObject({ landingCalleePrefix: 'ABC' });
expect(response.body.callerRewritePool[0]).toMatchObject({ caller: '02170020001', weight: 100 });
});
expect(repository.outboxEvents).toBeGreaterThanOrEqual(2);
@@ -28,6 +28,13 @@ export interface PrefixRuleSummary {
priority: number;
}
export interface CallerRewriteSummary {
id: string;
caller: string;
weight: number;
status: VendorGatewayStatus;
}
export interface VendorGatewaySummary {
id: string;
vendorId: string;
@@ -44,6 +51,8 @@ export interface VendorGatewaySummary {
billingCycleSec: number;
cycleRate: string;
minuteRate: string;
landingCalleePrefix: string | null;
callerRewritePool: CallerRewriteSummary[];
status: VendorGatewayStatus;
forbiddenPeriods: ForbiddenPeriodSummary[];
codecs: CodecSummary[];
@@ -70,6 +79,12 @@ export interface PrefixRuleInput {
priority: number;
}
export interface CallerRewriteInput {
caller: string;
weight: number;
status: VendorGatewayStatus;
}
export interface CreateVendorGatewayInput {
vendorId: string;
name: string;
@@ -83,10 +98,12 @@ export interface CreateVendorGatewayInput {
concurrencyLimit: number;
billingCycleSec: number;
cycleRate: string;
landingCalleePrefix?: string | null;
status: VendorGatewayStatus;
forbiddenPeriods: ForbiddenPeriodInput[];
codecs: CodecInput[];
prefixRules: PrefixRuleInput[];
callerRewritePool: CallerRewriteInput[];
actorId?: string;
}
@@ -110,7 +127,7 @@ function gatewayId(): string {
}
function childId(prefix: string): string {
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`;
return `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 32 - prefix.length - 1)}`;
}
function outboxId(): string {
@@ -156,12 +173,14 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
concurrencyLimit: input.concurrencyLimit,
billingCycleSec: input.billingCycleSec,
cycleRate: new Prisma.Decimal(input.cycleRate),
landingCalleePrefix: input.landingCalleePrefix,
status: input.status,
createdBy: input.actorId,
updatedBy: input.actorId,
forbiddenPeriods: { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) },
codecs: { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) },
prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) }
prefixRules: { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) },
callerRewritePool: { create: input.callerRewritePool.map((item) => this.callerRewriteCreate(item, input.actorId)) }
},
include: this.includeSummary()
});
@@ -192,6 +211,9 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
if (input.prefixRules) {
await tx.vendorGatewayPrefixRule.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
}
if (input.callerRewritePool) {
await tx.vendorGatewayCallerRewrite.deleteMany({ where: { vendorGatewayId: gatewayIdValue } });
}
const updated = await tx.vendorGateway.update({
where: { id: gatewayIdValue },
@@ -208,12 +230,14 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
concurrencyLimit: input.concurrencyLimit,
billingCycleSec: input.billingCycleSec,
cycleRate: input.cycleRate === undefined ? undefined : new Prisma.Decimal(input.cycleRate),
landingCalleePrefix: input.landingCalleePrefix,
status: input.status,
updatedBy: input.actorId,
version: { increment: 1 },
forbiddenPeriods: input.forbiddenPeriods ? { create: input.forbiddenPeriods.map((period) => this.forbiddenCreate(period, input.actorId)) } : undefined,
codecs: input.codecs ? { create: input.codecs.map((codec) => this.codecCreate(codec, input.actorId)) } : undefined,
prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined
prefixRules: input.prefixRules ? { create: input.prefixRules.map((rule) => this.prefixRuleCreate(rule, input.actorId)) } : undefined,
callerRewritePool: input.callerRewritePool ? { create: input.callerRewritePool.map((item) => this.callerRewriteCreate(item, input.actorId)) } : undefined
},
include: this.includeSummary()
});
@@ -331,12 +355,24 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
};
}
private callerRewriteCreate(item: CallerRewriteInput, actorId?: string) {
return {
id: childId('vgcr'),
caller: item.caller,
weight: item.weight,
status: item.status,
createdBy: actorId,
updatedBy: actorId
};
}
private includeSummary() {
return {
vendor: { select: { name: true } },
forbiddenPeriods: { orderBy: [{ weekdayMask: 'asc' }, { startTime: 'asc' }] },
codecs: { orderBy: [{ priority: 'asc' }] },
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] }
prefixRules: { orderBy: [{ direction: 'asc' }, { priority: 'asc' }] },
callerRewritePool: { where: { deletedAt: null }, orderBy: [{ weight: 'desc' }, { caller: 'asc' }] }
} satisfies Prisma.VendorGatewayInclude;
}
@@ -366,6 +402,7 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
concurrencyLimit: number;
billingCycleSec: number;
cycleRate: Prisma.Decimal;
landingCalleePrefix: string | null;
status: VendorGatewayStatus;
createdAt: Date;
updatedAt: Date;
@@ -373,6 +410,7 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
forbiddenPeriods: Array<{ id: string; weekdayMask: number; startTime: string; endTime: string }>;
codecs: Array<{ id: string; codec: string; priority: number }>;
prefixRules: Array<{ id: string; direction: PrefixDirection; matchPrefix: string; replacePrefix: string; priority: number }>;
callerRewritePool: Array<{ id: string; caller: string; weight: number; status: VendorGatewayStatus }>;
}): VendorGatewaySummary {
const minuteRate = gateway.cycleRate.mul(new Prisma.Decimal(60)).div(gateway.billingCycleSec);
return {
@@ -391,6 +429,8 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
billingCycleSec: gateway.billingCycleSec,
cycleRate: gateway.cycleRate.toFixed(6),
minuteRate: minuteRate.toFixed(6),
landingCalleePrefix: gateway.landingCalleePrefix,
callerRewritePool: gateway.callerRewritePool,
status: gateway.status,
forbiddenPeriods: gateway.forbiddenPeriods,
codecs: gateway.codecs,
@@ -4,6 +4,7 @@ import net from 'node:net';
import {
VENDOR_GATEWAYS_REPOSITORY,
type CodecInput,
type CallerRewriteInput,
type CreateVendorGatewayInput,
type ForbiddenPeriodInput,
type PrefixDirection,
@@ -28,6 +29,8 @@ interface VendorGatewayDto {
concurrencyLimit?: unknown;
billingCycleSec?: unknown;
cycleRate?: unknown;
landingCalleePrefix?: unknown;
callerRewritePool?: unknown;
status?: unknown;
forbiddenPeriods?: unknown;
codecs?: unknown;
@@ -65,10 +68,12 @@ export class VendorGatewaysService {
concurrencyLimit: body.concurrencyLimit === undefined ? 0 : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
landingCalleePrefix: this.optionalLandingPrefix(body.landingCalleePrefix),
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
forbiddenPeriods: this.forbiddenPeriods(body.forbiddenPeriods),
codecs: this.codecs(body.codecs),
prefixRules: this.prefixRules(body.prefixRules),
callerRewritePool: this.callerRewritePool(body.callerRewritePool),
actorId
};
return this.gateways.create(input);
@@ -104,10 +109,12 @@ export class VendorGatewaysService {
concurrencyLimit: body.concurrencyLimit === undefined ? undefined : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
billingCycleSec: body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
landingCalleePrefix: body.landingCalleePrefix === undefined ? undefined : this.optionalLandingPrefix(body.landingCalleePrefix),
status: body.status === undefined ? undefined : this.status(body.status),
forbiddenPeriods: body.forbiddenPeriods === undefined ? undefined : this.forbiddenPeriods(body.forbiddenPeriods),
codecs: body.codecs === undefined ? undefined : this.codecs(body.codecs),
prefixRules: body.prefixRules === undefined ? undefined : this.prefixRules(body.prefixRules),
callerRewritePool: body.callerRewritePool === undefined ? undefined : this.callerRewritePool(body.callerRewritePool),
actorId
};
return this.gateways.update(gatewayId, input);
@@ -316,6 +323,52 @@ export class VendorGatewaysService {
return text;
}
private optionalLandingPrefix(value: unknown): string | null {
if (value === undefined || value === null || value === '') {
return null;
}
const text = this.limitedString(value, 'landingCalleePrefix', 64);
if (!/^[A-Za-z0-9]+$/.test(text)) {
throw new BadRequestException({
code: 'LANDING_CALLEE_PREFIX_INVALID',
message: 'landingCalleePrefix must contain only letters or digits.'
});
}
return text;
}
private callerRewritePool(value: unknown): CallerRewriteInput[] {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new BadRequestException({ code: 'CALLER_REWRITE_POOL_INVALID', message: 'callerRewritePool must be an array.' });
}
if (value.length > 50) {
throw new BadRequestException({ code: 'CALLER_REWRITE_POOL_TOO_LARGE', message: 'callerRewritePool supports up to 50 items.' });
}
const pool = value
.map((item, index) => {
const record = item as Record<string, unknown>;
return {
caller: this.rewriteCaller(record.caller, `callerRewritePool[${index}].caller`),
weight: this.integer(record.weight, `callerRewritePool[${index}].weight`, 1, 100000),
status: record.status === undefined ? 'ENABLED' as const : this.status(record.status)
};
})
.filter((item) => item.caller);
this.ensureUnique(pool.map((item) => item.caller), 'CALLER_REWRITE_DUPLICATE', 'callerRewritePool callers must be unique.');
return pool;
}
private rewriteCaller(value: unknown, field: string): string {
const text = this.limitedString(value, field, 64);
if (!/^[A-Za-z0-9]+$/.test(text)) {
throw new BadRequestException({ code: 'CALLER_REWRITE_INVALID', message: `${field} must contain only letters or digits.` });
}
return text;
}
private ensureUnique(values: string[], code: string, message: string): void {
if (new Set(values).size !== values.length) {
throw new BadRequestException({ code, message });