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 { 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 { 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 { 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(); 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 { 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 { 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 { 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 { 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 { 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 { 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(), }; } }