753 lines
26 KiB
TypeScript
753 lines
26 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { randomBytes, createHash } from 'node:crypto';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
export interface CreateSmsApplicationDto {
|
|
tenantId: string;
|
|
name: string;
|
|
scene?: string;
|
|
callbackUrl?: string;
|
|
dailyLimit?: number;
|
|
customerUnitPrice?: number;
|
|
maxPhonesPerTask?: number;
|
|
templateMismatchMode?: string;
|
|
ipAllowlist?: string[];
|
|
}
|
|
|
|
export type UpdateSmsApplicationDto = Partial<Omit<CreateSmsApplicationDto, 'tenantId'>> & {
|
|
status?: string;
|
|
};
|
|
|
|
export interface ReplaceApplicationRouteRulesDto {
|
|
routes: Array<{
|
|
carrier: string;
|
|
groupId: string;
|
|
priority?: number;
|
|
status?: string;
|
|
}>;
|
|
}
|
|
|
|
export interface CreateSmsSignatureDto {
|
|
tenantId: string;
|
|
applicationId?: string;
|
|
name: string;
|
|
purpose?: string;
|
|
drainageInfo?: Record<string, unknown>;
|
|
}
|
|
|
|
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
|
|
auditStatus?: string;
|
|
};
|
|
|
|
export interface CreateSignatureMaterialDto {
|
|
signatureId: string;
|
|
fileObjectId?: string;
|
|
materialType: string;
|
|
title: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface CreateSmsTemplateDto {
|
|
tenantId: string;
|
|
applicationId: string;
|
|
signatureId?: string;
|
|
name: string;
|
|
content: string;
|
|
category?: string;
|
|
variables?: Array<{ name: string; example?: string; required?: boolean }>;
|
|
}
|
|
|
|
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId'>> & {
|
|
auditStatus?: string;
|
|
};
|
|
|
|
export interface ReviewDto {
|
|
reviewerId?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface StatusChangeDto {
|
|
status?: string;
|
|
operatorId?: string;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface TemplateListQuery {
|
|
tenantId?: string;
|
|
status?: string;
|
|
keyword?: string;
|
|
}
|
|
|
|
export interface ApplicationListQuery {
|
|
tenantId?: string;
|
|
keyword?: string;
|
|
includeConnections?: boolean;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SmsConfigService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
|
|
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
|
return this.prisma.smsApplication.findMany({
|
|
where: {
|
|
tenantId: query.tenantId,
|
|
OR: query.keyword ? [
|
|
{ name: { contains: query.keyword } },
|
|
{ tenant: { name: { contains: query.keyword } } },
|
|
] : undefined,
|
|
},
|
|
include: {
|
|
tenant: true,
|
|
ipAllowlist: true,
|
|
messageRecords: { where: { queuedAt: { gte: startOfToday() } }, take: 1000 },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
}).then(async (applications) => {
|
|
if (!query.includeConnections) {
|
|
return applications;
|
|
}
|
|
const applicationIds = applications.map((application) => application.id);
|
|
const connections = await this.prisma.cmppConnectionState.findMany({
|
|
where: { applicationId: { in: applicationIds } },
|
|
include: { channel: true },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 500,
|
|
});
|
|
return applications.map((application) => {
|
|
const appConnections = connections.filter((connection) => connection.applicationId === application.id);
|
|
const todayTotal = application.messageRecords.length;
|
|
const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length;
|
|
return {
|
|
...application,
|
|
cmppConnections: appConnections,
|
|
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
|
|
sentToday: todayTotal,
|
|
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
async getApplication(applicationId: string) {
|
|
const application = await this.prisma.smsApplication.findUnique({
|
|
where: { id: applicationId },
|
|
include: {
|
|
tenant: true,
|
|
ipAllowlist: true,
|
|
},
|
|
});
|
|
if (!application) {
|
|
throw new NotFoundException('Application not found');
|
|
}
|
|
return application;
|
|
}
|
|
|
|
createApplication(data: CreateSmsApplicationDto) {
|
|
const secret = randomBytes(24).toString('hex');
|
|
return this.prisma.smsApplication.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
name: data.name,
|
|
scene: data.scene,
|
|
callbackUrl: data.callbackUrl,
|
|
secretHash: hashSecret(secret),
|
|
dailyLimit: data.dailyLimit,
|
|
customerUnitPrice: data.customerUnitPrice ?? 0,
|
|
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
|
|
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
|
ipAllowlist: {
|
|
create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })),
|
|
},
|
|
},
|
|
include: { ipAllowlist: true },
|
|
});
|
|
}
|
|
|
|
async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) {
|
|
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
|
if (!application) {
|
|
throw new NotFoundException('Application not found');
|
|
}
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
if (data.ipAllowlist) {
|
|
await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } });
|
|
}
|
|
return tx.smsApplication.update({
|
|
where: { id: applicationId },
|
|
data: {
|
|
name: data.name,
|
|
scene: data.scene,
|
|
callbackUrl: data.callbackUrl,
|
|
dailyLimit: data.dailyLimit,
|
|
customerUnitPrice: data.customerUnitPrice,
|
|
maxPhonesPerTask: data.maxPhonesPerTask,
|
|
templateMismatchMode: data.templateMismatchMode,
|
|
status: data.status,
|
|
ipAllowlist: data.ipAllowlist ? {
|
|
create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })),
|
|
} : undefined,
|
|
},
|
|
include: { tenant: true, ipAllowlist: true },
|
|
});
|
|
});
|
|
}
|
|
|
|
async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) {
|
|
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
|
if (!application) {
|
|
throw new NotFoundException('Application not found');
|
|
}
|
|
const routes = data.routes ?? [];
|
|
if (routes.length === 0) {
|
|
throw new BadRequestException('At least one carrier channel group is required');
|
|
}
|
|
|
|
const carriers = new Set<string>();
|
|
routes.forEach((route) => {
|
|
if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) {
|
|
throw new BadRequestException('carrier must be mobile, unicom or telecom');
|
|
}
|
|
if (carriers.has(route.carrier)) {
|
|
throw new BadRequestException('Duplicate carrier route is not allowed');
|
|
}
|
|
carriers.add(route.carrier);
|
|
});
|
|
|
|
const groups = await this.prisma.smsChannelGroup.findMany({
|
|
where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } },
|
|
select: { id: true, carrier: true },
|
|
});
|
|
const groupMap = new Map(groups.map((group) => [group.id, group]));
|
|
routes.forEach((route) => {
|
|
const group = groupMap.get(route.groupId);
|
|
if (!group) {
|
|
throw new BadRequestException(`channel group ${route.groupId} does not exist`);
|
|
}
|
|
if (group.carrier !== route.carrier) {
|
|
throw new BadRequestException('channel group carrier must match route carrier');
|
|
}
|
|
});
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
await tx.channelRouteRule.deleteMany({
|
|
where: {
|
|
applicationId,
|
|
channelId: null,
|
|
province: null,
|
|
},
|
|
});
|
|
await tx.channelRouteRule.createMany({
|
|
data: routes.map((route, index) => ({
|
|
tenantId: application.tenantId,
|
|
applicationId,
|
|
groupId: route.groupId,
|
|
carrier: route.carrier,
|
|
priority: route.priority ?? (index + 1) * 10,
|
|
status: route.status ?? 'active',
|
|
})),
|
|
});
|
|
return tx.channelRouteRule.findMany({
|
|
where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } },
|
|
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
|
});
|
|
});
|
|
}
|
|
|
|
async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) {
|
|
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
|
if (!application) {
|
|
throw new NotFoundException('Application not found');
|
|
}
|
|
const secret = randomBytes(24).toString('hex');
|
|
const updated = await this.prisma.smsApplication.update({
|
|
where: { id: applicationId },
|
|
data: { secretHash: hashSecret(secret) },
|
|
});
|
|
await this.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, {
|
|
reason: data.reason,
|
|
});
|
|
return { ...updated, secret };
|
|
}
|
|
|
|
async changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
|
|
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
|
if (!application) {
|
|
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,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async listApplicationConnections(applicationId: string) {
|
|
const application = await this.prisma.smsApplication.findUnique({
|
|
where: { id: applicationId },
|
|
include: { tenant: true },
|
|
});
|
|
if (!application) {
|
|
throw new NotFoundException('Application not found');
|
|
}
|
|
const connections = await this.prisma.cmppConnectionState.findMany({
|
|
where: { applicationId },
|
|
include: { channel: true },
|
|
orderBy: { updatedAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
return {
|
|
application,
|
|
connections,
|
|
summary: {
|
|
desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0),
|
|
currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0),
|
|
status: normalizeApplicationCmppStatus(connections, application.status),
|
|
},
|
|
};
|
|
}
|
|
|
|
async getApplicationCmppParams(applicationId: string, tenantId?: string) {
|
|
const application = await this.prisma.smsApplication.findUnique({
|
|
where: { id: applicationId },
|
|
include: { tenant: true },
|
|
});
|
|
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
|
throw new NotFoundException('Application not found');
|
|
}
|
|
const channel = await this.prisma.smsChannel.findFirst({
|
|
where: { status: { not: 'deleted' } },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
return {
|
|
applicationId: application.id,
|
|
applicationName: application.name,
|
|
tenantId: application.tenantId,
|
|
tenantName: application.tenant.name,
|
|
appCode: application.id,
|
|
gatewayHost: channel?.gatewayHost ?? '',
|
|
gatewayPort: channel?.gatewayPort ?? 0,
|
|
enterpriseCode: channel?.enterpriseCode ?? application.tenant.code,
|
|
account: channel?.account ?? application.tenant.code,
|
|
passwordCipher: channel?.passwordCipher ?? application.secretHash,
|
|
srcId: channel?.srcId ?? '',
|
|
maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1,
|
|
heartbeatSeconds: 30,
|
|
windowSize: 16,
|
|
protocolVersion: channel?.cmppVersion ?? '3.0',
|
|
};
|
|
}
|
|
|
|
async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) {
|
|
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
|
if (!application) {
|
|
throw new NotFoundException('Application not found');
|
|
}
|
|
const connection = await this.prisma.cmppConnectionState.findFirst({
|
|
where: { applicationId, connectionId },
|
|
});
|
|
if (!connection) {
|
|
throw new NotFoundException('Connection not found');
|
|
}
|
|
const updated = await this.prisma.cmppConnectionState.update({
|
|
where: { id: connection.id },
|
|
data: {
|
|
status: 'disconnected',
|
|
currentConnections: 0,
|
|
lastDisconnectedAt: new Date(),
|
|
lastError: data.reason,
|
|
},
|
|
});
|
|
await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, {
|
|
applicationId,
|
|
reason: data.reason,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
|
|
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
|
return this.prisma.smsSignature.findMany({
|
|
where: {
|
|
tenantId: query.tenantId,
|
|
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
|
OR: query.keyword ? [
|
|
{ name: { contains: query.keyword } },
|
|
{ purpose: { contains: query.keyword } },
|
|
{ tenant: { name: { contains: query.keyword } } },
|
|
{ application: { name: { contains: query.keyword } } },
|
|
] : undefined,
|
|
},
|
|
include: { materials: true, tenant: true, application: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
createSignature(data: CreateSmsSignatureDto) {
|
|
return this.prisma.smsSignature.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
applicationId: data.applicationId,
|
|
name: data.name,
|
|
purpose: data.purpose,
|
|
drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) {
|
|
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
|
if (!signature) {
|
|
throw new NotFoundException('Signature not found');
|
|
}
|
|
return this.prisma.smsSignature.update({
|
|
where: { id: signatureId },
|
|
data: {
|
|
applicationId: data.applicationId,
|
|
name: data.name,
|
|
purpose: data.purpose,
|
|
auditStatus: data.auditStatus,
|
|
drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined,
|
|
},
|
|
include: { materials: true, tenant: true, application: true },
|
|
});
|
|
}
|
|
|
|
createSignatureMaterial(data: CreateSignatureMaterialDto) {
|
|
return this.prisma.signatureMaterial.create({
|
|
data: {
|
|
signatureId: data.signatureId,
|
|
fileObjectId: data.fileObjectId,
|
|
materialType: data.materialType,
|
|
title: data.title,
|
|
description: data.description,
|
|
},
|
|
});
|
|
}
|
|
|
|
async submitSignature(signatureId: string) {
|
|
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
|
if (!signature) {
|
|
throw new NotFoundException('Signature not found');
|
|
}
|
|
|
|
const updated = await this.prisma.smsSignature.update({
|
|
where: { id: signatureId },
|
|
data: { auditStatus: 'pending', rejectReason: null },
|
|
});
|
|
await this.createAuditRecord({
|
|
tenantId: signature.tenantId,
|
|
targetType: 'sms_signature',
|
|
targetId: signatureId,
|
|
action: 'submit',
|
|
statusBefore: signature.auditStatus,
|
|
statusAfter: 'pending',
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
listTemplates(queryOrTenantId?: string | TemplateListQuery) {
|
|
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
|
return this.prisma.smsTemplate.findMany({
|
|
where: {
|
|
tenantId: query.tenantId,
|
|
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
|
OR: query.keyword ? [
|
|
{ name: { contains: query.keyword } },
|
|
{ content: { contains: query.keyword } },
|
|
{ category: { contains: query.keyword } },
|
|
{ application: { name: { contains: query.keyword } } },
|
|
{ tenant: { name: { contains: query.keyword } } },
|
|
] : undefined,
|
|
},
|
|
include: { variables: true, application: true, tenant: true, signature: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
createTemplate(data: CreateSmsTemplateDto) {
|
|
return this.prisma.smsTemplate.create({
|
|
data: {
|
|
tenantId: data.tenantId,
|
|
applicationId: data.applicationId,
|
|
signatureId: data.signatureId,
|
|
name: data.name,
|
|
content: data.content,
|
|
category: data.category,
|
|
billingUnits: estimateBillingUnits(data.content),
|
|
variables: {
|
|
create: (data.variables ?? inferTemplateVariables(data.content)).map((variable: TemplateVariableInput) => ({
|
|
name: variable.name,
|
|
example: variable.example,
|
|
required: variable.required ?? true,
|
|
})),
|
|
},
|
|
},
|
|
include: { variables: true, application: true, tenant: true, signature: true },
|
|
});
|
|
}
|
|
|
|
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto) {
|
|
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
|
if (!template) {
|
|
throw new NotFoundException('Template not found');
|
|
}
|
|
if (data.applicationId) {
|
|
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
|
if (!application || application.tenantId !== template.tenantId) {
|
|
throw new BadRequestException('applicationId does not belong to the template tenant');
|
|
}
|
|
}
|
|
if (data.signatureId) {
|
|
const signature = await this.prisma.smsSignature.findUnique({ where: { id: data.signatureId }, select: { tenantId: true } });
|
|
if (!signature || signature.tenantId !== template.tenantId) {
|
|
throw new BadRequestException('signatureId does not belong to the template tenant');
|
|
}
|
|
}
|
|
const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined);
|
|
return this.prisma.$transaction(async (tx) => {
|
|
if (variables) {
|
|
await tx.templateVariable.deleteMany({ where: { templateId } });
|
|
}
|
|
return tx.smsTemplate.update({
|
|
where: { id: templateId },
|
|
data: {
|
|
applicationId: data.applicationId,
|
|
signatureId: data.signatureId,
|
|
name: data.name,
|
|
content: data.content,
|
|
category: data.category,
|
|
auditStatus: data.auditStatus,
|
|
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
|
variables: variables ? {
|
|
create: variables.map((variable) => ({
|
|
name: variable.name,
|
|
example: variable.example,
|
|
required: variable.required ?? true,
|
|
})),
|
|
} : undefined,
|
|
},
|
|
include: { variables: true, application: true, tenant: true, signature: true },
|
|
});
|
|
});
|
|
}
|
|
|
|
async submitTemplate(templateId: string) {
|
|
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
|
if (!template) {
|
|
throw new NotFoundException('Template not found');
|
|
}
|
|
|
|
const updated = await this.prisma.smsTemplate.update({
|
|
where: { id: templateId },
|
|
data: { auditStatus: 'pending', rejectReason: null },
|
|
});
|
|
await this.createAuditRecord({
|
|
tenantId: template.tenantId,
|
|
targetType: 'sms_template',
|
|
targetId: templateId,
|
|
action: 'submit',
|
|
statusBefore: template.auditStatus,
|
|
statusAfter: 'pending',
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
listAuditRecords(targetType?: string, targetId?: string) {
|
|
return this.prisma.auditRecord.findMany({
|
|
where: {
|
|
targetType,
|
|
targetId,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 100,
|
|
});
|
|
}
|
|
|
|
approveSignature(signatureId: string, data: ReviewDto) {
|
|
return this.reviewSignature(signatureId, 'approved', 'approve', data);
|
|
}
|
|
|
|
rejectSignature(signatureId: string, data: ReviewDto) {
|
|
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
|
|
}
|
|
|
|
approveTemplate(templateId: string, data: ReviewDto) {
|
|
return this.reviewTemplate(templateId, 'approved', 'approve', data);
|
|
}
|
|
|
|
rejectTemplate(templateId: string, data: ReviewDto) {
|
|
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
|
|
}
|
|
|
|
async changeSignatureStatus(signatureId: string, data: StatusChangeDto) {
|
|
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
|
if (!signature) {
|
|
throw new NotFoundException('Signature not found');
|
|
}
|
|
const status = data.status ?? 'deleted';
|
|
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } });
|
|
await this.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
|
|
statusBefore: signature.auditStatus,
|
|
statusAfter: status,
|
|
reason: data.reason,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async changeTemplateStatus(templateId: string, data: StatusChangeDto) {
|
|
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
|
if (!template) {
|
|
throw new NotFoundException('Template not found');
|
|
}
|
|
const status = data.status ?? 'deleted';
|
|
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
|
|
await this.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, {
|
|
statusBefore: template.auditStatus,
|
|
statusAfter: status,
|
|
reason: data.reason,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
private async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) {
|
|
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
|
if (!signature) {
|
|
throw new NotFoundException('Signature not found');
|
|
}
|
|
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
|
|
|
const updated = await this.prisma.smsSignature.update({
|
|
where: { id: signatureId },
|
|
data: {
|
|
auditStatus: statusAfter,
|
|
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
|
},
|
|
});
|
|
await this.createAuditRecord({
|
|
tenantId: signature.tenantId,
|
|
targetType: 'sms_signature',
|
|
targetId: signatureId,
|
|
action,
|
|
statusBefore: signature.auditStatus,
|
|
statusAfter,
|
|
reason: data.reason,
|
|
reviewerId,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
|
|
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
|
if (!template) {
|
|
throw new NotFoundException('Template not found');
|
|
}
|
|
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
|
|
|
const updated = await this.prisma.smsTemplate.update({
|
|
where: { id: templateId },
|
|
data: {
|
|
auditStatus: statusAfter,
|
|
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
|
},
|
|
});
|
|
await this.createAuditRecord({
|
|
tenantId: template.tenantId,
|
|
targetType: 'sms_template',
|
|
targetId: templateId,
|
|
action,
|
|
statusBefore: template.auditStatus,
|
|
statusAfter,
|
|
reason: data.reason,
|
|
reviewerId,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
private async resolveReviewerId(reviewerId?: string) {
|
|
if (!reviewerId) {
|
|
return undefined;
|
|
}
|
|
const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } });
|
|
if (!reviewer) {
|
|
throw new BadRequestException('reviewerId does not reference an existing user');
|
|
}
|
|
return reviewerId;
|
|
}
|
|
|
|
private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
|
|
return this.prisma.auditRecord.create({ data });
|
|
}
|
|
|
|
private writeOperationLog(
|
|
tenantId: string,
|
|
userId: string | undefined,
|
|
action: string,
|
|
resource: string,
|
|
resourceId: string,
|
|
detail: Record<string, unknown>,
|
|
) {
|
|
return this.prisma.operationLog.create({
|
|
data: {
|
|
tenantId,
|
|
userId,
|
|
action,
|
|
resource,
|
|
resourceId,
|
|
detail: detail as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
interface TemplateVariableInput {
|
|
name: string;
|
|
example?: string;
|
|
required?: boolean;
|
|
}
|
|
|
|
function hashSecret(secret: string) {
|
|
return createHash('sha256').update(secret).digest('hex');
|
|
}
|
|
|
|
function estimateBillingUnits(content: string) {
|
|
const length = [...content].length;
|
|
if (length <= 70) {
|
|
return 1;
|
|
}
|
|
return Math.ceil(length / 67);
|
|
}
|
|
|
|
function inferTemplateVariables(content: string): TemplateVariableInput[] {
|
|
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
|
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
|
}
|
|
|
|
function startOfToday() {
|
|
const date = new Date();
|
|
date.setHours(0, 0, 0, 0);
|
|
return date;
|
|
}
|
|
|
|
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
|
|
if (applicationStatus !== 'active') {
|
|
return 'inactive';
|
|
}
|
|
if (connections.some((connection) => connection.status === 'connected' && connection.currentConnections > 0)) {
|
|
return 'connected';
|
|
}
|
|
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
|
return 'degraded';
|
|
}
|
|
return 'disconnected';
|
|
}
|