feat: complete cmpp platform phases 0-5
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ReviewDto, SmsConfigService } from './sms-config.service';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
export class AdminSmsConfigController {
|
||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
@Get('enterprise-applications')
|
||||
listApplications(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listApplications(tenantId);
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures')
|
||||
listSignatures(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listSignatures(tenantId);
|
||||
}
|
||||
|
||||
@Get('enterprise-templates')
|
||||
listTemplates(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listTemplates(tenantId);
|
||||
}
|
||||
|
||||
@Get('audit-records')
|
||||
listAuditRecords(@Query('targetType') targetType?: string, @Query('targetId') targetId?: string) {
|
||||
return this.smsConfig.listAuditRecords(targetType, targetId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/approve')
|
||||
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveSignature(signatureId, body);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/reject')
|
||||
rejectSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectSignature(signatureId, body);
|
||||
}
|
||||
|
||||
@Post('templates/:id/approve')
|
||||
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Post('templates/:id/reject')
|
||||
rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectTemplate(templateId, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import {
|
||||
CreateSignatureMaterialDto,
|
||||
CreateSmsApplicationDto,
|
||||
CreateSmsSignatureDto,
|
||||
CreateSmsTemplateDto,
|
||||
SmsConfigService,
|
||||
} from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@Controller('client')
|
||||
export class ClientSmsConfigController {
|
||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
@Get('applications')
|
||||
listApplications(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listApplications(tenantId);
|
||||
}
|
||||
|
||||
@Post('applications')
|
||||
createApplication(@Body() body: CreateSmsApplicationDto) {
|
||||
return this.smsConfig.createApplication(body);
|
||||
}
|
||||
|
||||
@Get('signatures')
|
||||
listSignatures(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listSignatures(tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures')
|
||||
createSignature(@Body() body: CreateSmsSignatureDto) {
|
||||
return this.smsConfig.createSignature(body);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/materials')
|
||||
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: Omit<CreateSignatureMaterialDto, 'signatureId'>) {
|
||||
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
|
||||
}
|
||||
|
||||
@Post('signatures/:id/submit')
|
||||
submitSignature(@Param('id') signatureId: string) {
|
||||
return this.smsConfig.submitSignature(signatureId);
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
listTemplates(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listTemplates(tenantId);
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
createTemplate(@Body() body: CreateSmsTemplateDto) {
|
||||
return this.smsConfig.createTemplate(body);
|
||||
}
|
||||
|
||||
@Post('templates/:id/submit')
|
||||
submitTemplate(@Param('id') templateId: string) {
|
||||
return this.smsConfig.submitTemplate(templateId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminSmsConfigController } from './admin-sms-config.controller';
|
||||
import { ClientSmsConfigController } from './client-sms-config.controller';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ClientSmsConfigController, AdminSmsConfigController],
|
||||
providers: [SmsConfigService],
|
||||
exports: [SmsConfigService],
|
||||
})
|
||||
export class SmsConfigModule {}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { 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;
|
||||
maxPhonesPerTask?: number;
|
||||
templateMismatchMode?: string;
|
||||
ipAllowlist?: string[];
|
||||
}
|
||||
|
||||
export interface CreateSmsSignatureDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
name: string;
|
||||
purpose?: string;
|
||||
drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 interface ReviewDto {
|
||||
reviewerId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsConfigService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listApplications(tenantId?: string) {
|
||||
return this.prisma.smsApplication.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { ipAllowlist: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
|
||||
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
||||
ipAllowlist: {
|
||||
create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })),
|
||||
},
|
||||
},
|
||||
include: { ipAllowlist: true },
|
||||
});
|
||||
}
|
||||
|
||||
listSignatures(tenantId?: string) {
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { materials: 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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(tenantId?: string) {
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { variables: 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 },
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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: data.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 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: data.reviewerId,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
private createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
|
||||
return this.prisma.auditRecord.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user