Tema
Módulo Campanhas — Classes e Métodos
Documentação do módulo modules/marketing/ com foco em campanhas, envio de email e rastreamento.
Estrutura
modules/marketing/
├── campaigns/
│ ├── campaigns.module.ts
│ ├── campaigns.controller.ts
│ ├── campaigns.service.ts
│ └── services/
│ ├── campaign-scheduler.service.ts # cron: campanhas recorrentes
│ ├── campaign-trigger.processor.ts # fila: trigger agendado
│ ├── email-send.processor.ts # fila: envio de email
│ └── behavior-filter.service.ts # filtra audiência por comportamento
├── email-tracking/
│ ├── email-tracking.controller.ts # pixel, cliques, unsubscribe
│ ├── email-tracking.service.ts
│ └── html-processor.ts # compilação MJML, tracking
└── segmentation/
└── segmentation-query-builder.ts # SQL dinâmico para segmentosCampaignsService
Arquivo: campaigns/campaigns.service.ts | Decorator: @Injectable()
Dependências injetadas
typescript
constructor(
private prisma: PrismaService,
private encryption: EncryptionService,
private metaGraph: MetaGraphService,
private behaviorFilter: BehaviorFilterService,
private queryBuilder: SegmentationQueryBuilder,
@InjectQueue('whatsapp-send') private whatsappQueue: Queue,
@InjectQueue('campaign-trigger') private campaignTriggerQueue: Queue,
@InjectQueue('email-send') private emailQueue: Queue,
private minio: MinioService,
) {}Métodos públicos
typescript
// Cria campanha; agenda trigger se scheduledAt no futuro
async create(
data: CreateCampaignDto,
organizationId: string,
): Promise<Campaign>
// Atualiza campanha; re-agenda trigger se scheduledAt mudou
async update(
id: string,
data: Partial<CreateCampaignDto>,
organizationId: string,
): Promise<Campaign>
// Remove campanha: cancela jobs BullMQ, limpa referências
async remove(id: string, organizationId: string): Promise<{ deleted: true }>
// Duplica campanha (sufixo " (Cópia)", status=draft)
async duplicate(id: string, organizationId: string): Promise<Campaign>
// Dispara envio WhatsApp
async sendWhatsappCampaign(
campaignId: string,
organizationId: string,
): Promise<{ queued: number; excludedOptOut: number; excludedNonMobile: number }>
// Dispara envio de email
async sendEmailCampaign(
campaignId: string,
organizationId: string,
): Promise<{ enqueued: number; excluded: number }>
// Preview de limite de rate (pre-flight antes de enviar)
async checkRateLimitForCampaign(
campaignId: string,
organizationId: string,
targetDate?: Date,
): Promise<{
tier: string
limit: number
isUnlimited: boolean
sentOnDay: number
committedOnDay: number
remaining: number
campaignSize: number
willExceed: boolean
exceedBy: number
}>
// Estimativa de audiência sem salvar
async estimateAudience(
organizationId: string,
segmentId?: string,
behaviorRules?: BehaviorRulesDto,
channel?: string,
): Promise<{ count: number }>
// Métricas em tempo real (últimas 7 dias)
async getRealtimeStats(organizationId: string): Promise<Array<{
id: string
name: string
channel: string
status: string
sentAt: Date | null
target: number
total: number
sent: number
delivered: number
read: number
failed: number
}>>
// Analytics agregado WhatsApp por período
async getWhatsappAnalytics(organizationId, start?, end?): Promise<WhatsappAnalytics>
// Analytics agregado Email por período
async getEmailAnalytics(organizationId, start?, end?): Promise<EmailAnalytics>
// Envia mensagem de teste WhatsApp
async sendTestMessage(
campaignId: string,
organizationId: string,
testPhone: string,
): Promise<{ jobId: string }>
// Envia email de teste (direto via Nodemailer, não fila)
async sendTestEmail(
campaignId: string,
organizationId: string,
toEmail: string,
): Promise<{ sent: true }>Lock atômico de envio
typescript
// Previne duplo disparo:
const result = await prisma.campaign.updateMany({
where: { id: campaignId, status: { not: 'sending' } },
data: { status: 'sending' },
})
if (result.count === 0) {
throw new ConflictException('Campanha já está sendo enviada')
}Job de trigger agendado
typescript
// Ao criar/editar campanha com scheduledAt:
await campaignTriggerQueue.add(
'trigger-campaign',
{ campaignId, organizationId },
{
delay: scheduledAt.getTime() - Date.now(),
jobId: `campaign-${campaignId}`, // idempotente
}
)
// Ao editar (remove o antigo e cria novo):
await campaignTriggerQueue.remove(`campaign-${campaignId}`)
await campaignTriggerQueue.add('trigger-campaign', data, { delay, jobId })BehaviorFilterService
Arquivo: campaigns/services/behavior-filter.service.ts | Decorator: @Injectable()
Filtra clientes com base em comportamentos e regras de segment.
typescript
class BehaviorFilterService {
constructor(
private prisma: PrismaService,
private queryBuilder: SegmentationQueryBuilder,
@Inject(CUSTOMER_REPOSITORY) private customerRepo: ICustomerRepository,
) {}
async countMatchingCustomers(
organizationId: string,
segmentId?: string,
behaviorRules?: BehaviorRulesDto,
channel?: string,
): Promise<number>
async getMatchingCustomers(
organizationId: string,
segmentId?: string,
behaviorRules?: BehaviorRulesDto,
limitUnused?: number,
countOnly?: boolean,
channel?: string,
): Promise<Customer[] | number>
}Tipos de evento de comportamento
| Evento | Filtro |
|---|---|
purchased | Fez compra no período |
not_purchased | Não comprou nunca ou não no período |
whatsapp_read | Abriu mensagem WhatsApp |
whatsapp_not_read | Recebeu mas não abriu |
whatsapp_replied | Respondeu no WhatsApp |
received_campaign | Recebeu campanha específica |
not_received_campaign | Não recebeu campanha |
rfm_status | Tem status RFM (value = nome do status) |
in_segment | Está em outro segmento (value = segmentId) |
avg_ticket_above | Ticket médio > valor (value = valor em reais) |
registered | Cadastrado no período |
Períodos disponíveis
typescript
type PeriodType =
| 'last_7_days'
| 'last_30_days'
| 'last_60_days'
| 'last_90_days'
| 'custom' // com customDays: numberFiltros por canal
typescript
// WhatsApp:
where: { phone: { not: null }, phoneType: 'MOBILE', whatsappOptOut: false }
// Email:
where: { email: { not: null }, emailOptOut: false }
// Padrão:
where: { phone: { not: null } }CampaignScheduler
Arquivo: campaigns/services/campaign-scheduler.service.ts
Decorator: @Injectable() + OnApplicationBootstrap
Cron: campanhas recorrentes
typescript
@Cron('*/5 * * * *') // a cada 5 minutos
async handleRecurringCampaigns(): Promise<void>Critério de seleção: status IN ['draft', 'sent'], frequency != 'once', scheduledAt <= now, channel = 'WHATSAPP'.
Lock distribuído (Redis):
typescript
const locked = await redis.set(
`campaign:lock:${campaignId}`,
'1', 'EX', 300, 'NX'
)
if (!locked) return // outra instância está processandoCálculo do próximo run:
typescript
calculateNextRun(campaign: Campaign): Date | null {
switch (campaign.frequency) {
case 'daily': return addDays(campaign.scheduledAt, 1)
case 'weekly': return addDays(campaign.scheduledAt, 7)
case 'monthly': return addMonths(campaign.scheduledAt, 1)
// respeita scheduleDayOfMonth (ex: todo dia 30)
default: return null
}
}Recuperação de falhas (bootstrap)
typescript
async onApplicationBootstrap(): Promise<void> {
// Varre JobRun com status='running' e started > 10min atrás:
const stale = await prisma.jobRun.findMany({
where: { status: 'running', startedAt: { lt: tenMinutesAgo } }
})
for (const job of stale) {
// Re-dispara a campanha
await this.campaignsService.sendWhatsappCampaign(job.campaignId, job.organizationId)
await prisma.jobRun.update({ where: { id: job.id }, data: { status: 'completed' } })
}
}EmailTrackingService
Arquivo: email-tracking/email-tracking.service.ts
typescript
class EmailTrackingService {
constructor(private prisma: PrismaService) {}
// Registra abertura (idempotente: só na primeira vez)
async recordOpen(trackingId: string): Promise<boolean>
// → EmailMessage.openedAt = now
// → CampaignMetric.opens++
// Registra clique (idempotente: só na primeira vez)
async recordClick(trackingId: string): Promise<boolean>
// → EmailMessage.clickedAt = now
// → CampaignMetric.clicks++
// Processa unsubscribe
async recordUnsubscribe(
trackingId: string
): Promise<{ success: boolean; alreadyUnsubscribed: boolean }>
// → EmailMessage.unsubscribedAt = now
// → Customer.emailOptOut = true
// → CampaignMetric.unsubscribes++
}Endpoints públicos
GET /email-tracking/t/:trackingId.png → pixel 1x1 GIF (registra abertura)
GET /email-tracking/c/:trackingId?url= → registra clique, redireciona (302)
GET /email-tracking/u/:trackingId → página de unsubscribe
POST /email-tracking/u/:trackingId → unsubscribe one-click (RFC 8058)html-processor.ts — Processamento de HTML de email
Funções puras (sem DI) para preparação do HTML de email.
typescript
// Compila MJML para HTML
compileMjml(input: string): string
// Detecta sintaxe MJML, sanitiza, compila, retorna HTML
// Injeta pixel de rastreamento antes de </body>
injectTrackingPixel(html, trackingId, baseUrl): string
// Envolve links com rastreamento de cliques
wrapLinks(html, trackingId, baseUrl): string
// Ignora: URLs internas de tracking, mailto:, tel:
// Substitui: href="https://..." → href="${baseUrl}/email-tracking/c/${trackingId}?url=..."
// Adiciona rodapé de descadastro
addUnsubscribeFooter(html, trackingId, baseUrl): string
// Retorna headers RFC 8058
getListUnsubscribeHeaders(trackingId, baseUrl): {
'List-Unsubscribe': string // "<${baseUrl}/email-tracking/u/${trackingId}>"
'List-Unsubscribe-Post': string // "List-Unsubscribe=One-Click"
}
// Upload de imagens base64 para MinIO
uploadBase64Images(html, uploader): Promise<string>
// Encontra src="data:image/..." → faz upload → substitui por URL pública
// Orquestração completa (chama todas as funções acima em ordem)
processEmailHtml(
mjmlOrHtml: string,
trackingId: string,
baseUrl: string,
imageUploader?: (buffer: Buffer, mimeType: string, key: string) => Promise<string>,
): Promise<string>DTOs de Campanha
CreateCampaignDto
typescript
class CreateCampaignDto {
name: string
channel: 'WHATSAPP' | 'EMAIL'
organizationId?: string
storeId?: string
scheduledAt?: string // ISO date (futuro = agenda)
content?: {
subject?: string // apenas para email
body: string
mediaUrl?: string
designJson?: Record<string, any> // GrapesJS JSON para editor
emailTemplateId?: string
}
segmentId?: string
tags?: string[]
whatsappTemplateId?: string
whatsappNumberId?: string
templateVars?: Record<string, string> // mapeamento variável → campo CRM
behaviorRules?: BehaviorRulesDto
frequency?: 'once' | 'daily' | 'weekly' | 'monthly'
scheduleEndAt?: string
scheduleDayOfWeek?: number // 0-6 (para weekly)
scheduleDayOfMonth?: number // 1-31 (para monthly)
}BehaviorRulesDto
typescript
class BehaviorRulesDto {
include?: BehaviorRuleDto
exclude?: BehaviorRuleDto
}
class BehaviorRuleDto {
event: BehaviorEventType
period?: {
type: 'last_7_days' | 'last_30_days' | 'last_60_days' | 'last_90_days' | 'custom'
customDays?: number
}
value?: string // campaignId | segmentId | rfmStatus | valor monetário
}Status de campanha
draft → campanha criada mas não enviada
scheduled → agendada para o futuro (scheduledAt presente, frequency=once)
sending → disparo em andamento (lock atômico ativo)
sent → todos os jobs processados (pelo menos 1 sucesso)
failed → todos os jobs falharamStatus de mensagem
EmailMessage:
QUEUED → criada, aguardando job na fila
SENT → enviada pelo SMTP
BOUNCED → bounce HARD (550-559) ou SOFT (450-459)
FAILED → outro erro
WhatsappMessage:
SENT → aceita pela Meta API (WAM ID recebido)
DELIVERED → entregue ao dispositivo
READ → lida pelo destinatário
FAILED → erro na Meta APIFluxo: envio de campanha email
CampaignsService.sendEmailCampaign(campaignId)
│
▼
1. Lock atômico: campaign.status → 'sending'
2. Verifica configuração SMTP
3. Busca conteúdo: CampaignContent ou EmailTemplate (MJML)
4. BehaviorFilterService.getMatchingCustomers()
└── Filtra: email presente + !emailOptOut
│
▼
5. buildAndEnqueueEmailJobs() por cliente:
├── compileMjml() → HTML
├── uploadBase64Images() → URLs MinIO
├── processEmailHtml():
│ ├── wrapLinks() → tracking de cliques
│ ├── addUnsubscribeFooter()
│ └── injectTrackingPixel()
├── Cria EmailMessage(QUEUED)
└── Enfileira job com delay=i*200ms
│
▼
6. emailQueue.addBulk(jobs)
[processados pelo EmailSendProcessor]
│
▼
Por cada job:
├── Busca SMTP config + decripta senha
├── nodemailer.sendMail()
├── EmailMessage → SENT
├── CampaignMetric.sent++ delivered++
└── checkCampaignCompletion()
│
▼
7. Quando todos os QUEUED zerados:
└── campaign.status → 'sent'