fix vendor status customer toggles and cps
This commit is contained in:
@@ -31,10 +31,31 @@ describe('active calls service', () => {
|
||||
toTag: 'to-b',
|
||||
caller: 'sip:1001@s21.lisglosips.test',
|
||||
callee: 'sip:2001@s21.lisglosips.test',
|
||||
state: 'confirmed'
|
||||
state: 'confirmed',
|
||||
sipState: '200 OK',
|
||||
sipStatusCode: 200,
|
||||
sipStatusText: 'OK'
|
||||
});
|
||||
});
|
||||
|
||||
it('maps OpenSIPS dialog states to SIP signaling states', () => {
|
||||
const calls = normalizeDialogs({
|
||||
Dialogs: [
|
||||
{ ID: 'early-ring', callid: 'call-early', state: 2 },
|
||||
{ ID: 'early-progress', callid: 'call-progress', state: 'early', to_tag: 'tag-b' },
|
||||
{ ID: 'inviting', callid: 'call-invite', state: 1 },
|
||||
{ ID: 'terminating', callid: 'call-bye', state: 5 }
|
||||
]
|
||||
});
|
||||
|
||||
expect(calls.map((call) => [call.id, call.sipState, call.sipStatusCode, call.sipStatusText])).toEqual([
|
||||
['early-ring', '180 Ringing', 180, 'Ringing'],
|
||||
['early-progress', '183 Session Progress', 183, 'Session Progress'],
|
||||
['inviting', 'INVITE', null, 'INVITE'],
|
||||
['terminating', 'BYE / Terminating', null, 'Terminating']
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts caller and landing IPs from dialog contacts and SDP', () => {
|
||||
const calls = normalizeDialogs({
|
||||
Dialogs: [
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface ActiveCallSummary {
|
||||
caller: string | null;
|
||||
callee: string | null;
|
||||
state: string | null;
|
||||
sipState: string;
|
||||
sipStatusCode: number | null;
|
||||
sipStatusText: string;
|
||||
startedAt: string | null;
|
||||
durationSec: number | null;
|
||||
lifetimeSec: number | null;
|
||||
@@ -149,6 +152,9 @@ function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummar
|
||||
const calleeSdp =
|
||||
stringField(record, ['callee_sdp', 'calleeSdp', 'to_sdp']) ?? (calleeRecord ? stringField(calleeRecord, ['callee_sdp', 'calleeSdp', 'to_sdp', 'sdp']) : null);
|
||||
|
||||
const dialogState = stringField(record, ['state', 'status']);
|
||||
const sipState = sipStateFromDialog(dialogState, stringField(record, ['to_tag', 'totag', 'toTag']));
|
||||
|
||||
return {
|
||||
id,
|
||||
callId,
|
||||
@@ -156,7 +162,10 @@ function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummar
|
||||
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']),
|
||||
state: dialogState,
|
||||
sipState: sipState.label,
|
||||
sipStatusCode: sipState.code,
|
||||
sipStatusText: sipState.text,
|
||||
startedAt,
|
||||
durationSec,
|
||||
lifetimeSec,
|
||||
@@ -178,6 +187,26 @@ function toSummary(record: Record<string, unknown>, now: Date): ActiveCallSummar
|
||||
};
|
||||
}
|
||||
|
||||
function sipStateFromDialog(state: string | null, toTag: string | null): { label: string; code: number | null; text: string } {
|
||||
const normalized = (state || '').trim().toLowerCase();
|
||||
if (normalized === 'confirmed' || normalized === '3') {
|
||||
return { label: '200 OK', code: 200, text: 'OK' };
|
||||
}
|
||||
if (normalized === 'early' || normalized === '2') {
|
||||
return { label: toTag ? '183 Session Progress' : '180 Ringing', code: toTag ? 183 : 180, text: toTag ? 'Session Progress' : 'Ringing' };
|
||||
}
|
||||
if (normalized === 'unconfirmed' || normalized === '1') {
|
||||
return { label: 'INVITE', code: null, text: 'INVITE' };
|
||||
}
|
||||
if (normalized === 'deleted' || normalized === '4') {
|
||||
return { label: 'BYE', code: null, text: 'BYE' };
|
||||
}
|
||||
if (normalized === 'terminating' || normalized === '5') {
|
||||
return { label: 'BYE / Terminating', code: null, text: 'Terminating' };
|
||||
}
|
||||
return { label: state || 'UNKNOWN', code: null, text: state || 'UNKNOWN' };
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown>, names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const value = findCaseInsensitive(record, name);
|
||||
|
||||
@@ -143,7 +143,7 @@ class MemoryVendorGatewaysRepository implements VendorGatewaysRepository {
|
||||
transport: input.transport ?? 'udp',
|
||||
sipUsername: input.sipUsername ?? null,
|
||||
hasSipCredential: input.hasSipCredential ?? false,
|
||||
cpsLimit: input.cpsLimit ?? 0,
|
||||
cpsLimit: input.cpsLimit ?? 1,
|
||||
concurrencyLimit: input.concurrencyLimit ?? 0,
|
||||
billingCycleSec,
|
||||
cycleRate,
|
||||
@@ -325,7 +325,24 @@ describe('S16 vendor gateways API', () => {
|
||||
expect(JSON.stringify(audit.entries)).not.toContain('change-me-very-strong');
|
||||
});
|
||||
|
||||
it('validates child configuration and supports enable/disable', async () => {
|
||||
it('validates CPS and child configuration and supports enable/disable', async () => {
|
||||
for (const cpsLimit of [0, 10001, '20 CPS']) {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendor-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
vendorId: 'ven_seed',
|
||||
name: `Invalid CPS ${cpsLimit}`,
|
||||
authMode: 'IP',
|
||||
host: '203.0.113.21',
|
||||
cpsLimit
|
||||
})
|
||||
.expect(400)
|
||||
.expect((response) => {
|
||||
expect(response.body.code).toBe('INTEGER_INVALID');
|
||||
});
|
||||
}
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendor-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
|
||||
@@ -64,7 +64,7 @@ export class VendorGatewaysService {
|
||||
transport: body.transport === undefined ? 'udp' : this.transport(body.transport),
|
||||
sipUsername,
|
||||
sipHa1: sipPassword ? this.sipHa1(sipUsername, host, sipPassword) : undefined,
|
||||
cpsLimit: body.cpsLimit === undefined ? 0 : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000),
|
||||
cpsLimit: body.cpsLimit === undefined ? 1 : this.integer(body.cpsLimit, 'cpsLimit', 1, 10000),
|
||||
concurrencyLimit: body.concurrencyLimit === undefined ? 0 : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
|
||||
billingCycleSec: body.billingCycleSec === undefined ? 60 : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
|
||||
cycleRate: body.cycleRate === undefined ? '0.000000' : this.money(body.cycleRate, 'cycleRate'),
|
||||
@@ -105,7 +105,7 @@ export class VendorGatewaysService {
|
||||
transport: body.transport === undefined ? undefined : this.transport(body.transport),
|
||||
sipUsername: this.requiresSip(authMode) ? sipUsername : null,
|
||||
sipHa1: password ? this.sipHa1(sipUsername, host, password) : this.requiresSip(authMode) ? undefined : null,
|
||||
cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 0, 10000),
|
||||
cpsLimit: body.cpsLimit === undefined ? undefined : this.integer(body.cpsLimit, 'cpsLimit', 1, 10000),
|
||||
concurrencyLimit: body.concurrencyLimit === undefined ? undefined : this.integer(body.concurrencyLimit, 'concurrencyLimit', 0, 100000),
|
||||
billingCycleSec: body.billingCycleSec === undefined ? undefined : this.integer(body.billingCycleSec, 'billingCycleSec', 1, 60),
|
||||
cycleRate: body.cycleRate === undefined ? undefined : this.money(body.cycleRate, 'cycleRate'),
|
||||
|
||||
@@ -35,20 +35,6 @@ export class VendorsController {
|
||||
return this.vendorsService.update(id, body as never, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/enable')
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'enable', objectType: 'vendor', objectIdParam: 'id' })
|
||||
enable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorsService.enable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Post(':id/disable')
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'disable', objectType: 'vendor', objectIdParam: 'id' })
|
||||
disable(@Param('id') id: string, @CurrentUserParam() currentUser?: CurrentUser) {
|
||||
return this.vendorsService.disable(id, currentUser?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('vendors.manage')
|
||||
@AuditAction({ module: 'vendors', action: 'delete', objectType: 'vendor', objectIdParam: 'id' })
|
||||
|
||||
+7
-14
@@ -11,7 +11,6 @@ import {
|
||||
VENDORS_REPOSITORY,
|
||||
type CreateVendorInput,
|
||||
type UpdateVendorInput,
|
||||
type VendorStatus,
|
||||
type VendorSummary,
|
||||
type VendorsRepository
|
||||
} from './vendors.repository.js';
|
||||
@@ -62,7 +61,6 @@ class MemoryVendorsRepository implements VendorsRepository {
|
||||
contactName: input.contactName ?? null,
|
||||
phone: input.phone ?? null,
|
||||
email: input.email ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
creditLimit: input.creditLimit,
|
||||
settlement: input.settlement ?? null,
|
||||
notes: input.notes ?? null
|
||||
@@ -79,7 +77,6 @@ class MemoryVendorsRepository implements VendorsRepository {
|
||||
contactName: input.contactName === undefined ? current.contactName : input.contactName,
|
||||
phone: input.phone === undefined ? current.phone : input.phone,
|
||||
email: input.email === undefined ? current.email : input.email,
|
||||
status: input.status ?? current.status,
|
||||
creditLimit: input.creditLimit ?? current.creditLimit,
|
||||
availableBalance: (Number(current.balance) + Number(input.creditLimit ?? current.creditLimit)).toFixed(6),
|
||||
settlement: input.settlement === undefined ? current.settlement : input.settlement,
|
||||
@@ -90,12 +87,10 @@ class MemoryVendorsRepository implements VendorsRepository {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async setStatus(vendorId: string, status: VendorStatus): Promise<VendorSummary> {
|
||||
return this.update(vendorId, { status });
|
||||
}
|
||||
|
||||
async softDelete(vendorId: string): Promise<VendorSummary> {
|
||||
return this.update(vendorId, { status: 'DISABLED' });
|
||||
const current = this.vendors.get(vendorId) ?? this.summary({ id: vendorId, name: 'Deleted Vendor' });
|
||||
this.vendors.set(vendorId, { ...current, updatedAt: new Date('2026-06-21T05:00:00.000Z') });
|
||||
return this.vendors.get(vendorId) as VendorSummary;
|
||||
}
|
||||
|
||||
private summary(input: {
|
||||
@@ -104,7 +99,6 @@ class MemoryVendorsRepository implements VendorsRepository {
|
||||
contactName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: VendorStatus;
|
||||
creditLimit?: string;
|
||||
settlement?: string | null;
|
||||
notes?: string | null;
|
||||
@@ -118,7 +112,6 @@ class MemoryVendorsRepository implements VendorsRepository {
|
||||
contactName: input.contactName ?? null,
|
||||
phone: input.phone ?? null,
|
||||
email: input.email ?? null,
|
||||
status: input.status ?? 'ENABLED',
|
||||
balance,
|
||||
creditLimit,
|
||||
availableBalance: (Number(balance) + Number(creditLimit)).toFixed(6),
|
||||
@@ -215,7 +208,7 @@ describe('S15 vendors API', () => {
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates, updates, disables, enables, and soft deletes vendors with audit entries', async () => {
|
||||
it('creates, updates, and soft deletes vendors with audit entries', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/vendors')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
@@ -248,12 +241,12 @@ describe('S15 vendors API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(201);
|
||||
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/disable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(404);
|
||||
await request(app.getHttpServer()).post('/api/v2/vendors/ven_created/enable').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(404);
|
||||
await request(app.getHttpServer()).delete('/api/v2/vendors/ven_created').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(200);
|
||||
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'create' && entry.result === 'SUCCESS')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'disable' && entry.objectId === 'ven_created')).toBe(true);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendors' && (entry.action === 'disable' || entry.action === 'enable'))).toBe(false);
|
||||
expect(audit.entries.some((entry) => entry.module === 'vendors' && entry.action === 'delete' && entry.objectId === 'ven_created')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,15 +3,12 @@ import crypto from 'node:crypto';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export type VendorStatus = 'ENABLED' | 'DISABLED';
|
||||
|
||||
export interface VendorSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
contactName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
status: VendorStatus;
|
||||
balance: string;
|
||||
creditLimit: string;
|
||||
availableBalance: string;
|
||||
@@ -27,7 +24,6 @@ export interface CreateVendorInput {
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
status?: VendorStatus;
|
||||
creditLimit: string;
|
||||
settlement?: string;
|
||||
notes?: string;
|
||||
@@ -39,7 +35,6 @@ export interface UpdateVendorInput {
|
||||
contactName?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
status?: VendorStatus;
|
||||
creditLimit?: string;
|
||||
settlement?: string | null;
|
||||
notes?: string | null;
|
||||
@@ -51,7 +46,6 @@ export interface VendorsRepository {
|
||||
get(vendorId: string): Promise<VendorSummary>;
|
||||
create(input: CreateVendorInput): Promise<VendorSummary>;
|
||||
update(vendorId: string, input: UpdateVendorInput): Promise<VendorSummary>;
|
||||
setStatus(vendorId: string, status: VendorStatus, actorId?: string): Promise<VendorSummary>;
|
||||
softDelete(vendorId: string, actorId?: string): Promise<VendorSummary>;
|
||||
}
|
||||
|
||||
@@ -87,7 +81,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
|
||||
contactName: input.contactName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
status: input.status ?? 'ENABLED',
|
||||
creditLimit: new Prisma.Decimal(input.creditLimit),
|
||||
settlement: input.settlement,
|
||||
notes: input.notes,
|
||||
@@ -110,7 +103,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
|
||||
contactName: input.contactName,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
status: input.status,
|
||||
creditLimit: input.creditLimit === undefined ? undefined : new Prisma.Decimal(input.creditLimit),
|
||||
settlement: input.settlement,
|
||||
notes: input.notes,
|
||||
@@ -123,10 +115,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
|
||||
return this.toSummary(vendor);
|
||||
}
|
||||
|
||||
async setStatus(vendorIdValue: string, status: VendorStatus, actorId?: string): Promise<VendorSummary> {
|
||||
return this.update(vendorIdValue, { status, actorId });
|
||||
}
|
||||
|
||||
async softDelete(vendorIdValue: string, actorId?: string): Promise<VendorSummary> {
|
||||
const existing = await this.findActiveOrThrow(vendorIdValue);
|
||||
const linkedGateways = await this.prisma.vendorGateway.count({
|
||||
@@ -188,7 +176,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
|
||||
contactName: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
status: VendorStatus;
|
||||
balance: Prisma.Decimal;
|
||||
creditLimit: Prisma.Decimal;
|
||||
settlement: string | null;
|
||||
@@ -203,7 +190,6 @@ export class PrismaVendorsRepository implements VendorsRepository {
|
||||
contactName: vendor.contactName,
|
||||
phone: vendor.phone,
|
||||
email: vendor.email,
|
||||
status: vendor.status,
|
||||
balance: vendor.balance.toFixed(6),
|
||||
creditLimit: vendor.creditLimit.toFixed(6),
|
||||
availableBalance: vendor.balance.plus(vendor.creditLimit).toFixed(6),
|
||||
|
||||
-21
@@ -3,7 +3,6 @@ import {
|
||||
VENDORS_REPOSITORY,
|
||||
type CreateVendorInput,
|
||||
type UpdateVendorInput,
|
||||
type VendorStatus,
|
||||
type VendorSummary,
|
||||
type VendorsRepository
|
||||
} from './vendors.repository.js';
|
||||
@@ -13,7 +12,6 @@ interface CreateVendorDto {
|
||||
contactName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
status?: unknown;
|
||||
creditLimit?: unknown;
|
||||
settlement?: unknown;
|
||||
notes?: unknown;
|
||||
@@ -24,7 +22,6 @@ interface UpdateVendorDto {
|
||||
contactName?: unknown;
|
||||
phone?: unknown;
|
||||
email?: unknown;
|
||||
status?: unknown;
|
||||
creditLimit?: unknown;
|
||||
settlement?: unknown;
|
||||
notes?: unknown;
|
||||
@@ -48,7 +45,6 @@ export class VendorsService {
|
||||
contactName: this.optionalString(body.contactName, 'contactName', 80),
|
||||
phone: this.optionalString(body.phone, 'phone', 32),
|
||||
email: this.optionalString(body.email, 'email', 160),
|
||||
status: body.status === undefined ? 'ENABLED' : this.status(body.status),
|
||||
creditLimit: body.creditLimit === undefined ? '0.000000' : this.money(body.creditLimit, 'creditLimit'),
|
||||
settlement: this.optionalString(body.settlement, 'settlement', 80),
|
||||
notes: this.optionalString(body.notes, 'notes', 500),
|
||||
@@ -64,7 +60,6 @@ export class VendorsService {
|
||||
contactName: body.contactName === undefined ? undefined : this.nullableString(body.contactName, 'contactName', 80),
|
||||
phone: body.phone === undefined ? undefined : this.nullableString(body.phone, 'phone', 32),
|
||||
email: body.email === undefined ? undefined : this.nullableString(body.email, 'email', 160),
|
||||
status: body.status === undefined ? undefined : this.status(body.status),
|
||||
creditLimit: body.creditLimit === undefined ? undefined : this.money(body.creditLimit, 'creditLimit'),
|
||||
settlement: body.settlement === undefined ? undefined : this.nullableString(body.settlement, 'settlement', 80),
|
||||
notes: body.notes === undefined ? undefined : this.nullableString(body.notes, 'notes', 500),
|
||||
@@ -74,14 +69,6 @@ export class VendorsService {
|
||||
return this.vendors.update(vendorId, input);
|
||||
}
|
||||
|
||||
enable(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||
return this.vendors.setStatus(vendorId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
disable(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||
return this.vendors.setStatus(vendorId, 'DISABLED', actorId);
|
||||
}
|
||||
|
||||
remove(vendorId: string, actorId?: string): Promise<VendorSummary> {
|
||||
return this.vendors.softDelete(vendorId, actorId);
|
||||
}
|
||||
@@ -115,14 +102,6 @@ export class VendorsService {
|
||||
return this.limitedString(value, field, maxLength);
|
||||
}
|
||||
|
||||
private status(value: unknown): VendorStatus {
|
||||
if (value !== 'ENABLED' && value !== 'DISABLED') {
|
||||
throw new BadRequestException({ code: 'STATUS_INVALID', message: 'Status is invalid.' });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private money(value: unknown, field: string): string {
|
||||
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
|
||||
if (!/^\d{1,14}(?:\.\d{1,6})?$/.test(raw)) {
|
||||
|
||||
Reference in New Issue
Block a user