Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/backend/src/modules/scheduled/scheduler.service.ts
T
VPS 4 Deploy Agent 2f8c04a0a7
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
chore(ops): restore all source files in newwhats.clube67.com
2026-05-18 03:28:29 +02:00

228 lines
8.0 KiB
TypeScript

/**
* SchedulerService — executa agendamentos de mensagens ricos.
*
* Tipos suportados:
* ONCE — executa uma vez em scheduledAt
* RECURRING — cron expression (cronExpr)
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
* verificado diariamente às 08:00 contra dados dos contatos
*
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
* Eventos são checados uma vez por dia via job separado.
*
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
* (sem passar pelo HTTP — evita latência e auth overhead).
*/
import { schedule as cronSchedule } from 'node-cron'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export class SchedulerService {
private manager: WhatsAppConnectionManager
private minuteJob: ReturnType<typeof cronSchedule> | null = null
private eventJob: ReturnType<typeof cronSchedule> | null = null
constructor(manager: WhatsAppConnectionManager) {
this.manager = manager
}
start() {
// Tick a cada minuto — processa ONCE e RECURRING vencidos
this.minuteJob = cronSchedule('* * * * *', () => {
this.processTimeBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de minuto')
)
})
// Tick diário às 08:00 — processa agendamentos por EVENTO
this.eventJob = cronSchedule('0 8 * * *', () => {
this.processEventBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de evento')
)
})
logger.info('[Scheduler] Iniciado (minuto + evento diário)')
}
stop() {
this.minuteJob?.stop()
this.eventJob?.stop()
}
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
private async processTimeBased() {
const now = new Date()
const schedules = await prisma.scheduledMessage.findMany({
where: {
status: 'ACTIVE',
scheduleType: { in: ['ONCE', 'RECURRING'] },
OR: [
{ nextRunAt: { lte: now } },
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
],
},
})
for (const sched of schedules) {
await this.executeSchedule(sched)
}
}
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
private async processEventBased() {
const today = new Date()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const todayMmDd = `${mm}-${dd}`
const schedules = await prisma.scheduledMessage.findMany({
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
})
for (const sched of schedules) {
const recipients = sched.recipients as Recipient[]
const matched: Recipient[] = []
for (const r of recipients) {
if (!r.jid) continue
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
// birthday format: "YYYY-MM-DD" or "MM-DD"
const parts = r.birthday.split('-')
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday
if (bMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
const parts = r.anniversary.split('-')
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary
if (aMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
// Dispara N dias após o signup — N fica em cronExpr como "30"
const daysAfter = parseInt(sched.cronExpr ?? '0', 10)
const signup = new Date(r.signupDate)
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000)
if (diffDays === daysAfter) matched.push(r)
}
}
if (matched.length > 0) {
await this.executeSchedule(sched, matched)
}
}
}
// ── Executa um agendamento ─────────────────────────────────────────────────
private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) {
const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[])
const sock = this.manager.getSocket(sched.instanceId)
if (!sock) {
logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando')
return
}
let sentCount = 0
let failedCount = 0
const errors: string[] = []
for (const recipient of recipients) {
if (!recipient.jid) continue
try {
const payload = this.buildPayload(sched.payload as any, recipient)
await sock.sendMessage(recipient.jid, payload)
sentCount++
// Pequeno delay anti-ban
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400))
} catch (err: any) {
failedCount++
errors.push(`${recipient.jid}: ${err?.message ?? err}`)
logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar')
}
}
// Persiste log
await prisma.scheduledMessageLog.create({
data: {
scheduleId: sched.id,
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
sentCount,
failedCount,
error: errors.length ? errors.join('\n') : null,
},
})
// Calcula próximo nextRunAt ou marca como DONE
if (sched.scheduleType === 'ONCE') {
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
status: 'DONE',
lastRunAt: new Date(),
runCount: { increment: 1 },
nextRunAt: null,
},
})
} else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
const next = this.nextCronDate(sched.cronExpr)
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
lastRunAt: new Date(),
nextRunAt: next,
runCount: { increment: 1 },
},
})
} else {
// EVENT — apenas registra execução
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
})
}
logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado')
}
// ── Monta payload Baileys com substituição de variáveis ────────────────────
// Variáveis: {{name}}, {{birthday}}, etc.
private buildPayload(raw: any, recipient: Recipient): any {
const replace = (str: string) =>
str
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '')
const recurse = (node: any): any => {
if (typeof node === 'string') return replace(node)
if (Array.isArray(node)) return node.map(recurse)
if (node && typeof node === 'object') {
const out: any = {}
for (const k of Object.keys(node)) out[k] = recurse(node[k])
return out
}
return node
}
return recurse(raw)
}
// ── Calcula próxima data de um cron ───────────────────────────────────────
private nextCronDate(expr: string): Date | null {
try {
const job = new Cron(expr, { paused: true })
return job.nextRun() ?? null
} catch {
return null
}
}
}