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