feat: add number library routing and cdr location support

This commit is contained in:
hectorzhao
2026-06-24 11:39:32 +08:00
parent 5fa1bd35e9
commit 7057fd3c42
63 changed files with 6361 additions and 566 deletions
@@ -0,0 +1,24 @@
import { Controller, Get, Inject, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuditAction } from '../audit/audit.metadata.js';
import { RequirePermissions } from '../security/security.metadata.js';
import { ActiveCallsService } from './active-calls.service.js';
@ApiTags('active-calls')
@Controller('active-calls')
export class ActiveCallsController {
constructor(@Inject(ActiveCallsService) private readonly activeCallsService: ActiveCallsService) {}
@Get()
@RequirePermissions('active_calls.view')
list() {
return this.activeCallsService.list();
}
@Post(':id/hangup')
@RequirePermissions('active_calls.manage')
@AuditAction({ module: 'active_calls', action: 'hangup', objectType: 'dialog', objectIdParam: 'id' })
hangup(@Param('id') id: string) {
return this.activeCallsService.hangup(id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ActiveCallsController } from './active-calls.controller.js';
import { ActiveCallsService } from './active-calls.service.js';
import { OpenSipsMiClient } from './opensips-mi.client.js';
@Module({
controllers: [ActiveCallsController],
providers: [ActiveCallsService, OpenSipsMiClient]
})
export class ActiveCallsModule {}
@@ -0,0 +1,103 @@
import { BadGatewayException, BadRequestException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ActiveCallsService, normalizeDialogs } from './active-calls.service.js';
import type { OpenSipsMiClient } from './opensips-mi.client.js';
describe('active calls service', () => {
it('normalizes OpenSIPS dialog list payloads', () => {
const calls = normalizeDialogs(
{
Dialogs: [
{
ID: '6ae.4b38d013',
callid: 'call-1@lisglosips-t',
from_tag: 'from-a',
to_tag: 'to-b',
from_uri: 'sip:1001@s21.lisglosips.test',
to_uri: 'sip:2001@s21.lisglosips.test',
state: 'confirmed',
start_time: '1782100000'
}
]
},
new Date('2026-06-22T08:00:10.000Z')
);
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
id: '6ae.4b38d013',
callId: 'call-1@lisglosips-t',
fromTag: 'from-a',
toTag: 'to-b',
caller: 'sip:1001@s21.lisglosips.test',
callee: 'sip:2001@s21.lisglosips.test',
state: 'confirmed'
});
});
it('extracts caller and landing IPs from dialog contacts and SDP', () => {
const calls = normalizeDialogs({
Dialogs: [
{
ID: 'fd4.a9a0c987',
callid: 'call-2@lisglosips-t',
from_uri: 'sip:s28-ip-1001@s28.customer.local',
to_uri: 'sip:13800138000@100.90.90.90:15060',
caller_contact: 'sip:s28-ip-1001@100.93.185.30:47200',
caller_sdp: 'v=0\r\nc=IN IP4 100.93.185.30\r\n',
CALLEES: [
{
callee_sdp: 'v=0\r\nc=IN IP4 192.0.2.80\r\n'
}
]
}
]
});
expect(calls[0]).toMatchObject({
callerIp: '100.93.185.30',
landingIp: '192.0.2.80'
});
});
it('requests hangup for safe dialog identifiers', async () => {
const miClient = {
endDialog: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OpenSipsMiClient;
const service = new ActiveCallsService(miClient);
await expect(service.hangup('call-1%40lisglosips-t')).resolves.toMatchObject({
dialogId: 'call-1@lisglosips-t',
status: 'HANGUP_REQUESTED'
});
expect(miClient.endDialog).toHaveBeenCalledWith('call-1@lisglosips-t');
});
it('accepts OpenSIPS operation-failed hangup when the dialog is terminating', async () => {
const miClient = {
endDialog: vi.fn().mockRejectedValue(
new BadGatewayException({
code: 'ACTIVE_CALLS_MI_ERROR',
message: 'Operation failed',
detail: { code: 500, message: 'Operation failed' }
})
),
listDialogs: vi.fn().mockResolvedValue({
Dialogs: [{ ID: '6ae.4b38d013', callid: 'call-1@lisglosips-t', state: 5 }]
})
} as unknown as OpenSipsMiClient;
const service = new ActiveCallsService(miClient);
await expect(service.hangup('6ae.4b38d013')).resolves.toMatchObject({
dialogId: '6ae.4b38d013',
status: 'HANGUP_IN_PROGRESS',
state: '5'
});
});
it('rejects unsafe dialog identifiers', async () => {
const service = new ActiveCallsService({ endDialog: vi.fn() } as unknown as OpenSipsMiClient);
await expect(service.hangup('../../etc/passwd')).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,282 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { OpenSipsMiClient } from './opensips-mi.client.js';
export interface ActiveCallSummary {
id: string;
callId: string;
fromTag: string | null;
toTag: string | null;
caller: string | null;
callee: string | null;
state: string | null;
startedAt: string | null;
durationSec: number | null;
lifetimeSec: number | null;
callerIp: string | null;
landingIp: string | null;
callerContact: string | null;
calleeContact: string | null;
raw: Record<string, unknown>;
}
const SAFE_DIALOG_ID = /^[A-Za-z0-9@._:%+\-=]{1,220}$/;
@Injectable()
export class ActiveCallsService {
constructor(@Inject(OpenSipsMiClient) private readonly miClient: OpenSipsMiClient) {}
async list(now = new Date()) {
const result = await this.miClient.listDialogs();
const calls = normalizeDialogs(result, now);
return {
generatedAt: now.toISOString(),
source: 'opensips-mi',
total: calls.length,
items: calls
};
}
async hangup(dialogId: string) {
const normalized = this.dialogId(dialogId);
try {
const result = await this.miClient.endDialog(normalized);
return {
dialogId: normalized,
status: 'HANGUP_REQUESTED',
source: 'opensips-mi',
result
};
} catch (error) {
if (!isOpenSipsOperationFailed(error)) {
throw error;
}
await sleep(800);
const calls = normalizeDialogs(await this.miClient.listDialogs());
const current = calls.find((call) => call.id === normalized || call.callId === normalized);
if (!current) {
return {
dialogId: normalized,
status: 'HANGUP_CONFIRMED',
source: 'opensips-mi'
};
}
if (current.state === '5') {
return {
dialogId: normalized,
status: 'HANGUP_IN_PROGRESS',
source: 'opensips-mi',
state: current.state
};
}
throw error;
}
}
private dialogId(value: string): string {
const decoded = decodeURIComponent(value).trim();
if (!SAFE_DIALOG_ID.test(decoded)) {
throw new BadRequestException({
code: 'ACTIVE_CALL_ID_INVALID',
message: 'Active call id is invalid.'
});
}
return decoded;
}
}
function isOpenSipsOperationFailed(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('getResponse' in error) || typeof error.getResponse !== 'function') {
return false;
}
const response = error.getResponse() as unknown;
if (!response || typeof response !== 'object') {
return false;
}
const record = response as { code?: unknown; detail?: { code?: unknown; message?: unknown } };
return record.code === 'ACTIVE_CALLS_MI_ERROR' && record.detail?.code === 500 && record.detail.message === 'Operation failed';
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function normalizeDialogs(result: unknown, now = new Date()): ActiveCallSummary[] {
const records = collectDialogRecords(result);
return records
.map((record) => toSummary(record, now))
.filter((call): call is ActiveCallSummary => call !== null)
.sort((left, right) => (right.durationSec ?? 0) - (left.durationSec ?? 0));
}
function collectDialogRecords(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.flatMap((item) => collectDialogRecords(item));
}
if (!isRecord(value)) {
return [];
}
if (looksLikeDialog(value)) {
return [value];
}
return Object.values(value).flatMap((item) => collectDialogRecords(item));
}
function looksLikeDialog(record: Record<string, unknown>): boolean {
const keys = Object.keys(record).map((key) => key.toLowerCase().replaceAll(/[^a-z0-9]/g, ''));
return keys.some((key) => key === 'id' || key === 'dialogid' || key === 'dlgdid' || key === 'did') && keys.some((key) => key === 'callid');
}
function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummary | null {
const callId = stringField(record, ['callid', 'call_id', 'Call-ID', 'call-id']);
const id = stringField(record, ['ID', 'id', 'dialog_id', 'dialogId', 'dlg_id', 'dlgId', 'DID', 'did']) ?? callId;
if (!id || !callId) {
return null;
}
const createdRaw = stringField(record, ['start_time', 'startTime', 'created', 'creation_time', 'init_ts', 'timestamp']);
const startedAt = normalizeTime(createdRaw);
const lifetimeSec = numberField(record, ['lifetime', 'duration', 'timeout']);
const durationSec = startedAt ? Math.max(0, Math.floor((now.getTime() - new Date(startedAt).getTime()) / 1000)) : lifetimeSec;
const calleeRecord = firstRecordField(record, ['CALLEES', 'callees', 'callee']);
const callerContact = stringField(record, ['caller_contact', 'from_contact', 'fromContact']);
const calleeContact =
stringField(record, ['callee_contact', 'to_contact', 'toContact']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_contact', 'to_contact', 'toContact']) : null);
const callerSdp = stringField(record, ['caller_sdp', 'callerSdp', 'from_sdp', 'sdp']);
const calleeSdp =
stringField(record, ['callee_sdp', 'calleeSdp', 'to_sdp']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_sdp', 'calleeSdp', 'to_sdp', 'sdp']) : null);
return {
id,
callId,
fromTag: stringField(record, ['from_tag', 'fromtag', 'fromTag']),
toTag: stringField(record, ['to_tag', 'totag', 'toTag']),
caller: stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri']),
callee: stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri']),
state: stringField(record, ['state', 'status']),
startedAt,
durationSec,
lifetimeSec,
callerIp: firstNonNull([
hostFromSipAddress(callerContact),
ipFromSdp(callerSdp),
ipFromSipAddress(stringField(record, ['from_uri', 'fromUri', 'caller', 'caller_uri'])),
ipFromSipAddress(stringField(record, ['caller_bind_addr', 'from_bind_addr']))
]),
landingIp: firstNonNull([
hostFromSipAddress(calleeContact),
ipFromSdp(calleeSdp),
ipFromSipAddress(stringField(record, ['to_uri', 'toUri', 'callee', 'callee_uri'])),
ipFromSipAddress(stringField(record, ['callee_bind_addr', 'to_bind_addr']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_bind_addr', 'to_bind_addr']) : null))
]),
callerContact,
calleeContact,
raw: record
};
}
function stringField(record: Record<string, unknown>, names: string[]): string | null {
for (const name of names) {
const value = findCaseInsensitive(record, name);
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
}
return null;
}
function numberField(record: Record<string, unknown>, names: string[]): number | null {
for (const name of names) {
const value = findCaseInsensitive(record, name);
const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number.parseInt(value, 10) : NaN;
if (Number.isSafeInteger(parsed) && parsed >= 0) {
return parsed;
}
}
return null;
}
function findCaseInsensitive(record: Record<string, unknown>, name: string): unknown {
const wanted = normalizeKey(name);
const found = Object.entries(record).find(([key]) => normalizeKey(key) === wanted);
return found?.[1];
}
function firstRecordField(record: Record<string, unknown>, names: string[]): Record<string, unknown> | null {
for (const name of names) {
const value = findCaseInsensitive(record, name);
if (isRecord(value)) {
return value;
}
if (Array.isArray(value)) {
const found = value.find(isRecord);
if (found) {
return found;
}
}
}
return null;
}
function firstNonNull(values: Array<string | null>): string | null {
return values.find((value): value is string => !!value) ?? null;
}
function hostFromSipAddress(value: string | null): string | null {
if (!value) {
return null;
}
const match = value.match(/^(?:sips?:)?(?:[^@;\s]+@)?\[?([A-Fa-f0-9:.]+)\]?/);
return match ? normalizeIp(match[1]) : null;
}
function ipFromSipAddress(value: string | null): string | null {
return normalizeIp(hostFromSipAddress(value));
}
function ipFromSdp(value: string | null): string | null {
if (!value) {
return null;
}
const match = value.match(/^c=IN IP[46] ([^\r\n\s]+)/m);
return match ? normalizeIp(match[1]) : null;
}
function normalizeIp(value: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim().replace(/^\[/, '').replace(/\]$/, '');
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(trimmed) || /^[A-Fa-f0-9:]+$/.test(trimmed)) {
return trimmed;
}
return null;
}
function normalizeKey(value: string): string {
return value.toLowerCase().replaceAll(/[^a-z0-9]/g, '');
}
function normalizeTime(value: string | null): string | null {
if (!value) {
return null;
}
if (/^\d+$/.test(value)) {
const seconds = Number.parseInt(value, 10);
if (Number.isSafeInteger(seconds) && seconds > 0) {
return new Date(seconds * 1000).toISOString();
}
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
@@ -0,0 +1,108 @@
import { BadGatewayException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'node:child_process';
import type { RuntimeConfig } from '../../shared/config.js';
export interface MiResponse<T = unknown> {
jsonrpc?: string;
result?: T;
error?: {
code?: number;
message?: string;
};
id?: number | string | null;
}
export interface CommandExecutor {
run(args: string[], timeoutMs: number): Promise<string>;
}
@Injectable()
export class SshCommandExecutor implements CommandExecutor {
run(args: string[], timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
const timer = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`ssh command timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf8'));
return;
}
reject(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`));
});
});
}
}
@Injectable()
export class OpenSipsMiClient {
private readonly executor: CommandExecutor = new SshCommandExecutor();
constructor(private readonly configService: ConfigService<RuntimeConfig, true>) {}
async listDialogs(): Promise<unknown> {
return this.call('dlg_list');
}
async endDialog(dialogId: string): Promise<unknown> {
return this.call('dlg_end_dlg', [dialogId]);
}
private async call(method: string, params: string[] = []): Promise<unknown> {
const config = this.configService.get('activeCalls', { infer: true });
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
const args = [];
if (config.sshConfig) {
args.push('-F', config.sshConfig);
}
args.push(config.sshHost, remoteCommand);
let output: string;
try {
output = await this.executor.run(args, config.timeoutMs);
} catch (error) {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
message: 'OpenSIPS control plane is unavailable.',
detail: error instanceof Error ? error.message : String(error)
});
}
let response: MiResponse;
try {
response = JSON.parse(output) as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
if (response.error) {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_ERROR',
message: response.error.message ?? 'OpenSIPS MI command failed.',
detail: response.error
});
}
return response.result;
}
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
+6
View File
@@ -7,7 +7,9 @@ import { LOG_REDACT_PATHS } from '@lisglosips/observability';
import { appConfig, validationSchema } from '../shared/config.js';
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 { 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';
import { CustomersModule } from './customers/customers.module.js';
@@ -15,6 +17,7 @@ import { DashboardModule } from './dashboard/dashboard.module.js';
import { DatabaseModule } from './database/database.module.js';
import { HealthModule } from './health/health.module.js';
import { LandingLineGroupsModule } from './landing-line-groups/landing-line-groups.module.js';
import { NumberLibraryModule } from './number-library/number-library.module.js';
import { RechargesModule } from './recharges/recharges.module.js';
import { RecordingsModule } from './recordings/recordings.module.js';
import { QualityModule } from './quality/quality.module.js';
@@ -55,6 +58,8 @@ import { VendorsModule } from './vendors/vendors.module.js';
SecurityModule,
AuditModule,
AuthModule,
ActiveCallsModule,
CdrsModule,
DashboardModule,
CustomersModule,
CustomerGatewaysModule,
@@ -63,6 +68,7 @@ import { VendorsModule } from './vendors/vendors.module.js';
VendorsModule,
VendorGatewaysModule,
LandingLineGroupsModule,
NumberLibraryModule,
QualityModule,
RecordingsModule,
UsersModule,
+41 -6
View File
@@ -1,26 +1,50 @@
import { Body, Controller, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Inject, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Public } from '../security/security.metadata.js';
import { AuthService } from './auth.service.js';
import { CaptchaService } from './captcha.service.js';
import { parseCookie, serializeCookie } from './cookie.js';
interface LoginBody {
username?: unknown;
password?: unknown;
captchaId?: unknown;
captchaCode?: unknown;
}
@ApiTags('auth')
@Public()
@Controller('auth')
export class AuthController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
constructor(
@Inject(AuthService) private readonly authService: AuthService,
@Inject(CaptchaService) private readonly captchaService: CaptchaService
) {}
@Get('captcha')
@ApiOperation({ summary: 'Create a one-time image captcha for login' })
captcha() {
const challenge = this.captchaService.createChallenge();
return {
captchaId: challenge.id,
imageDataUrl: challenge.imageDataUrl,
expiresAt: challenge.expiresAt.toISOString()
};
}
@Post('login')
@HttpCode(200)
@ApiOperation({ summary: 'Login with username and password' })
async login(@Body() body: LoginBody, @Req() request: FastifyRequest, @Res({ passthrough: true }) reply: FastifyReply) {
const { username, password } = this.readCredentials(body);
const { username, password, captchaId, captchaCode } = this.readCredentials(body);
if (!this.captchaService.verify(captchaId, captchaCode)) {
throw new UnauthorizedException({
code: 'AUTH_CAPTCHA_INVALID',
message: 'Captcha verification failed.'
});
}
const result = await this.authService.login(username, password, this.requestContext(request));
this.setRefreshCookie(reply, result.tokens.refreshToken, result.tokens.refreshMaxAgeSeconds);
@@ -56,8 +80,17 @@ export class AuthController {
);
}
private readCredentials(body: LoginBody): { username: string; password: string } {
if (typeof body.username !== 'string' || typeof body.password !== 'string' || !body.username.trim() || !body.password) {
private readCredentials(body: LoginBody): { username: string; password: string; captchaId: string; captchaCode: string } {
if (
typeof body.username !== 'string' ||
typeof body.password !== 'string' ||
typeof body.captchaId !== 'string' ||
typeof body.captchaCode !== 'string' ||
!body.username.trim() ||
!body.password ||
!body.captchaId ||
!body.captchaCode.trim()
) {
throw new UnauthorizedException({
code: 'AUTH_INVALID_CREDENTIALS',
message: 'Invalid username or password.'
@@ -66,7 +99,9 @@ export class AuthController {
return {
username: body.username,
password: body.password
password: body.password,
captchaId: body.captchaId,
captchaCode: body.captchaCode
};
}
+15 -1
View File
@@ -5,6 +5,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { hashPasswordArgon2id, sha256Token } from '@lisglosips/auth';
import { CaptchaService } from './captcha.service.js';
import { AUTH_REPOSITORY, type AuthRepository, type AuthSessionRecord, type AuthUserRecord, type CreateSessionInput } from './auth.types.js';
class E2eAuthRepository implements AuthRepository {
@@ -77,6 +78,7 @@ class E2eAuthRepository implements AuthRepository {
describe('LisgloSIPS Auth API', () => {
let app: NestFastifyApplication;
let repo: E2eAuthRepository;
let captchaService: CaptchaService;
let secret: string;
beforeAll(async () => {
@@ -101,6 +103,7 @@ describe('LisgloSIPS Auth API', () => {
app.setGlobalPrefix('api/v2');
await app.init();
await app.getHttpAdapter().getInstance().ready();
captchaService = app.get(CaptchaService);
});
afterAll(async () => {
@@ -108,9 +111,20 @@ describe('LisgloSIPS Auth API', () => {
});
it('logs in, refreshes with cookie rotation, and logs out', async () => {
const login = await request(app.getHttpServer())
const publicCaptcha = await request(app.getHttpServer()).get('/api/v2/auth/captcha').expect(200);
expect(publicCaptcha.body.captchaId).toBeTypeOf('string');
expect(publicCaptcha.body.imageDataUrl).toMatch(/^data:image\/svg\+xml;base64,/);
expect(publicCaptcha.body.answer).toBeUndefined();
await request(app.getHttpServer())
.post('/api/v2/auth/login')
.send({ username: 'operator', password: secret })
.expect(401);
const captcha = captchaService.createChallenge();
const login = await request(app.getHttpServer())
.post('/api/v2/auth/login')
.send({ username: 'operator', password: secret, captchaId: captcha.id, captchaCode: captcha.answer })
.expect(200);
const loginCookie = login.headers['set-cookie'][0];
const firstRefreshToken = /lisglosips_refresh=([^;]+)/.exec(loginCookie)?.[1] ?? '';
+3 -1
View File
@@ -2,18 +2,20 @@ import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller.js';
import { PrismaAuthRepository } from './auth.repository.js';
import { AuthService } from './auth.service.js';
import { CaptchaService } from './captcha.service.js';
import { AUTH_REPOSITORY } from './auth.types.js';
@Module({
controllers: [AuthController],
providers: [
AuthService,
CaptchaService,
PrismaAuthRepository,
{
provide: AUTH_REPOSITORY,
useExisting: PrismaAuthRepository
}
],
exports: [AuthService]
exports: [AuthService, CaptchaService]
})
export class AuthModule {}
+13 -1
View File
@@ -77,16 +77,19 @@ class MemoryAuthRepository implements AuthRepository {
}
}
function config() {
function config(overrides: Record<string, unknown> = {}) {
const values = new Map<string, unknown>([
['auth.accessTokenSecret', 'test-only-access-token-secret-min-32-bytes'],
['auth.accessTokenTtlSeconds', 900],
['auth.refreshTokenTtlDays', 7],
['auth.lockMaxFailures', 3],
['auth.lockWindowSeconds', 60],
['auth.loginThrottleMaxFailures', 10],
['auth.loginThrottleWindowSeconds', 300],
['auth.tokenIssuer', 'lisglosips-api'],
['auth.tokenAudience', 'lisglosips-web']
]);
Object.entries(overrides).forEach(([key, value]) => values.set(key, value));
return {
get: (key: string) => values.get(key)
@@ -124,6 +127,15 @@ describe('AuthService', () => {
await expect(service.login('operator', secret)).rejects.toMatchObject({ status: 401 });
});
it('throttles repeated login attempts even when the username is unknown', async () => {
service = new AuthService(repo, config({ 'auth.loginThrottleMaxFailures': 3 }) as never);
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 401 });
await expect(service.login('missing', crypto.randomUUID(), { ip: '10.0.0.10' })).rejects.toMatchObject({ status: 429 });
});
it('rotates refresh sessions and rejects reuse', async () => {
const login = await service.login('operator', secret);
const refresh = await service.refresh(login.tokens.refreshToken);
+63 -2
View File
@@ -1,4 +1,4 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { HttpException, HttpStatus, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
ACCESS_TOKEN_TYPE,
@@ -36,8 +36,15 @@ export interface AuthResponse {
};
}
interface LoginThrottleRecord {
count: number;
resetAt: number;
}
@Injectable()
export class AuthService {
private readonly loginThrottle = new Map<string, LoginThrottleRecord>();
constructor(
@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository,
@Inject(ConfigService) private readonly config: ConfigService<RuntimeConfig, true>
@@ -45,23 +52,30 @@ export class AuthService {
async login(username: string, password: string, context: AuthRequestContext = {}): Promise<{ response: AuthResponse; tokens: AuthTokens }> {
const normalizedUsername = username.trim().toLowerCase();
const user = await this.repository.findUserByUsername(normalizedUsername);
const now = new Date();
this.assertLoginAllowed(normalizedUsername, context.ip, now);
const user = await this.repository.findUserByUsername(normalizedUsername);
if (!user) {
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
throw this.invalidCredentials();
}
if (this.isLocked(user, now) || user.status !== 'ENABLED' || !user.passwordHash || user.passwordAlgo !== PASSWORD_ALGO_ARGON2ID) {
await this.registerFailure(user, now);
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
throw this.invalidCredentials();
}
if (!(await verifyPasswordArgon2id(password, user.passwordHash))) {
await this.registerFailure(user, now);
this.registerLoginThrottleFailure(normalizedUsername, context.ip, now);
throw this.invalidCredentials();
}
this.clearLoginThrottleForUser(normalizedUsername);
await this.repository.markLoginSuccess(user.id, context.ip);
const tokens = await this.issueTokens(user, context);
@@ -175,6 +189,53 @@ export class AuthService {
return Boolean(user.lockedUntil && user.lockedUntil > now);
}
private assertLoginAllowed(username: string, ip: string | undefined, now: Date): void {
const maxFailures = this.config.get('auth.loginThrottleMaxFailures', { infer: true });
const nowMs = now.getTime();
for (const key of this.throttleKeys(username, ip)) {
const record = this.loginThrottle.get(key);
if (!record) {
continue;
}
if (record.resetAt <= nowMs) {
this.loginThrottle.delete(key);
continue;
}
if (record.count >= maxFailures) {
throw new HttpException(
{
code: 'AUTH_LOGIN_THROTTLED',
message: 'Too many login attempts. Please try again later.'
},
HttpStatus.TOO_MANY_REQUESTS
);
}
}
}
private registerLoginThrottleFailure(username: string, ip: string | undefined, now: Date): void {
const windowSeconds = this.config.get('auth.loginThrottleWindowSeconds', { infer: true });
const resetAt = now.getTime() + windowSeconds * 1000;
for (const key of this.throttleKeys(username, ip)) {
const record = this.loginThrottle.get(key);
if (!record || record.resetAt <= now.getTime()) {
this.loginThrottle.set(key, { count: 1, resetAt });
} else {
this.loginThrottle.set(key, { count: record.count + 1, resetAt: record.resetAt });
}
}
}
private clearLoginThrottleForUser(username: string): void {
this.loginThrottle.delete(`user:${username}`);
}
private throttleKeys(username: string, ip: string | undefined): string[] {
return [`user:${username}`, `ip:${ip || 'unknown'}`];
}
private invalidCredentials(): UnauthorizedException {
return new UnauthorizedException({
code: 'AUTH_INVALID_CREDENTIALS',
@@ -0,0 +1,102 @@
import { Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
export interface CaptchaChallenge {
id: string;
answer: string;
imageDataUrl: string;
expiresAt: Date;
}
interface StoredCaptcha {
answer: string;
expiresAt: number;
}
const CAPTCHA_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
const CAPTCHA_LENGTH = 5;
const CAPTCHA_TTL_MS = 5 * 60 * 1000;
@Injectable()
export class CaptchaService {
private readonly challenges = new Map<string, StoredCaptcha>();
createChallenge(now = new Date()): CaptchaChallenge {
this.cleanup(now.getTime());
const id = crypto.randomUUID();
const answer = this.randomAnswer();
const expiresAt = new Date(now.getTime() + CAPTCHA_TTL_MS);
this.challenges.set(id, {
answer,
expiresAt: expiresAt.getTime()
});
return {
id,
answer,
expiresAt,
imageDataUrl: this.renderSvgDataUrl(answer, id)
};
}
verify(id: string, answer: string, now = new Date()): boolean {
this.cleanup(now.getTime());
const challenge = this.challenges.get(id);
this.challenges.delete(id);
if (!challenge || challenge.expiresAt <= now.getTime()) {
return false;
}
const normalized = answer.trim().toUpperCase();
return normalized.length === challenge.answer.length && crypto.timingSafeEqual(Buffer.from(normalized), Buffer.from(challenge.answer));
}
private randomAnswer(): string {
let answer = '';
for (let index = 0; index < CAPTCHA_LENGTH; index += 1) {
answer += CAPTCHA_ALPHABET[crypto.randomInt(0, CAPTCHA_ALPHABET.length)];
}
return answer;
}
private renderSvgDataUrl(answer: string, id: string): string {
const shortId = id.replaceAll('-', '').slice(0, 8);
const noise = Array.from({ length: 8 }, (_, index) => {
const x1 = crypto.randomInt(0, 132);
const y1 = crypto.randomInt(8, 42);
const x2 = crypto.randomInt(0, 132);
const y2 = crypto.randomInt(8, 42);
const opacity = index % 2 === 0 ? '0.24' : '0.16';
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#2563eb" stroke-width="1.3" opacity="${opacity}" />`;
}).join('');
const chars = answer.split('').map((char, index) => {
const x = 18 + index * 21;
const y = 32 + crypto.randomInt(-3, 4);
const rotate = crypto.randomInt(-12, 13);
return `<text x="${x}" y="${y}" transform="rotate(${rotate} ${x} ${y})">${char}</text>`;
}).join('');
const dots = Array.from({ length: 20 }, () => {
const cx = crypto.randomInt(4, 128);
const cy = crypto.randomInt(5, 43);
return `<circle cx="${cx}" cy="${cy}" r="1" fill="#94a3b8" opacity="0.55" />`;
}).join('');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="132" height="48" viewBox="0 0 132 48" role="img" aria-label="captcha ${shortId}">
<rect width="132" height="48" rx="7" fill="#f8fafc"/>
${noise}
${dots}
<g font-family="Inter, Arial, sans-serif" font-size="24" font-weight="800" letter-spacing="2" fill="#111827">${chars}</g>
</svg>`;
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
}
private cleanup(nowMs: number): void {
for (const [id, challenge] of this.challenges.entries()) {
if (challenge.expiresAt <= nowMs) {
this.challenges.delete(id);
}
}
}
}
@@ -0,0 +1,22 @@
import { Controller, Get, Inject, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '../security/security.metadata.js';
import { CdrsService } from './cdrs.service.js';
@ApiTags('cdrs')
@Controller('cdrs')
export class CdrsController {
constructor(@Inject(CdrsService) private readonly cdrsService: CdrsService) {}
@Get()
@RequirePermissions('cdr.view')
list(@Query() query: Record<string, unknown>) {
return this.cdrsService.list(query);
}
@Get(':id')
@RequirePermissions('cdr.view')
get(@Param('id') id: string) {
return this.cdrsService.get(id);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { CdrsController } from './cdrs.controller.js';
import { CdrsService } from './cdrs.service.js';
import { CDRS_REPOSITORY, PrismaCdrsRepository } from './cdrs.repository.js';
@Module({
controllers: [CdrsController],
providers: [
CdrsService,
PrismaCdrsRepository,
{
provide: CDRS_REPOSITORY,
useExisting: PrismaCdrsRepository
}
]
})
export class CdrsModule {}
@@ -0,0 +1,167 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export type CdrCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN';
export interface CdrQuery {
caller?: string;
callee?: string;
customerGatewayId?: string;
vendorGatewayId?: string;
cityCode?: string;
carrier?: CdrCarrier;
take: number;
skip: number;
}
export interface CdrListItem {
id: string;
eventId: string;
callId: string;
sourceIp: string | null;
caller: string;
callee: string;
calleeCityCode: string | null;
calleeCityName: string | null;
calleeProvinceName: string | null;
calleeOperator: CdrCarrier;
calleeNumberType: string;
customerId: string | null;
customerName: string | null;
customerGatewayId: string | null;
customerGatewayName: string | null;
vendorId: string | null;
vendorName: string | null;
vendorGatewayId: string | null;
vendorGatewayName: string | null;
vendorGatewayHost: string | null;
vendorGatewayPort: number | null;
lineGroupId: string | null;
lineGroupName: string | null;
startedAt: Date;
answeredAt: Date | null;
endedAt: Date;
durationSec: number;
sipCode: number;
hangupReason: string | null;
recordingKey: string | null;
configVersion: number | null;
ratingStatus: string;
customerFee: string | null;
vendorCost: string | null;
grossProfit: string | null;
billSec: number | null;
}
export interface CdrsRepository {
list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }>;
get(id: string): Promise<CdrListItem>;
}
export const CDRS_REPOSITORY = Symbol('CDRS_REPOSITORY');
type RawCdrRecord = Prisma.RawCdrGetPayload<{
include: {
customer: { select: { name: true } };
customerGateway: { select: { name: true } };
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 } };
};
}>;
@Injectable()
export class PrismaCdrsRepository implements CdrsRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; total: number }> {
const where = this.where(query);
const [items, total] = await this.prisma.$transaction([
this.prisma.rawCdr.findMany({
where,
orderBy: [{ startedAt: 'desc' }],
take: query.take,
skip: query.skip,
include: this.includeCdr()
}),
this.prisma.rawCdr.count({ where })
]);
return { items: items.map((item) => this.toItem(item)), total };
}
async get(id: string): Promise<CdrListItem> {
const item = await this.prisma.rawCdr.findUnique({
where: { id },
include: this.includeCdr()
});
if (!item) {
throw new NotFoundException({ code: 'CDR_NOT_FOUND', message: 'CDR not found.' });
}
return this.toItem(item);
}
private where(query: CdrQuery): Prisma.RawCdrWhereInput {
return {
caller: query.caller ? { contains: query.caller } : undefined,
callee: query.callee ? { contains: query.callee } : undefined,
customerGatewayId: query.customerGatewayId,
vendorGatewayId: query.vendorGatewayId,
calleeCityCode: query.cityCode,
calleeOperator: query.carrier
};
}
private includeCdr() {
return {
customer: { select: { name: true } },
customerGateway: { select: { name: true } },
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 } }
} satisfies Prisma.RawCdrInclude;
}
private toItem(item: RawCdrRecord): CdrListItem {
return {
id: item.id,
eventId: item.eventId,
callId: item.callId,
sourceIp: item.sourceIp,
caller: item.caller,
callee: item.callee,
calleeCityCode: item.calleeCityCode,
calleeCityName: item.calleeCityName,
calleeProvinceName: item.calleeProvinceName,
calleeOperator: item.calleeOperator,
calleeNumberType: item.calleeNumberType,
customerId: item.customerId,
customerName: item.customer?.name ?? null,
customerGatewayId: item.customerGatewayId,
customerGatewayName: item.customerGateway?.name ?? null,
vendorId: item.vendorId,
vendorName: item.vendor?.name ?? null,
vendorGatewayId: item.vendorGatewayId,
vendorGatewayName: item.vendorGateway?.name ?? null,
vendorGatewayHost: item.vendorGateway?.host ?? null,
vendorGatewayPort: item.vendorGateway?.port ?? null,
lineGroupId: item.lineGroupId,
lineGroupName: item.lineGroup?.name ?? null,
startedAt: item.startedAt,
answeredAt: item.answeredAt,
endedAt: item.endedAt,
durationSec: item.durationSec,
sipCode: item.sipCode,
hangupReason: item.hangupReason,
recordingKey: item.recordingKey,
configVersion: item.configVersion,
ratingStatus: item.ratingStatus,
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
};
}
}
@@ -0,0 +1,77 @@
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';
class MemoryCdrsRepository implements CdrsRepository {
lastQuery: CdrQuery | null = null;
async list(query: CdrQuery) {
this.lastQuery = query;
return { items: [], total: 0 };
}
async get(id: string): Promise<CdrListItem> {
return {
id,
eventId: 'evt_1',
callId: 'call_1',
sourceIp: null,
caller: '1001',
callee: '13800138000',
calleeCityCode: '340100',
calleeCityName: '合肥市',
calleeProvinceName: '安徽省',
calleeOperator: 'MOBILE',
calleeNumberType: 'MOBILE',
customerId: null,
customerName: null,
customerGatewayId: null,
customerGatewayName: null,
vendorId: null,
vendorName: null,
vendorGatewayId: null,
vendorGatewayName: null,
vendorGatewayHost: null,
vendorGatewayPort: null,
lineGroupId: null,
lineGroupName: null,
startedAt: new Date(),
answeredAt: null,
endedAt: new Date(),
durationSec: 0,
sipCode: 503,
hangupReason: 'NO_VENDOR_ROUTE_REGION_BLOCKED',
recordingKey: null,
configVersion: null,
ratingStatus: 'SKIPPED',
customerFee: null,
vendorCost: null,
grossProfit: null,
billSec: null
};
}
}
describe('CDR service', () => {
it('normalizes filters for city and carrier queries', async () => {
const repository = new MemoryCdrsRepository();
const service = new CdrsService(repository);
await service.list({ caller: '1001', cityCode: '340100', carrier: 'MOBILE', take: '20', skip: '5' });
expect(repository.lastQuery).toMatchObject({
caller: '1001',
cityCode: '340100',
carrier: 'MOBILE',
take: 20,
skip: 5
});
});
it('rejects invalid carrier filters', async () => {
const service = new CdrsService(new MemoryCdrsRepository());
expect(() => service.list({ carrier: 'BAD' })).toThrow(BadRequestException);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { CDRS_REPOSITORY, type CdrCarrier, type CdrQuery, type CdrsRepository } from './cdrs.repository.js';
@Injectable()
export class CdrsService {
constructor(@Inject(CDRS_REPOSITORY) private readonly cdrs: CdrsRepository) {}
list(rawQuery: Record<string, unknown>) {
const query: CdrQuery = {
caller: this.optionalString(rawQuery.caller, 64),
callee: this.optionalString(rawQuery.callee, 64),
customerGatewayId: this.optionalString(rawQuery.customerGatewayId, 32),
vendorGatewayId: this.optionalString(rawQuery.vendorGatewayId, 32),
cityCode: this.optionalString(rawQuery.cityCode, 12),
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
take: this.int(rawQuery.take, 100, 1, 500),
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
};
return this.cdrs.list(query);
}
get(id: string) {
return this.cdrs.get(id);
}
private optionalString(value: unknown, maxLength: number): string | 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 trimmed = value.trim();
if (!trimmed || trimmed.length > maxLength) {
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
}
return trimmed;
}
private carrier(value: unknown): CdrCarrier {
if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') {
return value;
}
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
}
private int(value: unknown, defaultValue: number, min: number, max: number): number {
if (value === undefined) return defaultValue;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new BadRequestException({ code: 'QUERY_INVALID', message: 'Query parameter is invalid.' });
}
return parsed;
}
}
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
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';
@@ -48,4 +48,11 @@ export class CustomerGatewaysController {
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.customerGatewaysService.disable(id, currentUser?.id);
}
@Delete(':id')
@RequirePermissions('customer_gateways.manage')
@AuditAction({ module: 'customer_gateways', action: 'delete', objectType: 'customer_gateway', objectIdParam: 'id' })
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.customerGatewaysService.remove(id, currentUser?.id);
}
}
@@ -97,6 +97,13 @@ class MemoryCustomerGatewaysRepository implements CustomerGatewaysRepository {
return updated;
}
async softDelete(gatewayId: string): Promise<CustomerGatewaySummary> {
const current = await this.get(gatewayId);
const deleted = { ...current, status: 'DISABLED' as CustomerGatewayStatus, policyCount: 0 };
this.gateways.delete(gatewayId);
return deleted;
}
private summary(input: {
id: string;
customerId?: string;
@@ -269,5 +276,7 @@ describe('S13 customer gateways API', () => {
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
await request(app.getHttpServer()).post('/api/v2/customer-gateways/cgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
await request(app.getHttpServer()).delete('/api/v2/customer-gateways/cgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
expect(audit.entries.some((entry) => entry.module === 'customer_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true);
});
});
@@ -50,6 +50,7 @@ export interface CustomerGatewaysRepository {
create(input: CreateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
update(gatewayId: string, input: UpdateCustomerGatewayInput): Promise<CustomerGatewaySummary>;
setStatus(gatewayId: string, status: CustomerGatewayStatus, actorId?: string): Promise<CustomerGatewaySummary>;
softDelete(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary>;
}
export const CUSTOMER_GATEWAYS_REPOSITORY = Symbol('CUSTOMER_GATEWAYS_REPOSITORY');
@@ -168,6 +169,35 @@ export class PrismaCustomerGatewaysRepository implements CustomerGatewaysReposit
return this.toSummary(gateway);
}
async softDelete(gatewayIdValue: string, actorId?: string): Promise<CustomerGatewaySummary> {
await this.findActiveOrThrow(gatewayIdValue);
const gateway = await this.prisma.$transaction(async (tx) => {
await tx.customerGatewayPolicy.updateMany({
where: { gatewayId: gatewayIdValue, deletedAt: null },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId
}
});
const deleted = await tx.customerGateway.update({
where: { id: gatewayIdValue },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId,
version: { increment: 1 }
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, deleted.id, 'customer_gateway.deleted');
return deleted;
});
return this.toSummary(gateway);
}
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, aggregateId: string, eventType: string): Promise<void> {
await tx.outboxEvent.create({
data: {
@@ -103,6 +103,10 @@ export class CustomerGatewaysService {
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
}
remove(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
return this.gateways.softDelete(gatewayId, actorId);
}
private limitedString(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.` });
@@ -176,6 +176,7 @@ class MemoryLandingLineGroupsRepository implements LandingLineGroupsRepository {
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
policyCount: input.policyCount ?? 0,
customerGatewayCount: input.customerGatewayCount ?? 0,
items,
createdAt: new Date('2026-06-21T07:00:00.000Z'),
updatedAt: new Date('2026-06-21T07:00:00.000Z')
@@ -33,6 +33,7 @@ export interface LineGroupSummary {
concurrencyCapSum: number;
gatewayConcurrencyLimitSum: number;
policyCount: number;
customerGatewayCount: number;
items: LineGroupItemSummary[];
createdAt: Date;
updatedAt: Date;
@@ -139,11 +140,14 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
orderBy: [{ createdAt: 'desc' }],
include: this.includeSummary()
});
return groups.map((group) => this.toSummary(group));
const usageCounts = await this.customerGatewayUsageCounts(groups.map((group) => group.id));
return groups.map((group) => this.toSummary(group, usageCounts.get(group.id) ?? 0));
}
async get(lineGroupIdValue: string): Promise<LineGroupSummary> {
return this.toSummary(await this.findActiveOrThrow(lineGroupIdValue));
const group = await this.findActiveOrThrow(lineGroupIdValue);
const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]);
return this.toSummary(group, usageCounts.get(lineGroupIdValue) ?? 0);
}
async create(input: CreateLineGroupInput): Promise<LineGroupSummary> {
@@ -201,11 +205,10 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
async softDelete(lineGroupIdValue: string, actorId?: string): Promise<LineGroupSummary> {
await this.findActiveOrThrow(lineGroupIdValue);
const linkedPolicies = await this.prisma.customerGatewayPolicy.count({
where: { lineGroupId: lineGroupIdValue, deletedAt: null }
});
if (linkedPolicies > 0) {
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active policies cannot be deleted.' });
const usageCounts = await this.customerGatewayUsageCounts([lineGroupIdValue]);
const linkedCustomerGateways = usageCounts.get(lineGroupIdValue) ?? 0;
if (linkedCustomerGateways > 0) {
throw new BadRequestException({ code: 'LINE_GROUP_IN_USE', message: 'Line group referenced by active customer gateways cannot be deleted.' });
}
const group = await this.prisma.$transaction(async (tx) => {
@@ -222,7 +225,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
await this.enqueueConfigOutbox(tx, deleted.id, 'line_group.deleted');
return deleted;
});
return this.toSummary(group);
return this.toSummary(group, 0);
}
async addItem(input: AddLineGroupItemInput): Promise<LineGroupSummary> {
@@ -342,6 +345,27 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
return this.get(lineGroupIdValue);
}
private async customerGatewayUsageCounts(lineGroupIds: string[]): Promise<Map<string, number>> {
if (lineGroupIds.length === 0) {
return new Map();
}
const rows = await this.prisma.customerGatewayPolicy.groupBy({
by: ['lineGroupId', 'gatewayId'],
where: {
lineGroupId: { in: lineGroupIds },
deletedAt: null
}
});
const counts = new Map<string, number>();
for (const row of rows) {
if (!row.lineGroupId) {
continue;
}
counts.set(row.lineGroupId, (counts.get(row.lineGroupId) ?? 0) + 1);
}
return counts;
}
private includeItem() {
return {
vendorGateway: {
@@ -427,7 +451,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
});
}
private toSummary(group: LineGroupRecord): LineGroupSummary {
private toSummary(group: LineGroupRecord, customerGatewayCount = 0): LineGroupSummary {
const items = group.items.map((item) => this.toItemSummary(item));
const enabledItems = items.filter((item) => item.status === 'ENABLED');
return {
@@ -440,6 +464,7 @@ export class PrismaLandingLineGroupsRepository implements LandingLineGroupsRepos
concurrencyCapSum: enabledItems.reduce((sum, item) => sum + item.concurrencyCap, 0),
gatewayConcurrencyLimitSum: enabledItems.reduce((sum, item) => sum + item.vendorGatewayConcurrencyLimit, 0),
policyCount: group._count.policies,
customerGatewayCount,
items,
createdAt: group.createdAt,
updatedAt: group.updatedAt
@@ -0,0 +1,63 @@
import { Body, Controller, Get, Inject, 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 { NumberLibraryService } from './number-library.service.js';
@ApiTags('number-library')
@Controller('number-library')
export class NumberLibraryController {
constructor(@Inject(NumberLibraryService) private readonly numberLibraryService: NumberLibraryService) {}
@Get('cities')
@RequirePermissions('number_library.view')
listCities(@Query() query: unknown) {
return this.numberLibraryService.listCities(query as never);
}
@Post('cities/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_cities', objectType: 'geo_city' })
importCities(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importCities(body as never, currentUser?.id);
}
@Get('phone-segments')
@RequirePermissions('number_library.view')
listPhoneSegments(@Query() query: unknown) {
return this.numberLibraryService.listPhoneSegments(query as never);
}
@Post('phone-segments/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_phone_segments', objectType: 'phone_number_segment' })
importPhoneSegments(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importPhoneSegments(body as never, currentUser?.id);
}
@Get('area-codes')
@RequirePermissions('number_library.view')
listAreaCodes(@Query() query: unknown) {
return this.numberLibraryService.listAreaCodes(query as never);
}
@Post('area-codes/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_area_codes', objectType: 'phone_area_code' })
importAreaCodes(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importAreaCodes(body as never, currentUser?.id);
}
@Get('carrier-prefix-rules')
@RequirePermissions('number_library.view')
listCarrierPrefixRules(@Query() query: unknown) {
return this.numberLibraryService.listCarrierPrefixRules(query as never);
}
@Post('carrier-prefix-rules/import')
@RequirePermissions('number_library.manage')
@AuditAction({ module: 'number_library', action: 'import_carrier_prefix_rules', objectType: 'carrier_prefix_rule' })
importCarrierPrefixRules(@Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.numberLibraryService.importCarrierPrefixRules(body as never, currentUser?.id);
}
}
@@ -0,0 +1,240 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
import type { CurrentUser } from '../security/security.metadata.js';
import {
NUMBER_LIBRARY_REPOSITORY,
type AreaCodeInput,
type CarrierPrefixInput,
type CityInput,
type ImportResult,
type NumberCarrier,
type NumberLibraryRepository,
type PageQuery,
type PhoneSegmentInput
} from './number-library.repository.js';
class MemoryIdentityRepository implements IdentityRepository {
users = new Map<string, CurrentUser>();
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
return this.users.get(userId) ?? null;
}
}
class MemoryAuditRepository implements AuditRepository {
entries: AuditEntryInput[] = [];
async write(input: AuditEntryInput): Promise<void> {
this.entries.push(input);
}
}
class MemoryNumberLibraryRepository implements NumberLibraryRepository {
cities: CityInput[] = [];
segments: PhoneSegmentInput[] = [];
areaCodes: AreaCodeInput[] = [];
carrierRules: CarrierPrefixInput[] = [];
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
return this.page(this.cities.filter((city) => !query.keyword || city.cityName.includes(query.keyword) || city.provinceName.includes(query.keyword)), query);
}
async importCities(items: CityInput[]): Promise<ImportResult> {
this.upsert(this.cities, items, (item) => item.code);
return { imported: items.length };
}
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
return this.page(
this.segments.filter(
(item) =>
(!query.segment7 || item.segment7.startsWith(query.segment7)) &&
(!query.cityCode || item.cityCode === query.cityCode) &&
(!query.carrier || item.carrier === query.carrier)
),
query
);
}
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
this.upsert(this.segments, items, (item) => item.segment7);
return { imported: items.length };
}
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
return this.page(
this.areaCodes.filter((item) => (!query.areaCode || item.areaCode.startsWith(query.areaCode)) && (!query.cityCode || item.cityCode === query.cityCode)),
query
);
}
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
this.upsert(this.areaCodes, items, (item) => item.areaCode);
return { imported: items.length };
}
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
return this.page(
this.carrierRules.filter((item) => (!query.prefix || item.prefix.startsWith(query.prefix)) && (!query.carrier || item.carrier === query.carrier)),
query
);
}
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
this.upsert(this.carrierRules, items, (item) => item.prefix);
return { imported: items.length };
}
private upsert<T>(target: T[], items: T[], key: (item: T) => string): void {
for (const item of items) {
const index = target.findIndex((existing) => key(existing) === key(item));
if (index >= 0) {
target[index] = item;
} else {
target.push(item);
}
}
}
private page(items: unknown[], query: PageQuery): { items: unknown[]; total: number } {
return { items: items.slice(query.skip, query.skip + query.take), total: items.length };
}
}
describe('number library API', () => {
let app: NestFastifyApplication;
let audit: MemoryAuditRepository;
const tokenFor = (userId: string) =>
signAccessToken(
{
sub: userId,
username: userId,
roles: ['test'],
typ: 'access'
},
{
secret: 'test-only-access-token-secret-min-32-bytes',
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
ttlSeconds: 900
}
);
beforeAll(async () => {
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
const identities = new MemoryIdentityRepository();
audit = new MemoryAuditRepository();
identities.users.set('usr_ops', {
id: 'usr_ops',
username: 'ops',
roles: ['运营管理员'],
permissions: ['number_library.view', 'number_library.manage'] as PermissionKey[]
});
identities.users.set('usr_viewer', {
id: 'usr_viewer',
username: 'viewer',
roles: ['只读'],
permissions: ['number_library.view'] as PermissionKey[]
});
const { AppModule } = await import('../app.module.js');
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(IDENTITY_REPOSITORY)
.useValue(identities)
.overrideProvider(AUDIT_REPOSITORY)
.useValue(audit)
.overrideProvider(NUMBER_LIBRARY_REPOSITORY)
.useValue(new MemoryNumberLibraryRepository())
.compile();
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
app.setGlobalPrefix('api/v2');
await app.init();
await app.getHttpAdapter().getInstance().ready();
}, 30000);
afterAll(async () => {
await app?.close();
});
it('rejects imports without number_library.manage', async () => {
await request(app.getHttpServer())
.post('/api/v2/number-library/cities/import')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.send({ items: [] })
.expect(403);
});
it('imports and lists cities, phone segments, area codes, and carrier rules', async () => {
await request(app.getHttpServer())
.post('/api/v2/number-library/cities/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ code: '340100', provinceCode: '340000', provinceName: '安徽', cityCode: '340100', cityName: '合肥', cityLevel: '地级市' }] })
.expect(201)
.expect((response) => expect(response.body).toEqual({ imported: 1 }));
await request(app.getHttpServer())
.post('/api/v2/number-library/phone-segments/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ segment7: '1385510', cityCode: '340100', provinceName: '安徽', cityName: '合肥', carrier: 'MOBILE', batchId: 'batch_test' }] })
.expect(201);
await request(app.getHttpServer())
.post('/api/v2/number-library/area-codes/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ areaCode: '0551', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
.expect(201);
await request(app.getHttpServer())
.post('/api/v2/number-library/carrier-prefix-rules/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ prefix: '138', carrier: 'MOBILE', priority: 10 }] })
.expect(201);
await request(app.getHttpServer())
.get('/api/v2/number-library/phone-segments?segment7=138&carrier=MOBILE')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.expect(200)
.expect((response) => {
expect(response.body.total).toBe(1);
expect(response.body.items[0]).toMatchObject({ segment7: '1385510', cityName: '合肥', carrier: 'MOBILE' });
});
await request(app.getHttpServer())
.get('/api/v2/number-library/area-codes?areaCode=0551')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.expect(200)
.expect((response) => expect(response.body.items[0]).toMatchObject({ areaCode: '0551', cityName: '合肥' }));
await request(app.getHttpServer())
.get('/api/v2/number-library/carrier-prefix-rules?prefix=138')
.set('Authorization', `Bearer ${tokenFor('usr_viewer')}`)
.expect(200)
.expect((response) => expect(response.body.items[0]).toMatchObject({ prefix: '138', carrier: 'MOBILE' }));
expect(audit.entries.some((entry) => entry.module === 'number_library' && entry.action === 'import_cities')).toBe(true);
});
it('validates import batch shape and segment digits', async () => {
await request(app.getHttpServer())
.post('/api/v2/number-library/phone-segments/import')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.send({ items: [{ segment7: '13855', cityCode: '340100', provinceName: '安徽', cityName: '合肥' }] })
.expect(400)
.expect((response) => expect(response.body).toMatchObject({ code: 'DIGITS_INVALID' }));
});
});
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { NumberLibraryController } from './number-library.controller.js';
import { NUMBER_LIBRARY_REPOSITORY, PrismaNumberLibraryRepository } from './number-library.repository.js';
import { NumberLibraryService } from './number-library.service.js';
@Module({
controllers: [NumberLibraryController],
providers: [
NumberLibraryService,
{
provide: NUMBER_LIBRARY_REPOSITORY,
useClass: PrismaNumberLibraryRepository
}
],
exports: [NumberLibraryService]
})
export class NumberLibraryModule {}
@@ -0,0 +1,310 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import crypto from 'node:crypto';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export type NumberCarrier = 'MOBILE' | 'UNICOM' | 'TELECOM' | 'BROADCAST' | 'MVNO' | 'UNKNOWN';
export interface PageQuery {
keyword?: string;
take: number;
skip: number;
}
export interface CityInput {
code: string;
provinceCode: string;
provinceName: string;
cityCode: string;
cityName: string;
cityLevel: string;
status?: 'ENABLED' | 'DISABLED';
effectiveFrom?: Date | null;
effectiveTo?: Date | null;
actorId?: string;
}
export interface PhoneSegmentInput {
segment7: string;
cityCode: string;
provinceName: string;
cityName: string;
carrier?: NumberCarrier;
source?: string | null;
batchId?: string | null;
effectiveFrom?: Date | null;
effectiveTo?: Date | null;
actorId?: string;
}
export interface AreaCodeInput {
areaCode: string;
cityCode: string;
provinceName: string;
cityName: string;
source?: string | null;
batchId?: string | null;
actorId?: string;
}
export interface CarrierPrefixInput {
prefix: string;
carrier: NumberCarrier;
priority: number;
source?: string | null;
batchId?: string | null;
effectiveFrom?: Date | null;
effectiveTo?: Date | null;
actorId?: string;
}
export interface ImportResult {
imported: number;
}
export const NUMBER_LIBRARY_REPOSITORY = Symbol('NUMBER_LIBRARY_REPOSITORY');
function outboxId(): string {
return `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 36)}`;
}
export interface NumberLibraryRepository {
listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }>;
importCities(items: CityInput[]): Promise<ImportResult>;
listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult>;
listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }>;
importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult>;
listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }>;
importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult>;
}
@Injectable()
export class PrismaNumberLibraryRepository implements NumberLibraryRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async listCities(query: PageQuery): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.GeoCityWhereInput = {
deletedAt: null,
OR: query.keyword
? [
{ provinceName: { contains: query.keyword } },
{ cityName: { contains: query.keyword } },
{ cityCode: { contains: query.keyword } },
{ provinceCode: { contains: query.keyword } }
]
: undefined
};
const [items, total] = await this.prisma.$transaction([
this.prisma.geoCity.findMany({ where, orderBy: [{ provinceCode: 'asc' }, { cityCode: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.geoCity.count({ where })
]);
return { items, total };
}
async importCities(items: CityInput[]): Promise<ImportResult> {
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.geoCity.upsert({
where: { code: item.code },
update: {
provinceCode: item.provinceCode,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
cityLevel: item.cityLevel,
status: item.status ?? 'ENABLED',
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
code: item.code,
provinceCode: item.provinceCode,
provinceName: item.provinceName,
cityCode: item.cityCode,
cityName: item.cityName,
cityLevel: item.cityLevel,
status: item.status ?? 'ENABLED',
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'geo_city.changed', items.length);
});
return { imported: items.length };
}
async listPhoneSegments(query: PageQuery & { segment7?: string; cityCode?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.PhoneNumberSegmentWhereInput = {
deletedAt: null,
segment7: query.segment7 ? { startsWith: query.segment7 } : undefined,
cityCode: query.cityCode,
carrier: query.carrier,
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
};
const [items, total] = await this.prisma.$transaction([
this.prisma.phoneNumberSegment.findMany({ where, orderBy: [{ segment7: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.phoneNumberSegment.count({ where })
]);
return { items, total };
}
async importPhoneSegments(items: PhoneSegmentInput[]): Promise<ImportResult> {
await this.ensureCities(items.map((item) => item.cityCode));
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.phoneNumberSegment.upsert({
where: { segment7: item.segment7 },
update: {
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
carrier: item.carrier ?? 'UNKNOWN',
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
segment7: item.segment7,
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
carrier: item.carrier ?? 'UNKNOWN',
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'phone_number_segment.changed', items.length);
});
return { imported: items.length };
}
async listAreaCodes(query: PageQuery & { areaCode?: string; cityCode?: string }): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.PhoneAreaCodeWhereInput = {
deletedAt: null,
areaCode: query.areaCode ? { startsWith: query.areaCode } : undefined,
cityCode: query.cityCode,
OR: query.keyword ? [{ provinceName: { contains: query.keyword } }, { cityName: { contains: query.keyword } }] : undefined
};
const [items, total] = await this.prisma.$transaction([
this.prisma.phoneAreaCode.findMany({ where, orderBy: [{ areaCode: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.phoneAreaCode.count({ where })
]);
return { items, total };
}
async importAreaCodes(items: AreaCodeInput[]): Promise<ImportResult> {
await this.ensureCities(items.map((item) => item.cityCode));
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.phoneAreaCode.upsert({
where: { areaCode: item.areaCode },
update: {
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
source: item.source,
batchId: item.batchId,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
areaCode: item.areaCode,
cityCode: item.cityCode,
provinceName: item.provinceName,
cityName: item.cityName,
source: item.source,
batchId: item.batchId,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'phone_area_code.changed', items.length);
});
return { imported: items.length };
}
async listCarrierPrefixRules(query: PageQuery & { prefix?: string; carrier?: NumberCarrier }): Promise<{ items: unknown[]; total: number }> {
const where: Prisma.CarrierPrefixRuleWhereInput = {
deletedAt: null,
prefix: query.prefix ? { startsWith: query.prefix } : undefined,
carrier: query.carrier
};
const [items, total] = await this.prisma.$transaction([
this.prisma.carrierPrefixRule.findMany({ where, orderBy: [{ prefix: 'asc' }], take: query.take, skip: query.skip }),
this.prisma.carrierPrefixRule.count({ where })
]);
return { items, total };
}
async importCarrierPrefixRules(items: CarrierPrefixInput[]): Promise<ImportResult> {
await this.prisma.$transaction(async (tx) => {
for (const item of items) {
await tx.carrierPrefixRule.upsert({
where: { prefix: item.prefix },
update: {
carrier: item.carrier,
priority: item.priority,
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
updatedBy: item.actorId,
version: { increment: 1 },
deletedAt: null
},
create: {
prefix: item.prefix,
carrier: item.carrier,
priority: item.priority,
source: item.source,
batchId: item.batchId,
effectiveFrom: item.effectiveFrom,
effectiveTo: item.effectiveTo,
createdBy: item.actorId,
updatedBy: item.actorId
}
});
}
await this.enqueueConfigOutbox(tx, 'carrier_prefix_rule.changed', items.length);
});
return { imported: items.length };
}
private async enqueueConfigOutbox(tx: Prisma.TransactionClient, eventType: string, itemCount: number): Promise<void> {
await tx.outboxEvent.create({
data: {
id: outboxId(),
aggregateType: 'number_library_config',
aggregateId: 'number-library',
eventType,
payload: { eventType, itemCount }
}
});
}
private async ensureCities(cityCodes: string[]): Promise<void> {
const uniqueCodes = [...new Set(cityCodes)];
const count = await this.prisma.geoCity.count({ where: { cityCode: { in: uniqueCodes }, deletedAt: null } });
if (count !== uniqueCodes.length) {
throw new BadRequestException({ code: 'CITY_NOT_FOUND', message: 'One or more city codes are invalid.' });
}
}
}
@@ -0,0 +1,186 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import {
NUMBER_LIBRARY_REPOSITORY,
type AreaCodeInput,
type CarrierPrefixInput,
type CityInput,
type NumberCarrier,
type NumberLibraryRepository,
type PageQuery,
type PhoneSegmentInput
} from './number-library.repository.js';
const MAX_IMPORT_ITEMS = 1000;
type QueryRecord = Record<string, unknown>;
@Injectable()
export class NumberLibraryService {
constructor(@Inject(NUMBER_LIBRARY_REPOSITORY) private readonly numberLibrary: NumberLibraryRepository) {}
listCities(query: QueryRecord) {
return this.numberLibrary.listCities(this.page(query));
}
importCities(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
code: this.limitedString(item.code, 'code', 12),
provinceCode: this.limitedString(item.provinceCode, 'provinceCode', 12),
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
cityName: this.limitedString(item.cityName, 'cityName', 80),
cityLevel: this.limitedString(item.cityLevel, 'cityLevel', 32),
status: item.status === undefined ? 'ENABLED' : this.status(item.status),
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
actorId
})) satisfies CityInput[];
return this.numberLibrary.importCities(items);
}
listPhoneSegments(query: QueryRecord) {
return this.numberLibrary.listPhoneSegments({
...this.page(query),
segment7: query.segment7 === undefined ? undefined : this.digits(query.segment7, 'segment7', 1, 7),
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12),
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
});
}
importPhoneSegments(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
segment7: this.digits(item.segment7, 'segment7', 7, 7),
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
cityName: this.limitedString(item.cityName, 'cityName', 80),
carrier: item.carrier === undefined ? 'UNKNOWN' : this.carrier(item.carrier),
source: this.optionalLimitedString(item.source, 'source', 120),
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
actorId
})) satisfies PhoneSegmentInput[];
return this.numberLibrary.importPhoneSegments(items);
}
listAreaCodes(query: QueryRecord) {
return this.numberLibrary.listAreaCodes({
...this.page(query),
areaCode: query.areaCode === undefined ? undefined : this.digits(query.areaCode, 'areaCode', 2, 8),
cityCode: query.cityCode === undefined ? undefined : this.limitedString(query.cityCode, 'cityCode', 12)
});
}
importAreaCodes(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
areaCode: this.digits(item.areaCode, 'areaCode', 2, 8),
cityCode: this.limitedString(item.cityCode, 'cityCode', 12),
provinceName: this.limitedString(item.provinceName, 'provinceName', 80),
cityName: this.limitedString(item.cityName, 'cityName', 80),
source: this.optionalLimitedString(item.source, 'source', 120),
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
actorId
})) satisfies AreaCodeInput[];
return this.numberLibrary.importAreaCodes(items);
}
listCarrierPrefixRules(query: QueryRecord) {
return this.numberLibrary.listCarrierPrefixRules({
...this.page(query),
prefix: query.prefix === undefined ? undefined : this.digits(query.prefix, 'prefix', 1, 4),
carrier: query.carrier === undefined ? undefined : this.carrier(query.carrier)
});
}
importCarrierPrefixRules(body: QueryRecord, actorId?: string) {
const items = this.items(body).map((item) => ({
prefix: this.digits(item.prefix, 'prefix', 3, 4),
carrier: this.carrier(item.carrier),
priority: this.integer(item.priority ?? 100, 'priority', 1, 10_000),
source: this.optionalLimitedString(item.source, 'source', 120),
batchId: this.optionalLimitedString(item.batchId, 'batchId', 64),
effectiveFrom: this.optionalDate(item.effectiveFrom, 'effectiveFrom'),
effectiveTo: this.optionalDate(item.effectiveTo, 'effectiveTo'),
actorId
})) satisfies CarrierPrefixInput[];
return this.numberLibrary.importCarrierPrefixRules(items);
}
private page(query: QueryRecord): PageQuery {
return {
keyword: query.keyword === undefined ? undefined : this.limitedString(query.keyword, 'keyword', 80),
take: this.integer(query.take ?? 50, 'take', 1, 500),
skip: this.integer(query.skip ?? 0, 'skip', 0, 1_000_000)
};
}
private items(body: QueryRecord): QueryRecord[] {
if (!Array.isArray(body.items) || body.items.length === 0 || body.items.length > MAX_IMPORT_ITEMS) {
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: `items must contain 1-${MAX_IMPORT_ITEMS} records.` });
}
if (!body.items.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
throw new BadRequestException({ code: 'IMPORT_ITEMS_INVALID', message: 'items must be objects.' });
}
return body.items as QueryRecord[];
}
private limitedString(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 optionalLimitedString(value: unknown, field: string, maxLength: number): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
return this.limitedString(value, field, maxLength);
}
private digits(value: unknown, field: string, minLength: number, maxLength: number): string {
const text = this.limitedString(value, field, maxLength);
if (!new RegExp(`^\\d{${minLength},${maxLength}}$`).test(text)) {
throw new BadRequestException({ code: 'DIGITS_INVALID', message: `${field} must contain ${minLength}-${maxLength} digits.` });
}
return text;
}
private integer(value: unknown, field: string, min: number, max: number): number {
const number = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
if (!Number.isInteger(number) || number < min || number > max) {
throw new BadRequestException({ code: 'INTEGER_INVALID', message: `${field} is invalid.` });
}
return number;
}
private carrier(value: unknown): NumberCarrier {
if (value === 'MOBILE' || value === 'UNICOM' || value === 'TELECOM' || value === 'BROADCAST' || value === 'MVNO' || value === 'UNKNOWN') {
return value;
}
throw new BadRequestException({ code: 'CARRIER_INVALID', message: 'Carrier is invalid.' });
}
private status(value: unknown): 'ENABLED' | 'DISABLED' {
if (value === 'ENABLED' || value === 'DISABLED') {
return value;
}
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
}
private optionalDate(value: unknown, field: string): Date | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
return date;
}
}
@@ -234,7 +234,10 @@ export class PrismaRechargesRepository implements RechargesRepository {
}
private async listCustomerRecharges(accountId: string | undefined, take: number, skip: number): Promise<[RechargeSummary[], number]> {
const where = accountId ? { customerId: accountId } : {};
const where: Prisma.CustomerRechargeWhereInput = {
...(accountId ? { customerId: accountId } : {}),
NOT: [{ createdBy: 'worker-cdr' }, { idempotencyKey: { startsWith: 'cdr:' } }, { remark: { startsWith: 'CDR_CHARGE:' } }]
};
const [items, total] = await this.prisma.$transaction([
this.prisma.customerRecharge.findMany({
where,
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } 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';
@@ -29,6 +29,13 @@ export class RolesController {
return this.rolesService.updateRole(id, body as never, currentUser?.id);
}
@Delete('roles/:id')
@RequirePermissions('roles.manage')
@AuditAction({ module: 'roles', action: 'delete', objectType: 'role', objectIdParam: 'id' })
removeRole(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.rolesService.removeRole(id, currentUser?.id);
}
@Get('permissions')
@RequirePermissions('roles.view')
listPermissions() {
@@ -41,6 +41,7 @@ export interface RolesRepository {
listPermissions(): Promise<PermissionSummary[]>;
createRole(input: CreateRoleInput): Promise<RoleSummary>;
updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary>;
softDeleteRole(roleId: string, actorId?: string): Promise<RoleSummary>;
}
export const ROLES_REPOSITORY = Symbol('ROLES_REPOSITORY');
@@ -169,6 +170,54 @@ export class PrismaRolesRepository implements RolesRepository {
return this.toSummary(role);
}
async softDeleteRole(roleIdValue: string, actorId?: string): Promise<RoleSummary> {
const role = await this.prisma.$transaction(async (tx) => {
const existing = await tx.role.findUnique({ where: { id: roleIdValue } });
if (!existing || existing.deletedAt) {
throw new NotFoundException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' });
}
if (existing.builtIn) {
throw new ForbiddenException({
code: 'BUILT_IN_ROLE_PROTECTED',
message: 'Built-in roles are protected.'
});
}
const linkedUsers = await tx.userRole.count({
where: {
roleId: roleIdValue,
user: { deletedAt: null }
}
});
if (linkedUsers > 0) {
throw new BadRequestException({
code: 'ROLE_HAS_USERS',
message: 'Role with active users cannot be deleted.'
});
}
return tx.role.update({
where: { id: roleIdValue },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId,
version: { increment: 1 }
},
include: {
permissions: true,
_count: {
select: { userRoles: true }
}
}
});
});
return this.toSummary(role);
}
private async ensurePermissions(permissionIds: string[]): Promise<void> {
const uniquePermissionIds = [...new Set(permissionIds)];
const count = await this.prisma.permission.count({
@@ -49,6 +49,10 @@ export class RolesService {
return this.roles.updateRole(roleId, input);
}
removeRole(roleId: string, actorId?: string) {
return this.roles.softDeleteRole(roleId, actorId);
}
private requiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
@@ -83,6 +83,10 @@ class MemoryUsersRepository implements UsersRepository {
return this.summary(userId, 'operator', ['ROLE_SUPER_ADMIN'], true);
}
async softDelete(userId: string): Promise<UserSummary> {
return this.summary(userId, 'operator', []);
}
private summary(id: string, username: string, roleIds: string[], requirePasswordChange = false): UserSummary {
return {
id,
@@ -125,6 +129,10 @@ class MemoryRolesRepository implements RolesRepository {
return this.role(roleId, roleId === 'ROLE_SUPER_ADMIN', input.permissionIds ?? ['users.manage']);
}
async softDeleteRole(roleId: string): Promise<RoleSummary> {
return this.role(roleId, false, []);
}
private role(id: string, builtIn: boolean, permissionIds: string[], name = 'role'): RoleSummary {
return {
id,
@@ -0,0 +1,289 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import request from 'supertest';
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from './audit/audit.repository.js';
import { IDENTITY_REPOSITORY, type IdentityRepository } from './security/identity.repository.js';
import type { CurrentUser } from './security/security.metadata.js';
import {
ROLES_REPOSITORY,
type CreateRoleInput,
type PermissionSummary,
type RoleSummary,
type RolesRepository,
type UpdateRoleInput
} from './roles/roles.repository.js';
import {
USERS_REPOSITORY,
type CreateUserInput,
type UpdateUserInput,
type UserSummary,
type UsersRepository
} from './users/users.repository.js';
class MemoryIdentityRepository implements IdentityRepository {
users = new Map<string, CurrentUser>();
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
return this.users.get(userId) ?? null;
}
}
class MemoryAuditRepository implements AuditRepository {
entries: AuditEntryInput[] = [];
async write(input: AuditEntryInput): Promise<void> {
this.entries.push(input);
}
}
class MemoryRolesRepository implements RolesRepository {
private readonly roles = new Map<string, RoleSummary>();
private readonly permissions: PermissionSummary[] = [{ id: 'users.manage', module: 'users', action: 'manage', description: null }];
constructor() {
this.roles.set('rol_builtin', this.summary({ id: 'rol_builtin', name: '系统管理员', builtIn: true, userCount: 1 }));
this.roles.set('rol_used', this.summary({ id: 'rol_used', name: '运营', userCount: 1 }));
this.roles.set('rol_blocked', this.summary({ id: 'rol_blocked', name: '仍在使用', userCount: 1 }));
this.roles.set('rol_empty', this.summary({ id: 'rol_empty', name: '空角色', userCount: 0 }));
}
async listRoles(): Promise<RoleSummary[]> {
return [...this.roles.values()];
}
async listPermissions(): Promise<PermissionSummary[]> {
return this.permissions;
}
async createRole(input: CreateRoleInput): Promise<RoleSummary> {
const role = this.summary({ id: 'rol_created', name: input.name, description: input.description ?? null });
this.roles.set(role.id, role);
return role;
}
async updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSummary> {
const current = this.roles.get(roleId) ?? this.summary({ id: roleId, name: 'Missing Role' });
const role: RoleSummary = {
...current,
name: input.name ?? current.name,
description: input.description === undefined ? current.description : input.description,
status: input.status ?? current.status,
permissionIds: input.permissionIds ?? current.permissionIds,
updatedAt: new Date('2026-06-24T08:00:00.000Z')
};
this.roles.set(role.id, role);
return role;
}
async softDeleteRole(roleId: string): Promise<RoleSummary> {
const role = this.roles.get(roleId);
if (!role) {
throw new BadRequestException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' });
}
if (role.builtIn) {
throw new ForbiddenException({ code: 'BUILT_IN_ROLE_PROTECTED', message: 'Built-in roles are protected.' });
}
if (role.userCount > 0) {
throw new BadRequestException({ code: 'ROLE_HAS_USERS', message: 'Role with active users cannot be deleted.' });
}
const deleted: RoleSummary = { ...role, status: 'DISABLED', updatedAt: new Date('2026-06-24T08:00:00.000Z') };
this.roles.delete(roleId);
return deleted;
}
decrementUserCount(roleIds: string[]) {
for (const roleId of roleIds) {
const role = this.roles.get(roleId);
if (role) {
this.roles.set(roleId, { ...role, userCount: Math.max(0, role.userCount - 1) });
}
}
}
private summary(input: { id: string; name: string; description?: string | null; builtIn?: boolean; userCount?: number }): RoleSummary {
return {
id: input.id,
name: input.name,
description: input.description ?? null,
builtIn: input.builtIn ?? false,
status: 'ENABLED',
permissionIds: ['users.manage'],
userCount: input.userCount ?? 0,
createdAt: new Date('2026-06-24T07:00:00.000Z'),
updatedAt: new Date('2026-06-24T07:00:00.000Z')
};
}
}
class MemoryUsersRepository implements UsersRepository {
private readonly users = new Map<string, UserSummary>();
constructor(private readonly roles: MemoryRolesRepository) {
this.users.set('usr_seed', this.summary({ id: 'usr_seed', username: 'seed', displayName: 'Seed User', roleIds: ['rol_used'], roles: ['运营'] }));
}
async list(): Promise<UserSummary[]> {
return [...this.users.values()];
}
async create(input: CreateUserInput): Promise<UserSummary> {
const user = this.summary({ id: 'usr_created', username: input.username, displayName: input.displayName, roleIds: input.roleIds });
this.users.set(user.id, user);
return user;
}
async update(userId: string, input: UpdateUserInput): Promise<UserSummary> {
const current = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
const updated: UserSummary = {
...current,
displayName: input.displayName ?? current.displayName,
phone: input.phone === undefined ? current.phone : input.phone,
email: input.email === undefined ? current.email : input.email,
status: input.status ?? current.status,
roleIds: input.roleIds ?? current.roleIds,
updatedAt: new Date('2026-06-24T08:00:00.000Z')
};
this.users.set(userId, updated);
return updated;
}
async resetPassword(userId: string): Promise<UserSummary> {
return this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
}
async softDelete(userId: string): Promise<UserSummary> {
const user = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
const deleted: UserSummary = { ...user, status: 'DISABLED', roleIds: [], roles: [], updatedAt: new Date('2026-06-24T08:00:00.000Z') };
this.users.delete(userId);
this.roles.decrementUserCount(user.roleIds);
return deleted;
}
private summary(input: { id: string; username: string; displayName: string; roleIds?: string[]; roles?: string[] }): UserSummary {
return {
id: input.id,
username: input.username,
displayName: input.displayName,
phone: null,
email: null,
status: 'ENABLED',
requirePasswordChange: false,
lastLoginAt: null,
roles: input.roles ?? [],
roleIds: input.roleIds ?? [],
createdAt: new Date('2026-06-24T07:00:00.000Z'),
updatedAt: new Date('2026-06-24T07:00:00.000Z')
};
}
}
describe('S40 users and roles delete API', () => {
let app: NestFastifyApplication;
let audit: MemoryAuditRepository;
const tokenFor = (userId: string) =>
signAccessToken(
{
sub: userId,
username: userId,
roles: ['test'],
typ: 'access'
},
{
secret: 'test-only-access-token-secret-min-32-bytes',
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
ttlSeconds: 900
}
);
beforeAll(async () => {
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
const identities = new MemoryIdentityRepository();
audit = new MemoryAuditRepository();
const roles = new MemoryRolesRepository();
identities.users.set('usr_ops', {
id: 'usr_ops',
username: 'ops',
roles: ['运营管理员'],
permissions: ['users.view', 'users.manage', 'roles.view', 'roles.manage'] as PermissionKey[]
});
identities.users.set('usr_viewer', {
id: 'usr_viewer',
username: 'viewer',
roles: ['只读'],
permissions: ['users.view', 'roles.view'] as PermissionKey[]
});
const { AppModule } = await import('./app.module.js');
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(IDENTITY_REPOSITORY)
.useValue(identities)
.overrideProvider(AUDIT_REPOSITORY)
.useValue(audit)
.overrideProvider(ROLES_REPOSITORY)
.useValue(roles)
.overrideProvider(USERS_REPOSITORY)
.useValue(new MemoryUsersRepository(roles))
.compile();
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false }));
app.setGlobalPrefix('api/v2');
await app.init();
await app.getHttpAdapter().getInstance().ready();
}, 30000);
afterAll(async () => {
await app?.close();
});
it('rejects delete operations without manage permissions', async () => {
await request(app.getHttpServer()).delete('/api/v2/users/usr_seed').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403);
await request(app.getHttpServer()).delete('/api/v2/roles/rol_empty').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403);
});
it('soft deletes users with an audit entry', async () => {
await request(app.getHttpServer())
.delete('/api/v2/users/usr_seed')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.expect(200)
.expect((response) => {
expect(response.body).toMatchObject({ id: 'usr_seed', status: 'DISABLED', roleIds: [] });
});
expect(audit.entries.some((entry) => entry.module === 'users' && entry.action === 'delete' && entry.objectId === 'usr_seed')).toBe(true);
});
it('protects built-in and still-used roles, then deletes an empty custom role with audit', async () => {
await request(app.getHttpServer()).delete('/api/v2/roles/rol_builtin').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(403);
await request(app.getHttpServer())
.delete('/api/v2/roles/rol_blocked')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.expect(400)
.expect((response) => {
expect(response.body).toMatchObject({ code: 'ROLE_HAS_USERS' });
});
await request(app.getHttpServer())
.delete('/api/v2/roles/rol_empty')
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
.expect(200)
.expect((response) => {
expect(response.body).toMatchObject({ id: 'rol_empty', status: 'DISABLED' });
});
expect(audit.entries.some((entry) => entry.module === 'roles' && entry.action === 'delete' && entry.objectId === 'rol_empty')).toBe(true);
});
});
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post } 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';
@@ -35,4 +35,11 @@ export class UsersController {
resetPassword(@Param('id') id: string, @Body() body: unknown, @CurrentUserParam() currentUser?: CurrentUser) {
return this.usersService.resetPassword(id, body as never, currentUser?.id);
}
@Delete(':id')
@RequirePermissions('users.manage')
@AuditAction({ module: 'users', action: 'delete', objectType: 'user', objectIdParam: 'id' })
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.usersService.remove(id, currentUser?.id);
}
}
+53 -15
View File
@@ -43,6 +43,7 @@ export interface UsersRepository {
create(input: CreateUserInput): Promise<UserSummary>;
update(userId: string, input: UpdateUserInput): Promise<UserSummary>;
resetPassword(userId: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary>;
softDelete(userId: string, actorId?: string): Promise<UserSummary>;
}
export const USERS_REPOSITORY = Symbol('USERS_REPOSITORY');
@@ -167,24 +168,61 @@ export class PrismaUsersRepository implements UsersRepository {
}
async resetPassword(userIdValue: string, passwordHash: string, passwordAlgo: string, actorId?: string): Promise<UserSummary> {
const user = await this.prisma.user.update({
where: { id: userIdValue },
data: {
passwordHash,
passwordAlgo,
requirePasswordChange: true,
failedLoginCount: 0,
lockedUntil: null,
updatedBy: actorId,
version: { increment: 1 }
},
include: {
userRoles: {
include: {
role: true
const user = await this.prisma.$transaction(async (tx) => {
const exists = await tx.user.findUnique({ where: { id: userIdValue } });
if (!exists || exists.deletedAt) {
throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' });
}
return tx.user.update({
where: { id: userIdValue },
data: {
passwordHash,
passwordAlgo,
requirePasswordChange: true,
failedLoginCount: 0,
lockedUntil: null,
updatedBy: actorId,
version: { increment: 1 }
},
include: {
userRoles: {
include: {
role: true
}
}
}
});
});
return this.toSummary(user);
}
async softDelete(userIdValue: string, actorId?: string): Promise<UserSummary> {
const user = await this.prisma.$transaction(async (tx) => {
const exists = await tx.user.findUnique({ where: { id: userIdValue } });
if (!exists || exists.deletedAt) {
throw new NotFoundException({ code: 'USER_NOT_FOUND', message: 'User not found.' });
}
await tx.userRole.deleteMany({ where: { userId: userIdValue } });
return tx.user.update({
where: { id: userIdValue },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId,
version: { increment: 1 }
},
include: {
userRoles: {
include: {
role: true
}
}
}
});
});
return this.toSummary(user);
@@ -68,6 +68,10 @@ export class UsersService {
return this.users.resetPassword(userId, await hashPasswordArgon2id(this.password(body.password)), PASSWORD_ALGO_ARGON2ID, actorId);
}
remove(userId: string, actorId?: string): Promise<UserSummary> {
return this.users.softDelete(userId, actorId);
}
private requiredString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new BadRequestException({ code: 'VALIDATION_ERROR', message: `${field} is required.` });
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Inject, Param, Patch, Post, Query } from '@nestjs/common';
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';
@@ -48,4 +48,11 @@ export class VendorGatewaysController {
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.vendorGatewaysService.disable(id, currentUser?.id);
}
@Delete(':id')
@RequirePermissions('vendor_gateways.manage')
@AuditAction({ module: 'vendor_gateways', action: 'delete', objectType: 'vendor_gateway', objectIdParam: 'id' })
remove(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
return this.vendorGatewaysService.remove(id, currentUser?.id);
}
}
@@ -114,6 +114,14 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
return this.update(gatewayId, { status });
}
async softDelete(gatewayId: string): Promise<VendorGatewaySummary> {
const current = await this.get(gatewayId);
const deleted = this.summary({ ...current, status: 'DISABLED' });
this.gateways.delete(gatewayId);
this.outboxEvents += 1;
return deleted;
}
private summary(input: Partial<VendorGatewaySummary> & { id: string; name: string }): VendorGatewaySummary {
const cycleRate = input.cycleRate ?? '0.000000';
const billingCycleSec = input.billingCycleSec ?? 60;
@@ -313,5 +321,7 @@ describe('S16 vendor gateways API', () => {
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
await request(app.getHttpServer()).post('/api/v2/vendor-gateways/vgw_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
await request(app.getHttpServer()).delete('/api/v2/vendor-gateways/vgw_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
expect(audit.entries.some((entry) => entry.module === 'vendor_gateways' && entry.action === 'delete' && entry.result === 'SUCCESS')).toBe(true);
});
});
@@ -1,4 +1,4 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import crypto from 'node:crypto';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
@@ -100,6 +100,7 @@ export interface VendorGatewaysRepository {
create(input: CreateVendorGatewayInput): Promise<VendorGatewaySummary>;
update(gatewayId: string, input: UpdateVendorGatewayInput): Promise<VendorGatewaySummary>;
setStatus(gatewayId: string, status: VendorGatewayStatus, actorId?: string): Promise<VendorGatewaySummary>;
softDelete(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary>;
}
export const VENDOR_GATEWAYS_REPOSITORY = Symbol('VENDOR_GATEWAYS_REPOSITORY');
@@ -244,6 +245,38 @@ export class PrismaVendorGatewaysRepository implements VendorGatewaysRepository
return this.toSummary(gateway);
}
async softDelete(gatewayIdValue: string, actorId?: string): Promise<VendorGatewaySummary> {
await this.findActiveOrThrow(gatewayIdValue);
const linkedLineGroups = await this.prisma.landingLineGroupItem.count({
where: {
vendorGatewayId: gatewayIdValue,
lineGroup: { deletedAt: null }
}
});
if (linkedLineGroups > 0) {
throw new BadRequestException({
code: 'VENDOR_GATEWAY_IN_LINE_GROUP',
message: 'Vendor gateway referenced by active line groups cannot be deleted.'
});
}
const gateway = await this.prisma.$transaction(async (tx) => {
const deleted = await tx.vendorGateway.update({
where: { id: gatewayIdValue },
data: {
status: 'DISABLED',
deletedAt: new Date(),
updatedBy: actorId,
version: { increment: 1 }
},
include: this.includeSummary()
});
await this.enqueueConfigOutbox(tx, deleted.id, 'vendor_gateway.deleted');
return deleted;
});
return this.toSummary(gateway);
}
private async ensureVendor(vendorId: string): Promise<void> {
const vendor = await this.prisma.vendor.findUnique({
where: { id: vendorId },
@@ -121,6 +121,10 @@ export class VendorGatewaysService {
return this.gateways.setStatus(gatewayId, 'DISABLED', actorId);
}
remove(gatewayId: string, actorId?: string): Promise<VendorGatewaySummary> {
return this.gateways.softDelete(gatewayId, actorId);
}
private limitedString(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.` });