feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } 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 { SmsReportValidationService } from './report-validation.service';
|
||||
import { SmsAuditService } from './audit.service';
|
||||
|
||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||
export class SmsSignatureService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
|
||||
async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
|
||||
drainageItems: query.drainageKeyword ? {
|
||||
some: {
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: [
|
||||
{ siteName: { contains: query.drainageKeyword } },
|
||||
{ url: { contains: query.drainageKeyword } },
|
||||
{ remark: { contains: query.drainageKeyword } },
|
||||
],
|
||||
},
|
||||
} : undefined,
|
||||
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,
|
||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
|
||||
reportTasks: { include: { channel: true, drainageInfo: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
...(query.page && query.pageSize ? {
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
} : {}),
|
||||
});
|
||||
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
|
||||
const routes = applicationIds.length ? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: { in: applicationIds }, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
|
||||
}) : [];
|
||||
const hasCommonDrainageFields = await this.prisma.commonReportField.count({
|
||||
where: { status: 'active', reportType: 'drainage', drainageField: { status: 'active' } },
|
||||
}).then((count) => count > 0);
|
||||
return signatures.map((signature) => {
|
||||
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
const drainageLinks = signature.drainageItems.map((item) => ({
|
||||
id: item.id,
|
||||
siteName: item.siteName,
|
||||
url: item.url,
|
||||
remark: item.remark ?? '',
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
auditStatus: item.auditStatus,
|
||||
rejectReason: item.rejectReason,
|
||||
submittedAt: item.submittedAt.toISOString(),
|
||||
reviewedAt: item.reviewedAt?.toISOString(),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
}));
|
||||
return {
|
||||
...signature,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
drainageInfo: { ...legacyPayload, links: drainageLinks },
|
||||
reportTargets: (() => {
|
||||
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
|
||||
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
|
||||
})(),
|
||||
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
||||
const drainageItemId = drainageItem.id;
|
||||
const channels = routes
|
||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
||||
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
|
||||
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
|
||||
const task = taskByChannel.get(channel.id);
|
||||
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
|
||||
})];
|
||||
})),
|
||||
drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
|
||||
const drainageItemId = drainageItem.id;
|
||||
const channels = routes
|
||||
.filter((route) => route.applicationId === signature.applicationId && route.group)
|
||||
.flatMap((route) => route.group!.items.map((item) => item.channel))
|
||||
.filter((channel) => channel.status !== 'deleted' && (hasCommonDrainageFields || channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType))));
|
||||
const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
|
||||
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
|
||||
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: statuses.length }];
|
||||
}))];
|
||||
})),
|
||||
carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
|
||||
const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all'));
|
||||
const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()];
|
||||
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
|
||||
const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
|
||||
const approved = statuses.filter((status) => status === 'approved').length;
|
||||
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
|
||||
return [carrier, { status, approved, total: targets.length }];
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listSignaturesPage(query: SignatureListQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const where: Prisma.SmsSignatureWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
|
||||
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
|
||||
drainageItems: query.drainageKeyword ? {
|
||||
some: {
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: [
|
||||
{ siteName: { contains: query.drainageKeyword } },
|
||||
{ url: { contains: query.drainageKeyword } },
|
||||
{ remark: { contains: query.drainageKeyword } },
|
||||
],
|
||||
},
|
||||
} : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ purpose: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listSignatures({ ...query, page, pageSize }),
|
||||
this.prisma.smsSignature.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
listSignatureOptions(tenantId?: string) {
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: { tenantId, auditStatus: { not: 'deleted' } },
|
||||
select: { id: true, tenantId: true, applicationId: true, name: true, auditStatus: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async listClientSignatures(tenantId?: string, signatureId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) {
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
id: signatureId,
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
{ application: { name: { contains: query.keyword.trim() } } },
|
||||
] : undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
name: true,
|
||||
purpose: true,
|
||||
auditStatus: true,
|
||||
reportStatus: true,
|
||||
pendingReport: true,
|
||||
reportChangedAt: true,
|
||||
rejectReason: true,
|
||||
drainageInfo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
materials: {
|
||||
select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true },
|
||||
},
|
||||
drainageItems: {
|
||||
where: { auditStatus: { not: 'deleted' } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { reportMaterials: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
|
||||
take: query.pageSize,
|
||||
});
|
||||
return signatures.map((signature) => {
|
||||
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
return {
|
||||
id: signature.id,
|
||||
tenantId: signature.tenantId,
|
||||
applicationId: signature.applicationId,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
purpose: signature.purpose,
|
||||
auditStatus: signature.auditStatus,
|
||||
reportStatus: signature.reportStatus,
|
||||
pendingReport: signature.pendingReport,
|
||||
reportChangedAt: signature.reportChangedAt,
|
||||
rejectReason: signature.rejectReason,
|
||||
createdAt: signature.createdAt,
|
||||
updatedAt: signature.updatedAt,
|
||||
application: signature.application,
|
||||
materials: signature.materials,
|
||||
submittedMaterialCount: signature.materials.length + signature._count.reportMaterials,
|
||||
reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {},
|
||||
drainageInfo: {
|
||||
links: signature.drainageItems.map((item) => ({
|
||||
...item,
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getClientSignatureView(signatureId: string, tenantId?: string) {
|
||||
const [signature] = await this.listClientSignatures(tenantId, signatureId);
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
return signature;
|
||||
}
|
||||
|
||||
async getClientSignatureWorkspace(tenantId?: string, query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const filteredWhere: Prisma.SmsSignatureWhereInput = {
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
{ application: { name: { contains: query.keyword.trim() } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total, statusCounts] = await Promise.all([
|
||||
this.listClientSignatures(tenantId, undefined, { ...query, page, pageSize }),
|
||||
this.prisma.smsSignature.count({ where: filteredWhere }),
|
||||
this.prisma.smsSignature.groupBy({
|
||||
by: ['auditStatus'],
|
||||
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 };
|
||||
for (const item of statusCounts) {
|
||||
const count = item._count._all;
|
||||
summary.total += count;
|
||||
if (item.auditStatus in summary && item.auditStatus !== 'total') {
|
||||
summary[item.auditStatus as keyof Omit<typeof summary, 'total'>] = count;
|
||||
}
|
||||
}
|
||||
return { items, summary, total, page, pageSize };
|
||||
}
|
||||
|
||||
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
|
||||
await this.reportValidation.validateSignatureReportValues(data.applicationId, data.drainageInfo);
|
||||
const drainageInfo = await this.reportValidation.withReportRequirementSnapshot(data.applicationId, 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,
|
||||
},
|
||||
});
|
||||
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
|
||||
if (options.initialAuditStatus) {
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signature.id,
|
||||
action: 'admin_create_approved',
|
||||
statusAfter: options.initialAuditStatus,
|
||||
reason: '运营端新建签名自动审核通过',
|
||||
});
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
await this.reportValidation.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo);
|
||||
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
|
||||
const drainageInfo = data.drainageInfo
|
||||
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||
: undefined;
|
||||
const name = data.name === undefined ? undefined : validateCompleteSmsSignature(data.name);
|
||||
const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId)
|
||||
|| (name !== undefined && name !== normalizeSmsSignature(signature.name))
|
||||
|| (data.purpose !== undefined && data.purpose !== signature.purpose)
|
||||
|| (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null));
|
||||
const auditStatus = 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' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
reportChangedAt: new Date(),
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
await this.reportValidation.syncSignatureReportValues(signatureId, updated.applicationId ?? undefined, drainageInfo);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改签名');
|
||||
}
|
||||
const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
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, 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 updated = await this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: { auditStatus: 'pending', rejectReason: null },
|
||||
});
|
||||
await this.audit.createAuditRecord({
|
||||
tenantId: signature.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'submit',
|
||||
statusBefore: signature.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user