265 lines
7.3 KiB
JavaScript
265 lines
7.3 KiB
JavaScript
'use strict'
|
|
|
|
const { Queue } = require('bullmq')
|
|
const Redis = require('ioredis')
|
|
const WhatsappLog = require('../models/WhatsappLog')
|
|
const { queryOne } = require('../database/postgres')
|
|
|
|
const connection = new Redis({
|
|
host: process.env.REDIS_HOST || '127.0.0.1',
|
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
|
maxRetriesPerRequest: null
|
|
})
|
|
|
|
const whatsappQueue = new Queue('whatsapp-notifications', { connection })
|
|
|
|
// Tipos críticos — enviados mesmo sem opt-in explícito (transacionais)
|
|
const CRITICAL_TYPES = new Set([
|
|
'purchase_confirmed',
|
|
'purchase_pending',
|
|
'winner',
|
|
'reservation_expiring',
|
|
'security_login',
|
|
'club_welcome',
|
|
'club_payment_confirmed',
|
|
'club_payment_overdue',
|
|
'club_suspended',
|
|
'club_canceled',
|
|
'club_payment_reminder',
|
|
])
|
|
|
|
function parseTemplate(type, vars) {
|
|
const templates = {
|
|
'purchase_confirmed':
|
|
`🎟️ *Compra confirmada!*
|
|
|
|
Olá! Recebemos seu pagamento com sucesso.
|
|
Números Garantidos: *{numeros}*
|
|
Valor: *R$ {valor}*
|
|
|
|
Acesse sua conta para ver os detalhes:
|
|
{link}
|
|
|
|
Boa sorte! 🍀`,
|
|
|
|
'purchase_pending':
|
|
`⏳ *Finalize seu pagamento*
|
|
|
|
Seus números no Alemão Conveniências estão reservados por tempo limitado!
|
|
Para garantir sua participação, efetue o pagamento usando a chave PIX abaixo:
|
|
|
|
Chave copia-e-cola:
|
|
{pixCopiaECola}
|
|
|
|
Ou acesse o QRCode no link: {link}`,
|
|
|
|
'winner':
|
|
`🎉 *PARABÉNS, {nome}!* 🎉
|
|
|
|
Seu ticket *{numero}* foi SORTEADO na rifa *{raffleName}*!
|
|
Acesse nossa Área do Cliente para resgatar seu prêmio agora mesmo:
|
|
|
|
{link}`,
|
|
|
|
'last_hours':
|
|
`⏰ *Últimas horas!*
|
|
|
|
O sorteio da rifa *{raffleName}* encerra em breve.
|
|
Ainda dá tempo de participar!
|
|
|
|
👉 {link}`,
|
|
|
|
'price_drop':
|
|
`⚡ *QUEDA DE PREÇO FLASH* ⚡
|
|
|
|
Os números da rifa *{raffleName}* baixaram para apenas *R$ {novo_valor}*!
|
|
Corra antes que acabe:
|
|
|
|
👉 {link}`,
|
|
|
|
'price_drop_local':
|
|
`🔔 *Seu número ficou mais barato!*
|
|
|
|
Você tem uma reserva na rifa *{raffleName}*.
|
|
O preço caiu para *R$ {novo_valor}* — finalize agora antes que expire:
|
|
|
|
👉 {link}`,
|
|
|
|
'reservation_expiring':
|
|
`⚠️ *Sua reserva vai expirar!*
|
|
|
|
Você tem apenas *1 minuto* para finalizar o pagamento e garantir seu(s) número(s) na rifa *{raffleName}*.
|
|
|
|
Pague agora: {link}`,
|
|
|
|
'welcome':
|
|
`👋 *Bem-vindo ao Alemão Conveniências!*
|
|
|
|
Olá, *{nome}*! Seu cadastro foi realizado com sucesso.
|
|
|
|
Aqui você encontra promoções exclusivas e participa dos nossos sorteios. Acesse sua área:
|
|
{link}`,
|
|
|
|
'claim_received':
|
|
`✅ *Resgate solicitado!*
|
|
|
|
Oi, *{nome}*! Recebemos sua solicitação de resgate do prêmio da rifa *{raffleName}* (ticket #{numero}).
|
|
|
|
Nossa equipe analisará e entrará em contato em breve. 🎁`,
|
|
|
|
'claim_approved':
|
|
`🏆 *Prêmio aprovado!*
|
|
|
|
Boa notícia, *{nome}*! Seu prêmio da rifa *{raffleName}* foi *aprovado* pela nossa equipe.
|
|
|
|
Em breve entraremos em contato para combinar a entrega. 📦`,
|
|
|
|
'claim_delivered':
|
|
`🎁 *Prêmio entregue!*
|
|
|
|
Parabéns, *{nome}*! Seu prêmio da rifa *{raffleName}* foi marcado como *entregue*.
|
|
|
|
Obrigado por participar do Alemão Conveniências! Até o próximo sorteio! 🍀`,
|
|
|
|
'security_login':
|
|
`🔐 *Novo acesso à sua conta*
|
|
|
|
Olá, *{nome}*! Detectamos um acesso à sua conta no Alemão Conveniências em *{data}* às *{hora}*.
|
|
|
|
Se não foi você, acesse a loja ou entre em contato conosco imediatamente.`,
|
|
|
|
'password_reset':
|
|
`🔑 *Redefinição de senha*
|
|
|
|
Olá, *{nome}*! Recebemos uma solicitação para redefinir sua senha.
|
|
|
|
Clique no link abaixo para criar uma nova senha (válido por 1 hora):
|
|
{link}
|
|
|
|
Se não foi você, ignore esta mensagem.`,
|
|
|
|
'club_welcome':
|
|
`🎉 *Bem-vindo ao {clubName}!*
|
|
|
|
Olá, *{nome}*! Sua assinatura do plano *{planName}* foi recebida e está sendo processada.
|
|
|
|
Assim que o pagamento for confirmado, sua carteirinha digital estará disponível:
|
|
{link}`,
|
|
|
|
'club_payment_confirmed':
|
|
`✅ *Pagamento confirmado — {clubName}*
|
|
|
|
Oi, *{nome}*! Seu pagamento mensal do *{clubName}* foi confirmado.
|
|
|
|
Acesse sua carteirinha digital e aproveite todos os benefícios! 😊`,
|
|
|
|
'club_payment_overdue':
|
|
`⚠️ *Pagamento em atraso — {clubName}*
|
|
|
|
Olá, *{nome}*! Identificamos que seu pagamento mensal do *{clubName}* está em atraso.
|
|
|
|
Regularize agora para não perder seus benefícios:
|
|
{link}`,
|
|
|
|
'club_payment_reminder':
|
|
`🔔 *Lembrete de vencimento — {clubName}*
|
|
|
|
Oi, *{nome}*! Seu pagamento mensal do *{clubName}* vence em *{dueDate}*.
|
|
|
|
Pague com antecedência e mantenha seus benefícios ativos:
|
|
{link}`,
|
|
|
|
'club_suspended':
|
|
`⛔ *Plano suspenso — {clubName}*
|
|
|
|
Olá, *{nome}*. Seu plano no *{clubName}* foi *suspenso* por falta de pagamento.
|
|
|
|
Para reativar, regularize seu pagamento:
|
|
{link}`,
|
|
|
|
'club_canceled':
|
|
`📋 *Cancelamento confirmado — {clubName}*
|
|
|
|
Olá, *{nome}*. Sua assinatura do *{clubName}* foi cancelada conforme solicitado.
|
|
|
|
Se mudar de ideia, você pode assinar novamente a qualquer momento. Obrigado por ter sido nosso cliente! 🙏`,
|
|
|
|
'club_medico_partner_new':
|
|
`🏥 *Novo parceiro na rede — {clubName}*
|
|
|
|
Oi, *{nome}*! Temos um novo parceiro na rede do *{clubName}*:
|
|
|
|
*{partnerName}*
|
|
🩺 {specialty}
|
|
📍 {address}
|
|
|
|
Agende sua consulta com desconto exclusivo para membros!`,
|
|
}
|
|
|
|
let text = templates[type] || 'Notificação do Sistema Nuvem'
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
text = text.replace(new RegExp(`{${key}}`, 'g'), value ?? '')
|
|
}
|
|
return text
|
|
}
|
|
|
|
const WhatsappCore = {
|
|
queue: whatsappQueue,
|
|
|
|
async sendNotification({ userId, phone, type, variables, userCpf, tenantId }) {
|
|
if (!phone) return
|
|
|
|
// Opt-in: mensagens não-críticas só vão para usuários que aceitaram
|
|
if (!CRITICAL_TYPES.has(type)) {
|
|
const optin = await this._checkOptin(userCpf, phone)
|
|
if (!optin) return null
|
|
}
|
|
|
|
let cleanPhone = phone.replace(/\D/g, '')
|
|
if (cleanPhone.length === 10 || cleanPhone.length === 11) cleanPhone = `55${cleanPhone}`
|
|
|
|
const parsedMessage = parseTemplate(type, variables)
|
|
const payload = { number: cleanPhone, body: parsedMessage }
|
|
|
|
const logResult = await WhatsappLog.create({ user_id: userId, phone: cleanPhone, type, payload, tenant_id: tenantId || 1 })
|
|
const logId = logResult.rows?.[0]?.id || logResult.lastInsertRowid
|
|
|
|
// Compradores recorrentes têm prioridade máxima nas mensagens de engajamento
|
|
const isCritical = CRITICAL_TYPES.has(type)
|
|
let priority = isCritical ? 1 : 10
|
|
if (!isCritical && userCpf) {
|
|
const isRepeat = await this._isRepeatBuyer(userCpf)
|
|
if (isRepeat) priority = 3
|
|
}
|
|
|
|
await whatsappQueue.add('send-msg', { logId, payload }, {
|
|
priority,
|
|
attempts: 3,
|
|
backoff: { type: 'exponential', delay: 5000 }
|
|
})
|
|
|
|
return logId
|
|
},
|
|
|
|
async _checkOptin(userCpf, phone) {
|
|
if (!userCpf) return true // sem CPF = não conseguimos checar, assume permitido
|
|
const user = await queryOne(
|
|
'SELECT whatsapp_optin FROM users WHERE cpf = $1',
|
|
[userCpf.replace(/\D/g, '')]
|
|
).catch(() => null)
|
|
if (!user) return false // CPF fornecido mas não encontrado — não assume opt-in
|
|
return user.whatsapp_optin !== false
|
|
},
|
|
|
|
async _isRepeatBuyer(userCpf) {
|
|
const row = await queryOne(
|
|
`SELECT COUNT(*) as cnt FROM orders
|
|
WHERE user_cpf = $1 AND status = 'paid'`,
|
|
[userCpf.replace(/\D/g, '')]
|
|
).catch(() => null)
|
|
return parseInt(row?.cnt ?? 0) > 1
|
|
},
|
|
}
|
|
|
|
module.exports = WhatsappCore
|