feat: optimize UI queries and seed number library

This commit is contained in:
hectorzhao
2026-08-27 20:24:18 +08:00
parent 138297d191
commit 564234b769
22 changed files with 631 additions and 84 deletions
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ConfigService } from '@nestjs/config';
import type { RuntimeConfig } from '../../shared/config.js';
import { OpenSipsMiClient } from './opensips-mi.client.js';
function client() {
const config = {
get: () => ({
mode: 'http',
httpUrl: 'http://127.0.0.1:8888/mi',
sshHost: 'unused',
remoteCommand: 'unused',
timeoutMs: 3000
})
} as unknown as ConfigService<RuntimeConfig, true>;
return new OpenSipsMiClient(config);
}
describe('OpenSipsMiClient HTTP MI', () => {
afterEach(() => vi.unstubAllGlobals());
it('lists dialogs through the local JSON-RPC endpoint', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { Dialogs: [] } }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await expect(client().listDialogs()).resolves.toEqual({ Dialogs: [] });
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8888/mi', expect.objectContaining({ method: 'POST' }));
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] });
});
it('preserves OpenSIPS MI errors for the API layer', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 500, message: 'Operation failed' } }), { status: 200 })));
await expect(client().endDialog('dlg-1')).rejects.toMatchObject({ response: expect.objectContaining({ code: 'ACTIVE_CALLS_MI_ERROR' }) });
});
});
@@ -63,17 +63,13 @@ export class OpenSipsMiClient {
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;
let response: MiResponse;
try {
output = await this.executor.run(args, config.timeoutMs);
response = config.mode === 'http'
? await this.callHttp(config.httpUrl, method, params, config.timeoutMs)
: await this.callSsh(config, method, params);
} catch (error) {
if (error instanceof BadGatewayException) throw error;
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
message: 'OpenSIPS control plane is unavailable.',
@@ -81,16 +77,6 @@ export class OpenSipsMiClient {
});
}
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',
@@ -101,6 +87,42 @@ export class OpenSipsMiClient {
return response.result;
}
private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise<MiResponse> {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
signal: AbortSignal.timeout(timeoutMs)
});
if (!response.ok) {
throw new Error(`OpenSIPS MI HTTP returned ${response.status}`);
}
try {
return await response.json() as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
private async callSsh(config: RuntimeConfig['activeCalls'], method: string, params: string[]): Promise<MiResponse> {
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
const args: string[] = [];
if (config.sshConfig) args.push('-F', config.sshConfig);
args.push(config.sshHost, remoteCommand);
const output = await this.executor.run(args, config.timeoutMs);
try {
return JSON.parse(output) as MiResponse;
} catch {
throw new BadGatewayException({
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
message: 'OpenSIPS control plane returned invalid JSON.'
});
}
}
}
function shellQuote(value: string): string {
@@ -1,4 +1,5 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@lisglosips/database';
import { PrismaService } from '../database/prisma.service.js';
export interface AuditLogQuery {
@@ -8,6 +9,8 @@ export interface AuditLogQuery {
objectType?: string;
objectId?: string;
result?: 'SUCCESS' | 'FAILURE';
createdFrom?: Date;
createdTo?: Date;
take: number;
skip: number;
}
@@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
const where = {
const where: Prisma.AuditLogWhereInput = {
module: query.module,
action: query.action,
userId: query.userId,
objectType: query.objectType,
objectId: query.objectId,
result: query.result
result: query.result,
createdAt: query.createdFrom || query.createdTo ? { gte: query.createdFrom, lte: query.createdTo } : undefined
};
const [items, total] = await this.prisma.$transaction([
@@ -13,6 +13,8 @@ export class AuditLogsService {
objectType: this.optionalString(rawQuery.objectType),
objectId: this.optionalString(rawQuery.objectId),
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
createdFrom: this.optionalDate(rawQuery.createdFrom, 'createdFrom'),
createdTo: this.optionalDate(rawQuery.createdTo, 'createdTo'),
take: this.positiveInt(rawQuery.take, 50, 100),
skip: this.positiveInt(rawQuery.skip, 0, 10_000)
};
@@ -56,4 +58,16 @@ export class AuditLogsService {
return parsed;
}
private optionalDate(value: unknown, field: string): Date | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
}
@@ -65,8 +65,16 @@ export interface SaveReviewInput {
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
export interface RecordingListQuery {
status?: string;
reviewStatus?: 'PENDING' | 'REVIEWED';
limit?: number;
startedFrom?: Date;
startedTo?: Date;
}
export interface RecordingsRepository {
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>;
list(query?: RecordingListQuery): Promise<RecordingListItem[]>;
getDetail(id: string): Promise<RecordingDetail>;
getReadyForPlayback(id: string): Promise<RecordingPlayback>;
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
@@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{
export class PrismaRecordingsRepository implements RecordingsRepository {
constructor(private readonly prisma: PrismaService) {}
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> {
async list(query: RecordingListQuery = {}): Promise<RecordingListItem[]> {
const take = Math.min(query.limit ?? 100, 500);
const startedAt = query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined;
const recordings = await this.prisma.recording.findMany({
where: {
status: query.status ? (query.status as never) : 'READY',
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined,
OR: startedAt ? [
{ rawCdr: { is: { startedAt } } },
{ rawCdrId: null, createdAt: startedAt }
] : undefined
},
orderBy: [{ createdAt: 'desc' }],
take,
@@ -26,11 +26,13 @@ export class RecordingsService {
@Inject(QualityService) private readonly qualityService: QualityService
) {}
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) {
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown; startedFrom?: unknown; startedTo?: unknown } = {}) {
const recordings = await this.recordings.list({
status: query.status === undefined ? undefined : this.recordingStatus(query.status),
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500)
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500),
startedFrom: query.startedFrom === undefined ? undefined : this.date(query.startedFrom, 'startedFrom'),
startedTo: query.startedTo === undefined ? undefined : this.date(query.startedTo, 'startedTo')
});
return Promise.all(recordings.map((recording) => this.withSampling(recording)));
}
@@ -123,6 +125,17 @@ export class RecordingsService {
}
return parsed;
}
private date(value: unknown, field: string): Date {
if (typeof value !== 'string') {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
}
return parsed;
}
}
export function internalRecordingPath(storageKey: string): string {