fix: harden real backend admin workflows and ui
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
|
||||
import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
@@ -12,6 +12,26 @@ export class AdminSmsConfigController {
|
||||
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id')
|
||||
getApplication(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.getApplication(applicationId);
|
||||
}
|
||||
|
||||
@Post('enterprise-applications')
|
||||
createApplication(@Body() body: CreateSmsApplicationDto) {
|
||||
return this.smsConfig.createApplication(body);
|
||||
}
|
||||
|
||||
@Put('enterprise-applications/:id')
|
||||
updateApplication(@Param('id') applicationId: string, @Body() body: UpdateSmsApplicationDto) {
|
||||
return this.smsConfig.updateApplication(applicationId, body);
|
||||
}
|
||||
|
||||
@Put('enterprise-applications/:id/route-rules')
|
||||
replaceApplicationRouteRules(@Param('id') applicationId: string, @Body() body: ReplaceApplicationRouteRulesDto) {
|
||||
return this.smsConfig.replaceApplicationRouteRules(applicationId, body);
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id/connections')
|
||||
listApplicationConnections(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.listApplicationConnections(applicationId);
|
||||
@@ -33,8 +53,18 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures')
|
||||
listSignatures(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listSignatures(tenantId);
|
||||
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.smsConfig.listSignatures({ tenantId, keyword, status });
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures')
|
||||
createSignature(@Body() body: CreateSmsSignatureDto) {
|
||||
return this.smsConfig.createSignature(body);
|
||||
}
|
||||
|
||||
@Put('enterprise-signatures/:id')
|
||||
updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto) {
|
||||
return this.smsConfig.updateSignature(signatureId, body);
|
||||
}
|
||||
|
||||
@Get('enterprise-templates')
|
||||
@@ -42,6 +72,16 @@ export class AdminSmsConfigController {
|
||||
return this.smsConfig.listTemplates({ tenantId, status, keyword });
|
||||
}
|
||||
|
||||
@Post('enterprise-templates')
|
||||
createTemplate(@Body() body: CreateSmsTemplateDto) {
|
||||
return this.smsConfig.createTemplate(body);
|
||||
}
|
||||
|
||||
@Put('enterprise-templates/:id')
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) {
|
||||
return this.smsConfig.updateTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Get('audit-records')
|
||||
listAuditRecords(@Query('targetType') targetType?: string, @Query('targetId') targetId?: string) {
|
||||
return this.smsConfig.listAuditRecords(targetType, targetId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreateSmsTemplateDto,
|
||||
StatusChangeDto,
|
||||
SmsConfigService,
|
||||
UpdateSmsTemplateDto,
|
||||
} from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@@ -25,6 +26,11 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.createApplication(body);
|
||||
}
|
||||
|
||||
@Get('applications/:id/cmpp-params')
|
||||
getApplicationCmppParams(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
|
||||
}
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.resetApplicationSecret(applicationId, body);
|
||||
@@ -70,6 +76,11 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.createTemplate(body);
|
||||
}
|
||||
|
||||
@Put('templates/:id')
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) {
|
||||
return this.smsConfig.updateTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Post('templates/:id/submit')
|
||||
submitTemplate(@Param('id') templateId: string) {
|
||||
return this.smsConfig.submitTemplate(templateId);
|
||||
|
||||
@@ -19,14 +19,59 @@ function createPrismaMock() {
|
||||
secretHash: 'secret-hash',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })),
|
||||
},
|
||||
smsApplicationIpAllowlist: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
smsChannelGroup: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'group-mobile', carrier: 'mobile' },
|
||||
{ id: 'group-unicom', carrier: 'unicom' },
|
||||
]),
|
||||
},
|
||||
channelRouteRule: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' },
|
||||
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
|
||||
]),
|
||||
},
|
||||
smsSignature: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'sig-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
name: '签名A',
|
||||
purpose: '行业通知',
|
||||
auditStatus: 'pending',
|
||||
drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'pending', telecom: 'filing' }, links: [] },
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
application: { id: 'app-1', name: '应用A' },
|
||||
materials: [],
|
||||
}]),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
update: jest.fn(),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
|
||||
},
|
||||
smsTemplate: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'tpl-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
name: '模板A',
|
||||
content: '您好${name}',
|
||||
auditStatus: 'pending',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
application: { id: 'app-1', name: '应用A' },
|
||||
signature: { id: 'sig-1', name: '签名A' },
|
||||
variables: [{ name: 'name', required: true }],
|
||||
}]),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
update: jest.fn(),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-new', ...data })),
|
||||
},
|
||||
auditRecord: {
|
||||
create: jest.fn(),
|
||||
@@ -56,6 +101,28 @@ function createPrismaMock() {
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback) => callback({
|
||||
smsApplication: {
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
||||
},
|
||||
smsApplicationIpAllowlist: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
channelRouteRule: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' },
|
||||
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
|
||||
]),
|
||||
},
|
||||
templateVariable: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
smsTemplate: {
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +167,88 @@ describe('SmsConfigService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('updates enterprise application profile and allowlist through a transaction', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
smsApplication: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'app-1', name: '新应用', ipAllowlist: [{ ipCidr: '10.0.0.1/32' }] }),
|
||||
},
|
||||
smsApplicationIpAllowlist: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
channelRouteRule: prisma.channelRouteRule,
|
||||
};
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, ipAllowlist: ['10.0.0.1/32'] }))
|
||||
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
|
||||
|
||||
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
|
||||
expect(tx.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'app-1' },
|
||||
data: expect.objectContaining({
|
||||
name: '新应用',
|
||||
customerUnitPrice: 300,
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('replaces application carrier channel-group routes with carrier validation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
channelRouteRule: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'rule-1', carrier: 'mobile', groupId: 'group-mobile' },
|
||||
{ id: 'rule-2', carrier: 'unicom', groupId: 'group-unicom' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.replaceApplicationRouteRules('app-1', {
|
||||
routes: [
|
||||
{ carrier: 'mobile', groupId: 'group-mobile' },
|
||||
{ carrier: 'unicom', groupId: 'group-unicom' },
|
||||
],
|
||||
})).resolves.toEqual([
|
||||
expect.objectContaining({ carrier: 'mobile' }),
|
||||
expect.objectContaining({ carrier: 'unicom' }),
|
||||
]);
|
||||
|
||||
expect(tx.channelRouteRule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', channelId: null, province: null },
|
||||
});
|
||||
expect(tx.channelRouteRule.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-mobile', priority: 10 }),
|
||||
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'unicom', groupId: 'group-unicom', priority: 20 }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects application routes when channel-group carrier does not match', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.replaceApplicationRouteRules('app-1', {
|
||||
routes: [{ carrier: 'telecom', groupId: 'group-mobile' }],
|
||||
})).rejects.toThrow('channel group carrier must match route carrier');
|
||||
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides CMPP params when the application belongs to another tenant', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
|
||||
});
|
||||
|
||||
it('disconnects application CMPP connections and writes operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
@@ -118,4 +267,112 @@ describe('SmsConfigService', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('lists enterprise signatures with keyword filters and real relations', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listSignatures({ keyword: '签名A' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
tenant: expect.objectContaining({ name: '租户A' }),
|
||||
application: expect.objectContaining({ name: '应用A' }),
|
||||
}),
|
||||
]);
|
||||
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: expect.any(Array),
|
||||
}),
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
}));
|
||||
});
|
||||
|
||||
it('updates enterprise signature drainage info through the admin API path', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateSignature('sig-1', {
|
||||
name: '签名B',
|
||||
auditStatus: 'approved',
|
||||
drainageInfo: {
|
||||
carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' },
|
||||
links: [{ id: 'drain-1', siteName: '官网', url: 'https://example.com' }],
|
||||
},
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
name: '签名B',
|
||||
auditStatus: 'approved',
|
||||
}));
|
||||
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1' },
|
||||
data: expect.objectContaining({
|
||||
name: '签名B',
|
||||
auditStatus: 'approved',
|
||||
drainageInfo: expect.objectContaining({
|
||||
carrierStatus: expect.objectContaining({ mobile: 'approved' }),
|
||||
}),
|
||||
}),
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('lists enterprise templates with real relations and excludes deleted by default', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listTemplates({ keyword: '模板A' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tpl-1',
|
||||
tenant: expect.objectContaining({ name: '租户A' }),
|
||||
application: expect.objectContaining({ name: '应用A' }),
|
||||
signature: expect.objectContaining({ name: '签名A' }),
|
||||
}),
|
||||
]);
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: expect.any(Array),
|
||||
}),
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
}));
|
||||
});
|
||||
|
||||
it('updates enterprise templates and rebuilds template variables', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
templateVariable: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
smsTemplate: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'tpl-1', name: '模板B', variables: [{ name: 'code' }] }),
|
||||
},
|
||||
};
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateTemplate('tpl-1', {
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
name: '模板B',
|
||||
content: '验证码${code}',
|
||||
variables: [{ name: 'code', example: '123456', required: true }],
|
||||
})).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' }));
|
||||
|
||||
expect(tx.templateVariable.deleteMany).toHaveBeenCalledWith({ where: { templateId: 'tpl-1' } });
|
||||
expect(tx.smsTemplate.update).toHaveBeenCalledWith({
|
||||
where: { id: 'tpl-1' },
|
||||
data: expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
name: '模板B',
|
||||
content: '验证码${code}',
|
||||
variables: {
|
||||
create: [{ name: 'code', example: '123456', required: true }],
|
||||
},
|
||||
}),
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,19 @@ export interface CreateSmsApplicationDto {
|
||||
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;
|
||||
@@ -23,6 +36,10 @@ export interface CreateSmsSignatureDto {
|
||||
drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface CreateSignatureMaterialDto {
|
||||
signatureId: string;
|
||||
fileObjectId?: string;
|
||||
@@ -41,6 +58,10 @@ export interface CreateSmsTemplateDto {
|
||||
variables?: Array<{ name: string; example?: string; required?: boolean }>;
|
||||
}
|
||||
|
||||
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId'>> & {
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface ReviewDto {
|
||||
reviewerId?: string;
|
||||
reason?: string;
|
||||
@@ -111,6 +132,20 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -132,6 +167,97 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -188,12 +314,12 @@ export class SmsConfigService {
|
||||
};
|
||||
}
|
||||
|
||||
async getApplicationCmppParams(applicationId: string) {
|
||||
async getApplicationCmppParams(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application) {
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findFirst({
|
||||
@@ -246,10 +372,20 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
listSignatures(tenantId?: string) {
|
||||
listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { materials: true },
|
||||
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,
|
||||
});
|
||||
@@ -267,6 +403,24 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -305,7 +459,7 @@ export class SmsConfigService {
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
@@ -338,7 +492,52 @@ export class SmsConfigService {
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { variables: 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 },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user