feat: strengthen risk controls and review workflows
This commit is contained in:
@@ -54,6 +54,11 @@ export class AdminSmsConfigController {
|
||||
return this.smsConfig.listApplicationConnections(applicationId);
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id/deactivation-preview')
|
||||
getApplicationDeactivationPreview(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.getApplicationDeactivationPreview(applicationId);
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id/cmpp-params')
|
||||
getApplicationCmppParams(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.getApplicationCmppParams(applicationId);
|
||||
|
||||
@@ -43,6 +43,7 @@ function createPrismaMock() {
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })),
|
||||
},
|
||||
smsApplicationIpAllowlist: {
|
||||
@@ -147,12 +148,22 @@ function createPrismaMock() {
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }),
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
groupBy: jest.fn().mockResolvedValue([
|
||||
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 1 } },
|
||||
{ applicationId: 'app-1', status: 'undelivered', _count: { _all: 1 } },
|
||||
]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
cmppDownstreamDelivery: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
cmppDownstreamDeliveryAttempt: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
smsChannel: {
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
@@ -236,6 +247,71 @@ describe('SmsConfigService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('moves an application with outstanding receipts into disabling for 72 hours', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsMessageRecord.count.mockResolvedValue(1);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
const result = await service.changeApplicationStatus('app-1', {
|
||||
status: 'disabled',
|
||||
reason: '运营端停用',
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: 'disabling',
|
||||
autoDisableAt: expect.any(Date),
|
||||
deactivation: expect.objectContaining({ awaitingSupplierReceipt: 1 }),
|
||||
}));
|
||||
expect(prisma.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'app-1' },
|
||||
data: expect.objectContaining({
|
||||
status: 'disabling',
|
||||
disablingAt: expect.any(Date),
|
||||
autoDisableAt: expect.any(Date),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('automatically disables and abandons outstanding deliveries after 72 hours', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findMany.mockResolvedValue([{
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
status: 'disabling',
|
||||
autoDisableAt: new Date(Date.now() - 1_000),
|
||||
}]);
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
status: 'disabling',
|
||||
disablingAt: new Date(Date.now() - 73 * 60 * 60 * 1_000),
|
||||
autoDisableAt: new Date(Date.now() - 1_000),
|
||||
disableReason: '等待清算',
|
||||
});
|
||||
prisma.smsMessageRecord.count.mockResolvedValue(1);
|
||||
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1' }]);
|
||||
prisma.cmppDownstreamDelivery.updateMany.mockResolvedValue({ count: 1 });
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: jest.fn().mockResolvedValue('{"account":"100001","disconnected":2}'),
|
||||
}) as never;
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service['runApplicationDisableScan']();
|
||||
|
||||
expect(prisma.smsApplication.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ id: 'app-1', status: 'disabling' }),
|
||||
data: expect.objectContaining({ status: 'disabled' }),
|
||||
}));
|
||||
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'abandoned', retryEnabled: false }),
|
||||
}));
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('sorts enterprise applications by today send count descending with a stable name tie-breaker', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const baseApplication = {
|
||||
@@ -324,7 +400,6 @@ describe('SmsConfigService', () => {
|
||||
interfaceType: 'cmpp20',
|
||||
queuePriority: 'priority',
|
||||
dailyLimit: 100000,
|
||||
maxPhonesPerTask: 10000,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
@@ -24,7 +24,6 @@ export interface CreateSmsApplicationDto {
|
||||
dailyLimit?: number;
|
||||
customerUnitPrice?: number;
|
||||
queuePriority?: string;
|
||||
maxPhonesPerTask?: number;
|
||||
templateMismatchMode?: string;
|
||||
downstreamReceiptRetryEnabled?: boolean;
|
||||
downstreamUplinkRetryEnabled?: boolean;
|
||||
@@ -112,6 +111,7 @@ export interface StatusChangeDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateListQuery {
|
||||
@@ -159,11 +159,31 @@ type ApplicationQueuePriority = typeof APPLICATION_QUEUE_PRIORITIES[number];
|
||||
const APPLICATION_INTERFACE_TYPES = ['cmpp20'] as const;
|
||||
type ApplicationInterfaceType = typeof APPLICATION_INTERFACE_TYPES[number];
|
||||
const DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS = 90_000;
|
||||
const APPLICATION_DISABLE_GRACE_MS = 72 * 60 * 60 * 1_000;
|
||||
const DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS = 60_000;
|
||||
const UNRESOLVED_DOWNSTREAM_STATUSES = ['pending', 'awaiting_ack', 'failed', 'manual_requeueing'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SmsConfigService {
|
||||
export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SmsConfigService.name);
|
||||
private applicationDisableTimer?: ReturnType<typeof setInterval>;
|
||||
private applicationDisableScanRunning = false;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.applicationDisableTimer = setInterval(
|
||||
() => void this.runApplicationDisableScan(),
|
||||
getPositiveIntegerEnv('APPLICATION_DISABLE_SCAN_INTERVAL_MS', DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS),
|
||||
);
|
||||
this.applicationDisableTimer.unref?.();
|
||||
void this.runApplicationDisableScan();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.applicationDisableTimer) clearInterval(this.applicationDisableTimer);
|
||||
}
|
||||
|
||||
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
if (query.includeConnections) {
|
||||
@@ -202,6 +222,9 @@ export class SmsConfigService {
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const disablingDetails = new Map((await Promise.all(applications
|
||||
.filter((application) => application.status === 'disabling')
|
||||
.map(async (application) => [application.id, await this.getApplicationDeactivationPreview(application.id)] as const))));
|
||||
return applications.map((application) => {
|
||||
const appConnections = connections.filter((connection) => connection.applicationId === application.id);
|
||||
const appStats = messageStats.filter((item) => item.applicationId === application.id);
|
||||
@@ -213,6 +236,7 @@ export class SmsConfigService {
|
||||
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
|
||||
sentToday: todayTotal,
|
||||
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
|
||||
deactivation: disablingDetails.get(application.id) ?? null,
|
||||
};
|
||||
}).sort((left, right) => right.sentToday - left.sentToday
|
||||
|| left.name.localeCompare(right.name, 'zh-CN')
|
||||
@@ -389,7 +413,6 @@ export class SmsConfigService {
|
||||
dailyLimit: getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice ?? 0,
|
||||
queuePriority,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask ?? 10000,
|
||||
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
|
||||
@@ -459,7 +482,6 @@ export class SmsConfigService {
|
||||
dailyLimit: data.dailyLimit === undefined ? undefined : getPositiveInteger(data.dailyLimit, 100000, 'dailyLimit'),
|
||||
customerUnitPrice: data.customerUnitPrice,
|
||||
queuePriority,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask,
|
||||
templateMismatchMode: data.templateMismatchMode,
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled,
|
||||
@@ -567,13 +589,108 @@ export class SmsConfigService {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const status = data.status ?? 'disabled';
|
||||
const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, data: { status } });
|
||||
await this.writeOperationLog(application.tenantId, data.operatorId, `sms_application.${status}`, 'sms_application', applicationId, {
|
||||
statusBefore: application.status,
|
||||
statusAfter: status,
|
||||
reason: data.reason,
|
||||
if (status === 'active') {
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: { status: 'active', disablingAt: null, autoDisableAt: null, disableReason: null },
|
||||
});
|
||||
await this.writeApplicationStatusLog(application, data, 'active', {});
|
||||
return updated;
|
||||
}
|
||||
if (!['disabled', 'disabling', 'deleted'].includes(status)) {
|
||||
throw new BadRequestException(`不支持的企业应用状态:${status}`);
|
||||
}
|
||||
|
||||
const preview = await this.getApplicationDeactivationPreview(applicationId);
|
||||
if ((status === 'disabling' || status === 'disabled') && preview.totalOutstanding > 0 && !data.force) {
|
||||
const disablingAt = new Date();
|
||||
const autoDisableAt = new Date(disablingAt.getTime() + APPLICATION_DISABLE_GRACE_MS);
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: {
|
||||
status: 'disabling',
|
||||
disablingAt,
|
||||
autoDisableAt,
|
||||
disableReason: data.reason?.trim() || '等待未完成回执清算',
|
||||
},
|
||||
});
|
||||
await this.writeApplicationStatusLog(application, data, 'disabling', { preview, disablingAt, autoDisableAt });
|
||||
return { ...updated, deactivation: { ...preview, disablingAt, autoDisableAt } };
|
||||
}
|
||||
|
||||
const finalStatus = status === 'deleted' ? 'deleted' : 'disabled';
|
||||
const abandonReason = status === 'deleted'
|
||||
? '企业应用已删除,放弃剩余下游投递'
|
||||
: data.force
|
||||
? '运营强制停用企业应用,放弃剩余下游投递'
|
||||
: '企业应用无待清算数据,完成停用';
|
||||
const abandoned = await this.abandonApplicationDeliveries(applicationId, abandonReason);
|
||||
const updated = await this.prisma.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: {
|
||||
status: finalStatus,
|
||||
disablingAt: null,
|
||||
autoDisableAt: null,
|
||||
disableReason: data.reason?.trim() || abandonReason,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, abandonReason);
|
||||
await this.writeApplicationStatusLog(application, data, finalStatus, { preview, abandoned, disconnect });
|
||||
return { ...updated, deactivation: null, abandoned, disconnect };
|
||||
}
|
||||
|
||||
async getApplicationDeactivationPreview(applicationId: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
disablingAt: true,
|
||||
autoDisableAt: true,
|
||||
disableReason: true,
|
||||
},
|
||||
});
|
||||
if (!application) throw new NotFoundException('Application not found');
|
||||
const [
|
||||
awaitingSupplierReceipt,
|
||||
waitingToSend,
|
||||
awaitingClientAck,
|
||||
retryableFailures,
|
||||
pendingUplinks,
|
||||
activeConnections,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.count({
|
||||
where: { applicationId, status: { in: ['submitted', 'unknown'] }, receiptStatus: null },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'receipt', status: { in: ['pending', 'manual_requeueing'] } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'receipt', status: 'awaiting_ack' },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'receipt', status: 'failed', retryEnabled: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { applicationId, deliveryType: 'uplink', status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamConnection.count({
|
||||
where: { applicationId, status: 'connected' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
status: application.status,
|
||||
reason: application.disableReason,
|
||||
disablingAt: application.disablingAt,
|
||||
autoDisableAt: application.autoDisableAt,
|
||||
awaitingSupplierReceipt,
|
||||
waitingToSend,
|
||||
awaitingClientAck,
|
||||
retryableFailures,
|
||||
pendingUplinks,
|
||||
activeConnections,
|
||||
totalOutstanding: awaitingSupplierReceipt + waitingToSend + awaitingClientAck + retryableFailures + pendingUplinks,
|
||||
};
|
||||
}
|
||||
|
||||
async listApplicationConnections(applicationId: string) {
|
||||
@@ -690,7 +807,7 @@ export class SmsConfigService {
|
||||
});
|
||||
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
|
||||
}
|
||||
if (!application.interfaceEnabled || application.status !== 'active') {
|
||||
if (!application.interfaceEnabled || !['active', 'disabling'].includes(application.status)) {
|
||||
throw new ForbiddenException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
@@ -1658,6 +1775,132 @@ export class SmsConfigService {
|
||||
return this.prisma.auditRecord.create({ data });
|
||||
}
|
||||
|
||||
private async abandonApplicationDeliveries(applicationId: string, reason: string) {
|
||||
const deliveries = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { applicationId, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
|
||||
select: { id: true },
|
||||
});
|
||||
const deliveryIds = deliveries.map((delivery) => delivery.id);
|
||||
if (deliveryIds.length === 0) return 0;
|
||||
await this.prisma.cmppDownstreamDeliveryAttempt.updateMany({
|
||||
where: { deliveryId: { in: deliveryIds }, status: { in: ['awaiting_ack', 'sent'] } },
|
||||
data: {
|
||||
status: 'abandoned',
|
||||
ackDeadlineAt: null,
|
||||
failureType: 'application_disabled',
|
||||
errorMessage: reason,
|
||||
},
|
||||
});
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: { in: deliveryIds }, status: { in: [...UNRESOLVED_DOWNSTREAM_STATUSES] } },
|
||||
data: {
|
||||
status: 'abandoned',
|
||||
retryEnabled: false,
|
||||
nextRetryAt: null,
|
||||
ackDeadlineAt: null,
|
||||
lastError: reason,
|
||||
},
|
||||
});
|
||||
return updated.count;
|
||||
}
|
||||
|
||||
private async disconnectDownstreamAccount(account: string, reason: string) {
|
||||
const baseUrl = process.env.GATEWAY_CONTROL_URL?.trim() || 'http://127.0.0.1:8090';
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/downstream/connections/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ account, reason }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gateway returned ${response.status}: ${responseText}`);
|
||||
}
|
||||
return responseText ? JSON.parse(responseText) as { account: string; disconnected: number } : { account, disconnected: 0 };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to disconnect downstream CMPP account ${account}: ${message}`);
|
||||
return { account, disconnected: 0, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
private writeApplicationStatusLog(
|
||||
application: { id: string; tenantId: string; status: string },
|
||||
data: StatusChangeDto,
|
||||
statusAfter: string,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
return this.writeOperationLog(
|
||||
application.tenantId,
|
||||
data.operatorId,
|
||||
`sms_application.${statusAfter}`,
|
||||
'sms_application',
|
||||
application.id,
|
||||
{
|
||||
statusBefore: application.status,
|
||||
statusAfter,
|
||||
reason: data.reason,
|
||||
force: Boolean(data.force),
|
||||
...JSON.parse(JSON.stringify(detail)) as Record<string, unknown>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async runApplicationDisableScan() {
|
||||
if (this.applicationDisableScanRunning) return;
|
||||
this.applicationDisableScanRunning = true;
|
||||
try {
|
||||
const applications = await this.prisma.smsApplication.findMany({
|
||||
where: { status: 'disabling' },
|
||||
select: { id: true, tenantId: true, cmppAccount: true, status: true, autoDisableAt: true },
|
||||
take: 500,
|
||||
});
|
||||
const now = new Date();
|
||||
for (const application of applications) {
|
||||
const preview = await this.getApplicationDeactivationPreview(application.id);
|
||||
if (preview.totalOutstanding === 0) {
|
||||
await this.finalizeDisablingApplication(application, false, '待处理回执已清算完成,系统自动停用', preview);
|
||||
} else if (application.autoDisableAt && application.autoDisableAt <= now) {
|
||||
await this.finalizeDisablingApplication(application, true, '进入停用中状态已满72小时,系统自动放弃剩余回执', preview);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Application disabling scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
this.applicationDisableScanRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeDisablingApplication(
|
||||
application: { id: string; tenantId: string; cmppAccount: string; status: string },
|
||||
abandonOutstanding: boolean,
|
||||
reason: string,
|
||||
preview: Awaited<ReturnType<SmsConfigService['getApplicationDeactivationPreview']>>,
|
||||
) {
|
||||
const claimed = await this.prisma.smsApplication.updateMany({
|
||||
where: { id: application.id, status: 'disabling' },
|
||||
data: {
|
||||
status: 'disabled',
|
||||
disablingAt: null,
|
||||
autoDisableAt: null,
|
||||
disableReason: reason,
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) return false;
|
||||
const abandoned = abandonOutstanding
|
||||
? await this.abandonApplicationDeliveries(application.id, reason)
|
||||
: 0;
|
||||
const disconnect = await this.disconnectDownstreamAccount(application.cmppAccount, reason);
|
||||
await this.writeApplicationStatusLog(application, { reason, force: abandonOutstanding }, 'disabled', {
|
||||
preview,
|
||||
abandoned,
|
||||
disconnect,
|
||||
automatic: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private writeOperationLog(
|
||||
tenantId: string,
|
||||
userId: string | undefined,
|
||||
@@ -1837,7 +2080,7 @@ function getPositiveInteger(value: number | undefined, fallback: number, fieldNa
|
||||
}
|
||||
|
||||
function normalizeApplicationCmppStatus(connections: Array<{ status: string }>, applicationStatus: string) {
|
||||
if (applicationStatus !== 'active') {
|
||||
if (!['active', 'disabling'].includes(applicationStatus)) {
|
||||
return 'inactive';
|
||||
}
|
||||
if (connections.some((connection) => connection.status === 'connected')) {
|
||||
|
||||
Reference in New Issue
Block a user