fix: align signature review and admin operations
This commit is contained in:
@@ -556,7 +556,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
submitId,
|
||||
status: 'submit_queued',
|
||||
submitStatus: 'queued',
|
||||
errorMessage: '运营端通道测试短信',
|
||||
},
|
||||
});
|
||||
await this.prisma.smsSubmitRecord.create({
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('OperationsService', () => {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: { include: { channel: true } },
|
||||
submitRecords: { include: { channel: true, channelGroup: true } },
|
||||
receiptRecords: { include: { channel: true } },
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
@@ -341,6 +341,13 @@ describe('OperationsService', () => {
|
||||
|
||||
it('builds dashboard and statistics aggregates', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([{
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '租户A',
|
||||
todaySpendCents: 24000n,
|
||||
balanceCents: 1000000n,
|
||||
creditCents: 50000n,
|
||||
}]);
|
||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(2)
|
||||
@@ -365,6 +372,13 @@ describe('OperationsService', () => {
|
||||
total: 5,
|
||||
},
|
||||
today: expect.objectContaining({ returnedCents: 10 }),
|
||||
enterpriseSpendRanks: [{
|
||||
tenantId: 'tenant-1',
|
||||
tenantName: '租户A',
|
||||
todaySpendCents: 24000,
|
||||
balanceCents: 1000000,
|
||||
creditCents: 50000,
|
||||
}],
|
||||
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
downstreamDeliverySummary: expect.objectContaining({
|
||||
pending: 3,
|
||||
@@ -445,6 +459,8 @@ describe('OperationsService', () => {
|
||||
tenantName: '租户A',
|
||||
hasDrainage: false,
|
||||
total: 5,
|
||||
acceptedCount: 4,
|
||||
submitFailureCount: 1,
|
||||
successCount: 3,
|
||||
unknownCount: 1,
|
||||
failureCount: 1,
|
||||
@@ -488,7 +504,13 @@ describe('OperationsService', () => {
|
||||
submitFailureRate: 20,
|
||||
successRate: 60,
|
||||
})],
|
||||
signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })],
|
||||
signatures: [expect.objectContaining({
|
||||
signatureId: 'signature-1',
|
||||
signatureName: '【测试签名】',
|
||||
hasDrainage: false,
|
||||
acceptedCount: 4,
|
||||
submitFailureCount: 1,
|
||||
})],
|
||||
applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })],
|
||||
});
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(4);
|
||||
@@ -868,7 +890,7 @@ describe('OperationsService', () => {
|
||||
messageRecord: undefined,
|
||||
},
|
||||
include: { channel: true, submitRecord: true },
|
||||
orderBy: [{ submitId: 'asc' }, { segmentIndex: 'asc' }],
|
||||
orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,7 +102,7 @@ export class OperationsService {
|
||||
tenant: true,
|
||||
application: true,
|
||||
channel: true,
|
||||
submitRecords: { include: { channel: true } },
|
||||
submitRecords: { include: { channel: true, channelGroup: true } },
|
||||
receiptRecords: { include: { channel: true } },
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
@@ -179,6 +179,7 @@ export class OperationsService {
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const sinceToday = startOfToday();
|
||||
const businessDay = qualityBusinessDay();
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
||||
@@ -194,6 +195,7 @@ export class OperationsService {
|
||||
tenantAccounts,
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
enterpriseSpendRows,
|
||||
downstreamPendingCount,
|
||||
downstreamFailedCount,
|
||||
downstreamDeliveredCount,
|
||||
@@ -253,6 +255,29 @@ export class OperationsService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
todaySpendCents: bigint;
|
||||
balanceCents: bigint;
|
||||
creditCents: bigint;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents",
|
||||
account."balanceCents" AS "balanceCents",
|
||||
account."creditCents" AS "creditCents"
|
||||
FROM "TenantAccount" account
|
||||
JOIN "Tenant" tenant ON tenant.id = account."tenantId"
|
||||
LEFT JOIN "SmsBillingRecord" billing
|
||||
ON billing."tenantId" = tenant.id
|
||||
AND billing."createdAt" >= ${businessDay.startAt}
|
||||
AND billing."createdAt" < ${businessDay.endAt}
|
||||
WHERE (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null})
|
||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||
`),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'pending' },
|
||||
}),
|
||||
@@ -314,6 +339,13 @@ export class OperationsService {
|
||||
alertCount: downstreamAlertCount,
|
||||
},
|
||||
accounts: tenantAccounts,
|
||||
enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({
|
||||
tenantId: row.tenantId,
|
||||
tenantName: row.tenantName,
|
||||
todaySpendCents: moneyToNumber(row.todaySpendCents),
|
||||
balanceCents: moneyToNumber(row.balanceCents),
|
||||
creditCents: moneyToNumber(row.creditCents),
|
||||
})),
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
};
|
||||
@@ -333,6 +365,7 @@ export class OperationsService {
|
||||
pendingAudits: dashboard.pendingAudits,
|
||||
downstreamDeliverySummary: dashboard.downstreamDeliverySummary,
|
||||
accounts: dashboard.accounts.map(clientAccountView),
|
||||
enterpriseSpendRanks: dashboard.enterpriseSpendRanks,
|
||||
recentTasks: dashboard.recentTasks.map(clientBatchTaskView),
|
||||
recentRecharges: dashboard.recentRecharges.map(clientRechargeView),
|
||||
};
|
||||
@@ -468,6 +501,8 @@ export class OperationsService {
|
||||
tenantName: string;
|
||||
hasDrainage: boolean;
|
||||
total: number;
|
||||
acceptedCount: number;
|
||||
submitFailureCount: number;
|
||||
successCount: number;
|
||||
unknownCount: number;
|
||||
failureCount: number;
|
||||
@@ -479,6 +514,7 @@ export class OperationsService {
|
||||
message."signatureId" AS signature_id,
|
||||
(message."drainageInfoId" IS NOT NULL) AS has_drainage,
|
||||
message.status,
|
||||
message."submitStatus" AS submit_status,
|
||||
message."receiptStatus" AS receipt_status,
|
||||
CASE
|
||||
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
|
||||
@@ -498,23 +534,40 @@ export class OperationsService {
|
||||
tenant.id AS "tenantId",
|
||||
tenant.name AS "tenantName",
|
||||
base.has_drainage AS "hasDrainage",
|
||||
COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected')::integer AS total,
|
||||
COUNT(*)::integer AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
)::integer AS "acceptedCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE base.status = 'submit_failed'
|
||||
OR base.submit_status IN ('rejected', 'timeout')
|
||||
)::integer AS "submitFailureCount",
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'rejected'
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "unknownCount",
|
||||
COUNT(*) FILTER (
|
||||
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
|
||||
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
|
||||
)::integer AS "failureCount",
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected') = 0 THEN 0
|
||||
WHEN COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
) = 0 THEN 0
|
||||
ELSE ROUND(
|
||||
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
|
||||
* 100.0
|
||||
/ COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected'),
|
||||
/ COUNT(*) FILTER (
|
||||
WHERE COALESCE(base.status, '') <> 'submit_failed'
|
||||
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
|
||||
),
|
||||
1
|
||||
)::double precision
|
||||
END AS "successRate",
|
||||
@@ -1016,7 +1069,7 @@ export class OperationsService {
|
||||
messageRecord: query.messageId ? { messageId: query.messageId } : undefined,
|
||||
},
|
||||
include: { channel: true, submitRecord: true },
|
||||
orderBy: [{ submitId: 'asc' }, { segmentIndex: 'asc' }],
|
||||
orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ function createPrismaMock() {
|
||||
status: 'active',
|
||||
group: {
|
||||
id: 'group-1',
|
||||
name: '默认通道组',
|
||||
carrier: 'mobile',
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
@@ -1753,7 +1754,13 @@ describe('SendChainService', () => {
|
||||
);
|
||||
|
||||
expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1', submitStatus: 'queued' }),
|
||||
data: expect.objectContaining({
|
||||
messageRecordId: 'record-1',
|
||||
channelId: 'channel-1',
|
||||
channelGroupId: 'group-1',
|
||||
channelGroupName: '默认通道组',
|
||||
submitStatus: 'queued',
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
|
||||
@@ -264,6 +264,7 @@ type RoutedChannel = {
|
||||
carrier: string;
|
||||
province?: string | null;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
routeScope: 'province' | 'national';
|
||||
};
|
||||
|
||||
@@ -3373,6 +3374,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
batchTaskId: message.batchTaskId,
|
||||
messageRecordId: message.id,
|
||||
channelId: channel.id,
|
||||
channelGroupId: routed.groupId,
|
||||
channelGroupName: routed.groupName,
|
||||
sessionId: session.id,
|
||||
retryOfSubmitRecordId,
|
||||
submitId,
|
||||
@@ -3651,6 +3654,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
carrier,
|
||||
province,
|
||||
groupId: route.groupId,
|
||||
groupName: route.group.name,
|
||||
routeScope: isNationalChannel(selected) ? 'national' : 'province',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,16 @@ import { ReviewGovernanceService } from './review-governance.service';
|
||||
function signature(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: null,
|
||||
drainageInfo: { signatureProfile: { companyName: '企业A', creditCode: '9133', legalPersonName: '法人', responsibleName: '责任人', responsiblePhone: '13800000000', credentialFile: { fileObjectId: 'file-1' } } },
|
||||
drainageInfo: {
|
||||
signatureReportValues: { companyName: '企业A', credentialFile: { fileObjectId: 'file-1' } },
|
||||
reportRequirementSnapshot: {
|
||||
fields: [
|
||||
{ code: 'companyName', name: '公司名称', required: true, reportTypes: ['signature'] },
|
||||
{ code: 'credentialFile', name: '资质文件', required: true, reportTypes: ['signature'] },
|
||||
{ code: 'siteOwner', name: '网站主体', required: true, reportTypes: ['drainage'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
auditStatus: 'pending', reportStatus: 'waiting_material', rejectReason: null, materialVersion: 1,
|
||||
pendingReport: true, reportChangedAt: new Date(), createdAt: new Date(), updatedAt: new Date('2026-07-21T08:00:00.000Z'),
|
||||
tenant: { id: 'tenant-1', name: '企业A' }, application: { id: 'app-1', name: '应用A' }, materials: [],
|
||||
@@ -32,13 +41,46 @@ function prismaMock() {
|
||||
describe('ReviewGovernanceService', () => {
|
||||
it('blocks approval when required signature qualification is incomplete', async () => {
|
||||
const prisma = prismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue(signature({ applicationId: null, application: null, drainageInfo: {}, materials: [] }));
|
||||
prisma.smsSignature.findUnique.mockResolvedValue(signature({
|
||||
applicationId: null,
|
||||
application: null,
|
||||
drainageInfo: {
|
||||
signatureReportValues: {},
|
||||
reportRequirementSnapshot: {
|
||||
fields: [
|
||||
{ code: 'companyName', name: '公司名称', required: true, reportTypes: ['signature'] },
|
||||
{ code: 'credentialFile', name: '资质文件', required: true, reportTypes: ['both'] },
|
||||
{ code: 'siteOwner', name: '网站主体', required: true, reportTypes: ['drainage'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
materials: [],
|
||||
}));
|
||||
const service = new ReviewGovernanceService(prisma as never);
|
||||
|
||||
const result = await service.preflight('signature', 'sig-1');
|
||||
|
||||
expect(result.allowedActions).toEqual(['reject']);
|
||||
expect(result.blockedReasons).toEqual(expect.arrayContaining(['未绑定短信应用', '缺少公司名称', '缺少资质文件']));
|
||||
expect(result.blockedReasons).not.toContain('缺少网站主体');
|
||||
});
|
||||
|
||||
it('does not invent legacy qualification requirements when the configured channel has no signature fields', async () => {
|
||||
const prisma = prismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue(signature({
|
||||
drainageInfo: {
|
||||
signatureReportValues: {},
|
||||
reportRequirementSnapshot: {
|
||||
fields: [{ code: 'icpNo', name: 'ICP备案号', required: true, reportTypes: ['drainage'] }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const service = new ReviewGovernanceService(prisma as never);
|
||||
|
||||
const result = await service.preflight('signature', 'sig-1');
|
||||
|
||||
expect(result.allowedActions).toEqual(['approve', 'reject']);
|
||||
expect(result.blockedReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it('atomically approves the expected version and returns an audit operation id', async () => {
|
||||
|
||||
@@ -90,21 +90,24 @@ export class ReviewGovernanceService {
|
||||
});
|
||||
if (!item) throw new NotFoundException('Signature not found');
|
||||
const payload = asRecord(item.drainageInfo);
|
||||
const profile = asRecord(payload.signatureProfile);
|
||||
const missing: string[] = [];
|
||||
if (!item.applicationId) missing.push('未绑定短信应用');
|
||||
if (!String(profile.companyName ?? '').trim()) missing.push('缺少公司名称');
|
||||
if (!String(profile.creditCode ?? '').trim()) missing.push('缺少统一社会信用代码');
|
||||
if (!String(profile.legalPersonName ?? '').trim()) missing.push('缺少法人姓名');
|
||||
if (!String(profile.responsibleName ?? '').trim()) missing.push('缺少责任人姓名');
|
||||
if (!String(profile.responsiblePhone ?? '').trim()) missing.push('缺少责任人手机号');
|
||||
const profileHasFile = Object.values(profile).some((value) => Boolean(asRecord(value).fileObjectId));
|
||||
if (!profileHasFile && item.materials.length === 0) missing.push('缺少资质文件');
|
||||
const signatureValues = asRecord(payload.signatureReportValues);
|
||||
const requiredFields = reportRequirementFields(payload)
|
||||
.filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both'));
|
||||
for (const field of requiredFields) {
|
||||
if (!hasReportValue(signatureValues[field.code])) missing.push(`缺少${field.name}`);
|
||||
}
|
||||
return reviewPreflight('signature', item, {
|
||||
identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定应用' },
|
||||
blockedReasons: missing,
|
||||
impacts: ['通过后签名将进入报备资格链路', '已绑定模板和后续发送资格可能受此决定影响'],
|
||||
materialSummary: { qualificationFiles: item.materials.length + (profileHasFile ? 1 : 0), missingCount: missing.length },
|
||||
materialSummary: {
|
||||
configuredRequiredFields: requiredFields.length,
|
||||
submittedFields: Object.values(signatureValues).filter(hasReportValue).length,
|
||||
qualificationFiles: item.materials.length + Object.values(signatureValues).filter((value) => Boolean(asRecord(value).fileObjectId)).length,
|
||||
missingCount: missing.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,3 +159,30 @@ function normalizeIdempotencyKey(value: string) {
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function reportRequirementFields(payload: Record<string, unknown>) {
|
||||
const snapshot = asRecord(payload.reportRequirementSnapshot);
|
||||
if (!Array.isArray(snapshot.fields)) return [];
|
||||
return snapshot.fields.flatMap((value) => {
|
||||
const field = asRecord(value);
|
||||
const code = typeof field.code === 'string' ? field.code.trim() : '';
|
||||
if (!code) return [];
|
||||
const reportTypes = Array.isArray(field.reportTypes)
|
||||
? field.reportTypes.filter((type): type is string => typeof type === 'string')
|
||||
: [];
|
||||
return [{
|
||||
code,
|
||||
name: typeof field.name === 'string' && field.name.trim() ? field.name.trim() : code,
|
||||
required: field.required === true,
|
||||
reportTypes,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function hasReportValue(value: unknown) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const record = asRecord(value);
|
||||
return Boolean(record.fileObjectId || record.fieldValue || record.value);
|
||||
}
|
||||
return value !== undefined && value !== null && String(value).trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -250,21 +250,19 @@ describe('UsersService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forbids disabling the last active administrator of a tenant', async () => {
|
||||
it('allows disabling the last active administrator of a tenant', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.user.findFirst.mockResolvedValue({
|
||||
id: 'user-1', tenantId: 'tenant-1', username: 'admin', status: 'active', roles: [{ role: { code: 'enterprise_admin' } }],
|
||||
});
|
||||
prisma.user.count.mockResolvedValue(1);
|
||||
prisma.user.update.mockResolvedValue({
|
||||
id: 'user-1', tenantId: 'tenant-1', username: 'admin', status: 'disabled', roles: [{ role: { code: 'enterprise_admin' } }],
|
||||
});
|
||||
const service = new UsersService(prisma as never);
|
||||
|
||||
await expect(service.changeStatus('user-1', { status: 'disabled' }, 'tenant-1', 'operator-2'))
|
||||
.rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
code: 'LAST_ENTERPRISE_ADMIN',
|
||||
message: expect.stringContaining('请先创建或启用该企业的另一名管理员'),
|
||||
}),
|
||||
});
|
||||
.resolves.toEqual(expect.objectContaining({ id: 'user-1' }));
|
||||
expect(prisma.user.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps duplicate login identifiers to HTTP 409 with the conflicting field', async () => {
|
||||
|
||||
@@ -380,25 +380,23 @@ export class UsersService {
|
||||
nextTenantId?: string | null,
|
||||
) {
|
||||
const currentRole = current.roles[0]?.role.code;
|
||||
if (current.status !== 'active' || !['platform_admin', 'enterprise_admin'].includes(currentRole)) return;
|
||||
if (current.status !== 'active' || currentRole !== 'platform_admin') return;
|
||||
const remainsSameAdmin = nextStatus === 'active'
|
||||
&& nextRoleCode === currentRole
|
||||
&& (currentRole !== 'enterprise_admin' || nextTenantId === current.tenantId);
|
||||
&& nextTenantId === current.tenantId;
|
||||
if (remainsSameAdmin) return;
|
||||
const activeCount = await this.prisma.user.count({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
status: 'active',
|
||||
tenantId: currentRole === 'platform_admin' ? null : current.tenantId,
|
||||
tenantId: null,
|
||||
roles: { some: { role: { code: currentRole } } },
|
||||
},
|
||||
});
|
||||
if (activeCount <= 1) {
|
||||
throw new ConflictException({
|
||||
code: currentRole === 'platform_admin' ? 'LAST_PLATFORM_ADMIN' : 'LAST_ENTERPRISE_ADMIN',
|
||||
message: currentRole === 'platform_admin'
|
||||
? '不能删除、禁用或降权最后一个平台管理员;请先创建或启用另一名平台管理员'
|
||||
: '不能删除、禁用或降权最后一个企业管理员;请先创建或启用该企业的另一名管理员',
|
||||
code: 'LAST_PLATFORM_ADMIN',
|
||||
message: '不能删除、禁用或降权最后一个平台管理员;请先创建或启用另一名平台管理员',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user