Persist manual, campaign, and automation SMS; expose history APIs for people and leads. Co-authored-by: Cursor <cursoragent@cursor.com>
663 lines
20 KiB
TypeScript
663 lines
20 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { Cron } from '@nestjs/schedule';
|
|
import type {
|
|
AddSmsCampaignRecipientsRequest,
|
|
CreateSmsCampaignRequest,
|
|
SmsCampaignClickerDto,
|
|
SmsCampaignDto,
|
|
SmsCampaignListResponse,
|
|
SmsCampaignReportDto,
|
|
SmsCampaignStatus,
|
|
} from '@mento/shared';
|
|
import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { SmsService } from '../common/sms.service';
|
|
import { renderSmsText } from '../common/sms-text';
|
|
import {
|
|
type Actor,
|
|
elevated,
|
|
forScope,
|
|
ownerWhere,
|
|
} from '../common/record-scope';
|
|
import { TrackingService } from '../tracking/tracking.service';
|
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
|
import type { Prisma } from '@prisma/client';
|
|
|
|
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
|
|
const actor = forScope(baseActor, 'leads');
|
|
if (elevated(actor.role)) return {};
|
|
if (actor.dataVisibility === 'org') return {};
|
|
if (
|
|
actor.dataVisibility === 'team' &&
|
|
actor.visibleUserIds &&
|
|
actor.visibleUserIds.length
|
|
) {
|
|
return { assigneeId: { in: actor.visibleUserIds } };
|
|
}
|
|
return { assigneeId: actor.userId };
|
|
}
|
|
|
|
@Injectable()
|
|
export class SmsCampaignsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly sms: SmsService,
|
|
private readonly tracking: TrackingService,
|
|
private readonly outboundSms: OutboundSmsLogService,
|
|
) {}
|
|
|
|
async list(
|
|
organizationId: string,
|
|
page = 1,
|
|
limit = 20,
|
|
): Promise<SmsCampaignListResponse> {
|
|
const where = { organizationId };
|
|
const [total, rows] = await Promise.all([
|
|
this.prisma.smsCampaign.count({ where }),
|
|
this.prisma.smsCampaign.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
}),
|
|
]);
|
|
return {
|
|
total,
|
|
items: rows.map((row) => this.toDto(row)),
|
|
};
|
|
}
|
|
|
|
async get(organizationId: string, id: string): Promise<SmsCampaignDto> {
|
|
const row = await this.prisma.smsCampaign.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!row) throw new NotFoundException('کمپین پیدا نشد');
|
|
return this.toDto(row);
|
|
}
|
|
|
|
async report(
|
|
organizationId: string,
|
|
id: string,
|
|
): Promise<SmsCampaignReportDto> {
|
|
const campaign = await this.prisma.smsCampaign.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!campaign) throw new NotFoundException('کمپین پیدا نشد');
|
|
|
|
const links = await this.prisma.trackingLink.findMany({
|
|
where: {
|
|
organizationId,
|
|
sourceType: 'sms_campaign',
|
|
sourceId: id,
|
|
clickCount: { gt: 0 },
|
|
},
|
|
select: {
|
|
personId: true,
|
|
leadId: true,
|
|
clickCount: true,
|
|
firstClickedAt: true,
|
|
lastClickedAt: true,
|
|
},
|
|
});
|
|
|
|
type Agg = {
|
|
personId: string | null;
|
|
leadId: string | null;
|
|
clickCount: number;
|
|
firstClickedAt: Date | null;
|
|
lastClickedAt: Date | null;
|
|
};
|
|
const byContact = new Map<string, Agg>();
|
|
let totalClicks = 0;
|
|
for (const link of links) {
|
|
totalClicks += link.clickCount;
|
|
const key = link.personId
|
|
? `person:${link.personId}`
|
|
: link.leadId
|
|
? `lead:${link.leadId}`
|
|
: null;
|
|
if (!key) continue;
|
|
const prev = byContact.get(key);
|
|
if (!prev) {
|
|
byContact.set(key, {
|
|
personId: link.personId,
|
|
leadId: link.leadId,
|
|
clickCount: link.clickCount,
|
|
firstClickedAt: link.firstClickedAt,
|
|
lastClickedAt: link.lastClickedAt,
|
|
});
|
|
continue;
|
|
}
|
|
prev.clickCount += link.clickCount;
|
|
if (
|
|
link.firstClickedAt &&
|
|
(!prev.firstClickedAt || link.firstClickedAt < prev.firstClickedAt)
|
|
) {
|
|
prev.firstClickedAt = link.firstClickedAt;
|
|
}
|
|
if (
|
|
link.lastClickedAt &&
|
|
(!prev.lastClickedAt || link.lastClickedAt > prev.lastClickedAt)
|
|
) {
|
|
prev.lastClickedAt = link.lastClickedAt;
|
|
}
|
|
}
|
|
|
|
const personIds = [...byContact.values()]
|
|
.map((c) => c.personId)
|
|
.filter((x): x is string => Boolean(x));
|
|
const leadIds = [...byContact.values()]
|
|
.map((c) => c.leadId)
|
|
.filter((x): x is string => Boolean(x));
|
|
|
|
const [people, leads, recipients] = await Promise.all([
|
|
personIds.length
|
|
? this.prisma.person.findMany({
|
|
where: { id: { in: personIds }, organizationId },
|
|
select: { id: true, firstName: true, lastName: true, mobile: true },
|
|
})
|
|
: Promise.resolve([]),
|
|
leadIds.length
|
|
? this.prisma.lead.findMany({
|
|
where: { id: { in: leadIds }, organizationId },
|
|
select: { id: true, title: true, contactName: true, mobile: true },
|
|
})
|
|
: Promise.resolve([]),
|
|
this.prisma.smsCampaignRecipient.findMany({
|
|
where: { campaignId: id },
|
|
select: { personId: true, leadId: true, mobile: true },
|
|
}),
|
|
]);
|
|
|
|
const personMap = new Map(people.map((p) => [p.id, p]));
|
|
const leadMap = new Map(leads.map((l) => [l.id, l]));
|
|
const mobileByPerson = new Map(
|
|
recipients
|
|
.filter((r) => r.personId)
|
|
.map((r) => [r.personId!, r.mobile ?? ''] as const),
|
|
);
|
|
const mobileByLead = new Map(
|
|
recipients
|
|
.filter((r) => r.leadId)
|
|
.map((r) => [r.leadId!, r.mobile ?? ''] as const),
|
|
);
|
|
|
|
const clickedUsers: SmsCampaignClickerDto[] = [...byContact.values()]
|
|
.flatMap((agg): SmsCampaignClickerDto[] => {
|
|
if (agg.personId) {
|
|
const p = personMap.get(agg.personId);
|
|
const name = p
|
|
? `${p.firstName} ${p.lastName}`.trim()
|
|
: 'مخاطب';
|
|
return [
|
|
{
|
|
contactType: 'customer',
|
|
personId: agg.personId,
|
|
leadId: null,
|
|
name,
|
|
mobile: p?.mobile || mobileByPerson.get(agg.personId) || null,
|
|
firstClickedAt: agg.firstClickedAt?.toISOString() ?? null,
|
|
lastClickedAt: agg.lastClickedAt?.toISOString() ?? null,
|
|
clickCount: agg.clickCount,
|
|
},
|
|
];
|
|
}
|
|
if (agg.leadId) {
|
|
const l = leadMap.get(agg.leadId);
|
|
return [
|
|
{
|
|
contactType: 'lead',
|
|
personId: null,
|
|
leadId: agg.leadId,
|
|
name: l?.contactName || l?.title || 'سرنخ',
|
|
mobile: l?.mobile || mobileByLead.get(agg.leadId) || null,
|
|
firstClickedAt: agg.firstClickedAt?.toISOString() ?? null,
|
|
lastClickedAt: agg.lastClickedAt?.toISOString() ?? null,
|
|
clickCount: agg.clickCount,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
})
|
|
.sort((a, b) => b.clickCount - a.clickCount);
|
|
|
|
const uniqueClickers = clickedUsers.length;
|
|
const sent = campaign.sentCount;
|
|
const clickRate =
|
|
sent > 0 ? Math.round((uniqueClickers / sent) * 10000) / 100 : 0;
|
|
|
|
return {
|
|
campaign: this.toDto(campaign),
|
|
totalCount: campaign.totalCount,
|
|
sentCount: campaign.sentCount,
|
|
failedCount: campaign.failedCount,
|
|
skippedCount: campaign.skippedCount,
|
|
uniqueClickers,
|
|
totalClicks,
|
|
clickRate,
|
|
multiClickers: clickedUsers.filter((u) => u.clickCount > 1).length,
|
|
clickedUsers,
|
|
};
|
|
}
|
|
|
|
async create(
|
|
actor: Actor,
|
|
dto: CreateSmsCampaignRequest,
|
|
): Promise<SmsCampaignDto> {
|
|
const name = dto.name?.trim();
|
|
const body = dto.body?.trim();
|
|
if (!name) throw new BadRequestException('نام کمپین الزامی است');
|
|
if (!body || body.length < 2) {
|
|
throw new BadRequestException('متن پیامک کوتاه است');
|
|
}
|
|
const personIds = [...new Set((dto.personIds ?? []).filter(Boolean))];
|
|
const leadIds = [...new Set((dto.leadIds ?? []).filter(Boolean))];
|
|
if (personIds.length && leadIds.length) {
|
|
throw new BadRequestException('کمپین نمیتواند همزمان شخص و لید داشته باشد.');
|
|
}
|
|
if (!personIds.length && !leadIds.length) {
|
|
throw new BadRequestException('حداقل یک مخاطب انتخاب کنید');
|
|
}
|
|
if (personIds.length > SMS_CAMPAIGN_CHUNK_SIZE || leadIds.length > SMS_CAMPAIGN_CHUNK_SIZE) {
|
|
throw new BadRequestException(
|
|
`حداکثر ${SMS_CAMPAIGN_CHUNK_SIZE} مخاطب در هر درخواست مجاز است.`,
|
|
);
|
|
}
|
|
|
|
const recipients =
|
|
personIds.length > 0
|
|
? await this.resolvePersonRecipients(actor, personIds)
|
|
: await this.resolveLeadRecipients(actor, leadIds);
|
|
if (!recipients.length) throw new BadRequestException('مخاطب معتبری پیدا نشد');
|
|
|
|
const queueNow = Boolean(dto.queueNow);
|
|
const scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : null;
|
|
if (scheduledAt && Number.isNaN(scheduledAt.getTime())) {
|
|
throw new BadRequestException('زمانبندی نامعتبر است');
|
|
}
|
|
|
|
const status: SmsCampaignStatus =
|
|
queueNow || scheduledAt ? 'queued' : 'draft';
|
|
const skipped = recipients.filter((r) => !r.mobile).length;
|
|
|
|
const campaign = await this.prisma.smsCampaign.create({
|
|
data: {
|
|
organizationId: actor.organizationId,
|
|
name,
|
|
body,
|
|
status,
|
|
scheduledAt: queueNow ? new Date() : scheduledAt,
|
|
totalCount: recipients.length,
|
|
skippedCount: skipped,
|
|
createdById: actor.userId,
|
|
recipients: {
|
|
create: recipients.map((r) => ({
|
|
personId: r.personId,
|
|
leadId: r.leadId,
|
|
mobile: r.mobile,
|
|
status: r.mobile ? 'pending' : 'skipped',
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
return this.get(actor.organizationId, campaign.id);
|
|
}
|
|
|
|
async addRecipients(
|
|
actor: Actor,
|
|
campaignId: string,
|
|
dto: AddSmsCampaignRecipientsRequest,
|
|
): Promise<SmsCampaignDto> {
|
|
const campaign = await this.prisma.smsCampaign.findFirst({
|
|
where: { id: campaignId, organizationId: actor.organizationId },
|
|
});
|
|
if (!campaign) throw new NotFoundException('کمپین پیدا نشد');
|
|
if (campaign.status !== 'draft' && campaign.status !== 'cancelled') {
|
|
throw new BadRequestException('فقط به کمپین پیشنویس میتوان مخاطب افزود.');
|
|
}
|
|
|
|
const personIds = [...new Set((dto.personIds ?? []).filter(Boolean))];
|
|
const leadIds = [...new Set((dto.leadIds ?? []).filter(Boolean))];
|
|
if (personIds.length && leadIds.length) {
|
|
throw new BadRequestException('در هر درخواست فقط شخص یا فقط لید بفرستید.');
|
|
}
|
|
if (!personIds.length && !leadIds.length) {
|
|
throw new BadRequestException('حداقل یک مخاطب انتخاب کنید');
|
|
}
|
|
if (personIds.length > SMS_CAMPAIGN_CHUNK_SIZE || leadIds.length > SMS_CAMPAIGN_CHUNK_SIZE) {
|
|
throw new BadRequestException(
|
|
`حداکثر ${SMS_CAMPAIGN_CHUNK_SIZE} مخاطب در هر درخواست مجاز است.`,
|
|
);
|
|
}
|
|
|
|
const existingSample = await this.prisma.smsCampaignRecipient.findFirst({
|
|
where: { campaignId },
|
|
select: { personId: true, leadId: true },
|
|
});
|
|
if (existingSample?.personId && leadIds.length) {
|
|
throw new BadRequestException('این کمپین فقط برای اشخاص است.');
|
|
}
|
|
if (existingSample?.leadId && personIds.length) {
|
|
throw new BadRequestException('این کمپین فقط برای لیدها است.');
|
|
}
|
|
|
|
const resolved =
|
|
personIds.length > 0
|
|
? await this.resolvePersonRecipients(actor, personIds)
|
|
: await this.resolveLeadRecipients(actor, leadIds);
|
|
|
|
const existingIds = new Set(
|
|
(
|
|
await this.prisma.smsCampaignRecipient.findMany({
|
|
where: { campaignId },
|
|
select: { personId: true, leadId: true },
|
|
})
|
|
).map((r) => r.personId ?? r.leadId ?? ''),
|
|
);
|
|
const fresh = resolved.filter(
|
|
(r) => !existingIds.has(r.personId ?? r.leadId ?? ''),
|
|
);
|
|
if (!fresh.length) {
|
|
return this.get(actor.organizationId, campaignId);
|
|
}
|
|
|
|
const skipped = fresh.filter((r) => !r.mobile).length;
|
|
await this.prisma.smsCampaignRecipient.createMany({
|
|
data: fresh.map((r) => ({
|
|
campaignId,
|
|
personId: r.personId,
|
|
leadId: r.leadId,
|
|
mobile: r.mobile,
|
|
status: r.mobile ? 'pending' : 'skipped',
|
|
})),
|
|
});
|
|
await this.prisma.smsCampaign.update({
|
|
where: { id: campaignId },
|
|
data: {
|
|
totalCount: { increment: fresh.length },
|
|
skippedCount: { increment: skipped },
|
|
status: 'draft',
|
|
},
|
|
});
|
|
return this.get(actor.organizationId, campaignId);
|
|
}
|
|
|
|
private async resolvePersonRecipients(
|
|
actor: Actor,
|
|
personIds: string[],
|
|
): Promise<
|
|
Array<{ personId: string; leadId: null; mobile: string | null }>
|
|
> {
|
|
const people = await this.prisma.person.findMany({
|
|
where: {
|
|
id: { in: personIds },
|
|
organizationId: actor.organizationId,
|
|
...ownerWhere(actor),
|
|
},
|
|
select: { id: true, mobile: true },
|
|
});
|
|
return people.map((p) => ({
|
|
personId: p.id,
|
|
leadId: null,
|
|
mobile: p.mobile?.trim() || null,
|
|
}));
|
|
}
|
|
|
|
private async resolveLeadRecipients(
|
|
actor: Actor,
|
|
leadIds: string[],
|
|
): Promise<
|
|
Array<{ personId: null; leadId: string; mobile: string | null }>
|
|
> {
|
|
const leads = await this.prisma.lead.findMany({
|
|
where: {
|
|
id: { in: leadIds },
|
|
organizationId: actor.organizationId,
|
|
archivedAt: null,
|
|
...leadAccessWhere(actor),
|
|
},
|
|
select: { id: true, mobile: true },
|
|
});
|
|
return leads.map((l) => ({
|
|
personId: null,
|
|
leadId: l.id,
|
|
mobile: l.mobile?.trim() || null,
|
|
}));
|
|
}
|
|
|
|
async queue(organizationId: string, id: string): Promise<SmsCampaignDto> {
|
|
const row = await this.prisma.smsCampaign.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!row) throw new NotFoundException('کمپین پیدا نشد');
|
|
if (row.status !== 'draft' && row.status !== 'cancelled') {
|
|
throw new BadRequestException('این کمپین قابل صفبندی نیست');
|
|
}
|
|
await this.prisma.smsCampaign.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'queued',
|
|
scheduledAt: row.scheduledAt ?? new Date(),
|
|
},
|
|
});
|
|
return this.get(organizationId, id);
|
|
}
|
|
|
|
async cancel(organizationId: string, id: string): Promise<SmsCampaignDto> {
|
|
const row = await this.prisma.smsCampaign.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
if (!row) throw new NotFoundException('کمپین پیدا نشد');
|
|
if (row.status === 'done') {
|
|
throw new BadRequestException('کمپین تمامشده قابل لغو نیست');
|
|
}
|
|
await this.prisma.smsCampaign.update({
|
|
where: { id },
|
|
data: { status: 'cancelled' },
|
|
});
|
|
return this.get(organizationId, id);
|
|
}
|
|
|
|
@Cron('*/1 * * * *')
|
|
async processDue(): Promise<number> {
|
|
if (!this.sms.isConfigured()) return 0;
|
|
const now = new Date();
|
|
const campaigns = await this.prisma.smsCampaign.findMany({
|
|
where: {
|
|
status: 'queued',
|
|
OR: [{ scheduledAt: null }, { scheduledAt: { lte: now } }],
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
take: 3,
|
|
});
|
|
|
|
let processed = 0;
|
|
for (const campaign of campaigns) {
|
|
await this.processCampaign(campaign.id);
|
|
processed += 1;
|
|
}
|
|
return processed;
|
|
}
|
|
|
|
private async processCampaign(campaignId: string): Promise<void> {
|
|
await this.prisma.smsCampaign.update({
|
|
where: { id: campaignId },
|
|
data: { status: 'sending' },
|
|
});
|
|
|
|
const campaign = await this.prisma.smsCampaign.findUnique({
|
|
where: { id: campaignId },
|
|
});
|
|
if (!campaign) return;
|
|
|
|
const batch = await this.prisma.smsCampaignRecipient.findMany({
|
|
where: { campaignId, status: 'pending' },
|
|
take: SMS_CAMPAIGN_CHUNK_SIZE,
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
|
|
const personIds = batch
|
|
.map((r) => r.personId)
|
|
.filter((id): id is string => Boolean(id));
|
|
const leadIds = batch
|
|
.map((r) => r.leadId)
|
|
.filter((id): id is string => Boolean(id));
|
|
const [people, leads] = await Promise.all([
|
|
personIds.length
|
|
? this.prisma.person.findMany({
|
|
where: { id: { in: personIds } },
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
mobile: true,
|
|
company: { select: { name: true } },
|
|
},
|
|
})
|
|
: Promise.resolve([]),
|
|
leadIds.length
|
|
? this.prisma.lead.findMany({
|
|
where: { id: { in: leadIds } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
contactName: true,
|
|
mobile: true,
|
|
},
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
const personMap = new Map(people.map((p) => [p.id, p]));
|
|
const leadMap = new Map(leads.map((l) => [l.id, l]));
|
|
|
|
let sent = 0;
|
|
let failed = 0;
|
|
let skipped = 0;
|
|
|
|
for (const recipient of batch) {
|
|
if (!recipient.mobile) {
|
|
await this.prisma.smsCampaignRecipient.update({
|
|
where: { id: recipient.id },
|
|
data: { status: 'skipped', error: 'موبایل ندارد' },
|
|
});
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
let name = '';
|
|
let title = '';
|
|
let company = '';
|
|
if (recipient.personId) {
|
|
const p = personMap.get(recipient.personId);
|
|
name = [p?.firstName, p?.lastName].filter(Boolean).join(' ').trim();
|
|
title = name;
|
|
company = p?.company?.name ?? '';
|
|
} else if (recipient.leadId) {
|
|
const l = leadMap.get(recipient.leadId);
|
|
name = (l?.contactName || l?.title || '').trim();
|
|
title = (l?.title || name).trim();
|
|
}
|
|
|
|
const withTokens = renderSmsText(campaign.body, {
|
|
name,
|
|
phone: recipient.mobile,
|
|
title,
|
|
company,
|
|
});
|
|
|
|
const personalized = await this.tracking.personalizeSmsBody({
|
|
organizationId: campaign.organizationId,
|
|
channel: 'sms',
|
|
sourceType: 'sms_campaign',
|
|
sourceId: campaign.id,
|
|
recipientType: 'sms_campaign_recipient',
|
|
recipientId: recipient.id,
|
|
smsRecipientId: recipient.id,
|
|
personId: recipient.personId,
|
|
leadId: recipient.leadId,
|
|
body: withTokens,
|
|
});
|
|
|
|
const result = await this.sms.sendSmsDetailed(
|
|
recipient.mobile,
|
|
personalized.body,
|
|
);
|
|
await this.outboundSms.record({
|
|
organizationId: campaign.organizationId,
|
|
personId: recipient.personId,
|
|
leadId: recipient.leadId,
|
|
mobile: recipient.mobile,
|
|
body: personalized.body,
|
|
source: 'campaign',
|
|
sourceId: campaign.id,
|
|
status: result.ok ? 'sent' : 'failed',
|
|
error: result.ok ? null : result.reason,
|
|
});
|
|
if (result.ok) {
|
|
await this.prisma.smsCampaignRecipient.update({
|
|
where: { id: recipient.id },
|
|
data: { status: 'sent', sentAt: new Date(), error: null },
|
|
});
|
|
sent += 1;
|
|
} else {
|
|
await this.prisma.smsCampaignRecipient.update({
|
|
where: { id: recipient.id },
|
|
data: {
|
|
status: 'failed',
|
|
error: result.reason?.slice(0, 200) ?? 'ارسال ناموفق',
|
|
},
|
|
});
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
const remaining = await this.prisma.smsCampaignRecipient.count({
|
|
where: { campaignId, status: 'pending' },
|
|
});
|
|
|
|
await this.prisma.smsCampaign.update({
|
|
where: { id: campaignId },
|
|
data: {
|
|
sentCount: { increment: sent },
|
|
failedCount: { increment: failed },
|
|
skippedCount: { increment: skipped },
|
|
status: remaining > 0 ? 'queued' : 'done',
|
|
scheduledAt: remaining > 0 ? new Date() : campaign.scheduledAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
private toDto(row: {
|
|
id: string;
|
|
name: string;
|
|
body: string;
|
|
status: string;
|
|
scheduledAt: Date | null;
|
|
totalCount: number;
|
|
sentCount: number;
|
|
failedCount: number;
|
|
skippedCount: number;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}): SmsCampaignDto {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
body: row.body,
|
|
status: row.status as SmsCampaignStatus,
|
|
scheduledAt: row.scheduledAt?.toISOString() ?? null,
|
|
totalCount: row.totalCount,
|
|
sentCount: row.sentCount,
|
|
failedCount: row.failedCount,
|
|
skippedCount: row.skippedCount,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
}
|