fix: 限制有效签名名称唯一并保留批量导入补资料

This commit is contained in:
hectorzhao
2026-09-17 16:49:58 +08:00
parent 5e4d644788
commit 1676cfe622
13 changed files with 711 additions and 174 deletions
@@ -0,0 +1,24 @@
BEGIN;
-- Preserve every historical record. A conflicting installation must be reviewed before release.
LOCK TABLE "SmsSignature" IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "SmsSignature"
WHERE "auditStatus" NOT IN ('deleted', 'disabled')
GROUP BY "tenantId", "applicationId", "name" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'Cannot enforce signature uniqueness: duplicate active signatures exist; review tenantId/applicationId/name groups without deleting or merging automatically';
END IF;
END $$;
CREATE UNIQUE INDEX "SmsSignature_active_application_name_key"
ON "SmsSignature" ("tenantId", "applicationId", "name")
WHERE "applicationId" IS NOT NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
CREATE UNIQUE INDEX "SmsSignature_active_unbound_name_key"
ON "SmsSignature" ("tenantId", "name")
WHERE "applicationId" IS NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
COMMIT;
+2
View File
@@ -776,6 +776,8 @@ model ReportNotificationRead {
}
model SmsSignature {
// Active name uniqueness (including null applicationId) is enforced by two partial SQL indexes.
// Owned by migration 20260917120000_signature_active_name_unique; do not replace with @@unique.
id String @id @default(cuid())
tenantId String
applicationId String?
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import { SignatureNameConflict } from '../sms-config/signature-uniqueness';
import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
import {
normalizePage,
@@ -310,6 +311,19 @@ export class ReportImportReviewService {
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
}
private async findImportSignature(tenantId: string, applicationId: string | undefined, name: string) {
const where = { tenantId, applicationId: applicationId ?? null, name };
return (
(await this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: { notIn: ['deleted', 'disabled'] } },
})) ??
this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: 'disabled' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
);
}
async stageSignatureRow(
tenantId: string,
applicationId: string | undefined,
@@ -320,9 +334,7 @@ export class ReportImportReviewService {
if (!name) throw new Error('缺少短信签名');
const purpose = mappedCorePatchValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
const existing = await this.findImportSignature(tenantId, applicationId, name);
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
@@ -443,20 +455,22 @@ export class ReportImportReviewService {
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
} else {
const duplicate = await this.prisma.smsSignature.findFirst({
where: {
tenantId: batch.tenantId,
applicationId: applicationId ?? null,
name,
auditStatus: { not: 'deleted' },
},
});
const duplicate = await this.findImportSignature(batch.tenantId, applicationId, name);
if (duplicate) {
targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
} else {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id;
try {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id;
} catch (error) {
if (!(error instanceof SignatureNameConflict)) throw error;
// A concurrent create won. Imports continue to supplement the existing materials.
const current = await this.findImportSignature(batch.tenantId, applicationId, name);
if (!current) throw error;
targetId = current.id;
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
}
}
}
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
+149 -131
View File
@@ -1,177 +1,195 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import type { ReviewDto, StatusChangeDto } from './sms-config.contracts';
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
import { SmsReportValidationService } from './report-validation.service';
import { writeUniqueSignature } from './signature-uniqueness';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsAuditService {
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService, private readonly reportValidation: SmsReportValidationService) {}
constructor(
private readonly prisma: PrismaService,
private readonly lifecycle: SmsApplicationLifecycleService,
private readonly reportValidation: SmsReportValidationService,
) {}
listAuditRecords(targetType?: string, targetId?: string) {
return this.prisma.auditRecord.findMany({
where: {
targetType,
targetId,
},
include: {
reviewer: { select: { id: true, username: true, displayName: true } },
},
orderBy: { createdAt: 'desc' },
});
}
return this.prisma.auditRecord.findMany({
where: {
targetType,
targetId,
},
include: {
reviewer: { select: { id: true, username: true, displayName: true } },
},
orderBy: { createdAt: 'desc' },
});
}
approveSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'approved', 'approve', data);
}
return this.reviewSignature(signatureId, 'approved', 'approve', data);
}
rejectSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
}
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
}
approveTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'approved', 'approve', data);
}
return this.reviewTemplate(templateId, 'approved', 'approve', data);
}
rejectTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
}
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
}
async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
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.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
throw new NotFoundException('Signature not found');
}
const status = data.status ?? 'deleted';
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: status }, () =>
this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } }),
);
await this.lifecycle.writeOperationLog(
signature.tenantId,
data.operatorId,
`sms_signature.${status}`,
'sms_signature',
signatureId,
{
statusBefore: signature.auditStatus,
statusAfter: status,
reason: data.reason,
});
return updated;
}
},
);
return updated;
}
async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template || (tenantId && template.tenantId !== tenantId)) {
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.lifecycle.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template || (tenantId && template.tenantId !== tenantId)) {
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.lifecycle.writeOperationLog(
template.tenantId,
data.operatorId,
`sms_template.${status}`,
'sms_template',
templateId,
{
statusBefore: template.auditStatus,
statusAfter: status,
reason: data.reason,
});
return updated;
}
},
);
return updated;
}
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 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({
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: statusAfter }, () =>
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;
}
}),
);
await this.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
targetId: signatureId,
action,
statusBefore: signature.auditStatus,
statusAfter,
reason: data.reason,
reviewerId,
});
return updated;
}
async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
if (!['pending', 'rejected'].includes(item.auditStatus)) {
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
}
if (statusAfter === 'rejected' && !data.reason?.trim()) {
throw new BadRequestException('驳回引流信息时必须填写原因');
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsDrainageInfo.update({
where: { id: itemId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
reviewedAt: new Date(),
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action,
statusBefore: item.auditStatus,
statusAfter,
reason: data.reason,
reviewerId,
});
if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
return updated;
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
if (!['pending', 'rejected'].includes(item.auditStatus)) {
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
}
if (statusAfter === 'rejected' && !data.reason?.trim()) {
throw new BadRequestException('驳回引流信息时必须填写原因');
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsDrainageInfo.update({
where: { id: itemId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
reviewedAt: new Date(),
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action,
statusBefore: item.auditStatus,
statusAfter,
reason: data.reason,
reviewerId,
});
if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
return updated;
}
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;
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;
}
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;
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;
}
createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
return this.prisma.auditRecord.create({ data });
}
return this.prisma.auditRecord.create({ data });
}
}
@@ -0,0 +1,103 @@
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
import { SignatureNameConflict, writeUniqueSignature } from './signature-uniqueness';
describe('active signature uniqueness', () => {
const identity = { tenantId: 'tenant', applicationId: 'app', name: '【测试】' };
function fixture() {
const findFirst = jest.fn().mockResolvedValue(null);
return { findFirst, prisma: { smsSignature: { findFirst } } as unknown as PrismaService };
}
it('rejects duplicates before writing and returns a business conflict', async () => {
const { prisma, findFirst } = fixture();
findFirst.mockResolvedValue({ id: 'existing' });
const write = jest.fn();
await expect(writeUniqueSignature(prisma, identity, write)).rejects.toBeInstanceOf(SignatureNameConflict);
expect(write).not.toHaveBeenCalled();
expect(new SignatureNameConflict().getStatus()).toBe(409);
});
it('scopes null applications exactly and excludes the current record', async () => {
const { prisma, findFirst } = fixture();
await expect(
writeUniqueSignature(prisma, { ...identity, id: 'self', applicationId: null }, async () => 'ok'),
).resolves.toBe('ok');
expect(findFirst).toHaveBeenCalledWith({
where: {
tenantId: 'tenant',
applicationId: null,
name: '【测试】',
auditStatus: { notIn: ['deleted', 'disabled'] },
id: { not: 'self' },
},
select: { id: true },
});
});
it.each(['draft', 'pending', 'approved', 'rejected'])('reserves names in %s status', async (auditStatus) => {
const { prisma, findFirst } = fixture();
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
expect(findFirst).toHaveBeenCalledTimes(1);
});
it.each(['deleted', 'disabled'])('releases names in %s status', async (auditStatus) => {
const { prisma, findFirst } = fixture();
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
expect(findFirst).not.toHaveBeenCalled();
});
it.each([
[['tenantId', 'applicationId', 'name']],
[['tenantId', 'name']],
['SmsSignature_active_application_name_key'],
['SmsSignature_active_unbound_name_key'],
])('maps only the signature identity race (%j)', async (target) => {
const { prisma } = fixture();
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
code: 'P2002',
clientVersion: 'test',
meta: { target },
});
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBeInstanceOf(SignatureNameConflict);
});
it('preserves unrelated database failures', async () => {
const { prisma } = fixture();
for (const error of [
new Error('offline'),
new Prisma.PrismaClientKnownRequestError('id', {
code: 'P2002',
clientVersion: 'test',
meta: { target: ['id'] },
}),
]) {
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBe(error);
}
});
it('recognizes the real Prisma pg adapter metadata and quoted identifiers', async () => {
const { prisma } = fixture();
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
code: 'P2002',
clientVersion: '7.9.0',
meta: {
modelName: 'SmsSignature',
driverAdapterError: { cause: { constraint: { fields: ['"tenantId"', '"applicationId"', 'name'] } } },
},
});
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBeInstanceOf(SignatureNameConflict);
});
});
@@ -0,0 +1,61 @@
import { ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
export class SignatureNameConflict extends ConflictException {
constructor() {
super('同一企业、同一应用下已存在同名有效签名,请修改已有签名资料');
}
}
type SignatureIdentity = {
id?: string;
tenantId: string;
applicationId?: string | null;
name: string;
auditStatus?: string;
};
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
/** The partial SQL indexes are authoritative when concurrent requests pass the precheck. */
export async function writeUniqueSignature<T>(
prisma: PrismaService,
identity: SignatureIdentity,
write: () => Promise<T>,
): Promise<T> {
if (!['deleted', 'disabled'].includes(identity.auditStatus ?? 'draft')) {
const duplicate = await prisma.smsSignature.findFirst({
where: {
tenantId: identity.tenantId,
applicationId: identity.applicationId ?? null,
name: identity.name,
auditStatus: { notIn: ['deleted', 'disabled'] },
...(identity.id ? { id: { not: identity.id } } : {}),
},
select: { id: true },
});
if (duplicate) throw new SignatureNameConflict();
}
try {
return await write();
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
// Prisma's pg adapter exposes the constraint under driverAdapterError.cause (Prisma 7).
const constraint = record(record(record(error.meta?.driverAdapterError).cause).constraint);
const target = error.meta?.target ?? constraint.fields;
const fields = Array.isArray(target)
? target.map((field: unknown) => (typeof field === 'string' ? field.replace(/^"|"$/g, '') : field))
: [];
if (
(fields.includes('tenantId') && fields.includes('name')) ||
(typeof target === 'string' && /^SmsSignature_active_(application|unbound)_name_key$/.test(target))
) {
throw new SignatureNameConflict();
}
}
throw error;
}
}
+49 -30
View File
@@ -1,3 +1,4 @@
import { writeUniqueSignature } from './signature-uniqueness';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -766,16 +767,21 @@ export class SmsSignatureService {
data.drainageInfo,
);
const name = validateCompleteSmsSignature(data.name);
const signature = await this.prisma.smsSignature.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
name,
purpose: data.purpose,
auditStatus: options.initialAuditStatus,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
},
});
const signature = await writeUniqueSignature(
this.prisma,
{ ...data, name, auditStatus: options.initialAuditStatus },
() =>
this.prisma.smsSignature.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
name,
purpose: data.purpose,
auditStatus: options.initialAuditStatus,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
},
}),
);
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
if (options.initialAuditStatus) {
await this.audit.createAuditRecord({
@@ -805,10 +811,11 @@ export class SmsSignatureService {
throw new NotFoundException('Signature not found');
}
await this.reportValidation.validateSignatureReportValues(
data.applicationId ?? signature.applicationId ?? undefined,
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined,
data.drainageInfo,
);
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
const applicationId =
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined;
const drainageInfo = data.drainageInfo
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
: undefined;
@@ -822,21 +829,31 @@ export class SmsSignatureService {
const auditStatus =
options.initialAuditStatus ??
(materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus);
const updated = await this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
applicationId: data.applicationId,
name,
purpose: data.purpose,
auditStatus,
rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: materialChanged ? { increment: 1 } : undefined,
pendingReport: materialChanged ? true : undefined,
reportChangedAt: materialChanged ? new Date() : undefined,
const updated = await writeUniqueSignature(
this.prisma,
{
...signature,
applicationId: applicationId ?? null,
name: name ?? signature.name,
auditStatus: auditStatus ?? signature.auditStatus,
},
include: { materials: true, tenant: true, application: true },
});
() =>
this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
applicationId: data.applicationId,
name,
purpose: data.purpose,
auditStatus,
rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
materialVersion: materialChanged ? { increment: 1 } : undefined,
pendingReport: materialChanged ? true : undefined,
reportChangedAt: materialChanged ? new Date() : undefined,
},
include: { materials: true, tenant: true, application: true },
}),
);
await this.reportValidation.syncSignatureReportValues(
signatureId,
updated.applicationId ?? undefined,
@@ -897,10 +914,12 @@ export class SmsSignatureService {
throw new NotFoundException('Signature not found');
}
const updated = await this.prisma.smsSignature.update({
where: { id: signatureId },
data: { auditStatus: 'pending', rejectReason: null },
});
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: 'pending' }, () =>
this.prisma.smsSignature.update({
where: { id: signatureId },
data: { auditStatus: 'pending', rejectReason: null },
}),
);
await this.audit.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
@@ -88,6 +88,7 @@ function createPrismaMock() {
count: jest.fn().mockResolvedValue(0),
},
smsSignature: {
findFirst: jest.fn().mockResolvedValue(null),
groupBy: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([