feat: add number library routing and cdr location support
This commit is contained in:
@@ -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("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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] ?? '';
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.` });
|
||||
|
||||
Reference in New Issue
Block a user