浏览代码

Implement WA job processing and logging system with background worker

yazid138 4 天之前
父节点
当前提交
ac98a70a0d

+ 7 - 0
app.js

@@ -5,13 +5,19 @@ const logger = require('morgan')
 const cors = require('cors')
 const csrf = require('./middleware/csrf')
 const response = require('./utils/responseHandler')
+const requestLogger = require('./middleware/requestLogger')
+const { startWorker } = require('./worker/waWorker')
 
 const app = express()
 
 require('./config/db')()
 
+// Start background worker for async WA sending
+startWorker(3000)
+
 app.use(logger('dev'))
 app.use(express.json())
+app.use(requestLogger)
 app.use(
   cors({
     origin: process.env.CORS_ORIGIN || '*',
@@ -38,6 +44,7 @@ app.use((req, res) =>
 
 app.use((err, req, res, next) => {
   console.error(err)
+  requestLogger.logUncaughtError(req, err)
   if (err.code === 'EBADCSRFTOKEN') {
     response.error(res, { code: 403, message: 'invalid csrf token' })
   } else {

+ 35 - 76
controller/v1/auto.controller.js

@@ -109,59 +109,37 @@ exports.reminderKeberatan = handleError(async (req, res) => {
     })
     .populate('user')
     .populate('laporan')
-  const notif = await Promise.all(
+  
+  let count = 0
+  const errors = []
+
+  await Promise.all(
     dataSanksi.map(async (e) => {
-      const dayLeft = moment(e.batas_waktu.jawaban_keberatan).diff(
-        new Date(),
-        'days'
-      )
+      const targetDate = e.batas_waktu?.jawaban_keberatan
+      const dayLeft = moment(targetDate).diff(new Date(), 'days')
       if (dayLeft > 0 && dayLeft <= 7) {
         try {
           await notifWA(TEMPLATE_REMINDER, [
-            {
-              key: '1',
-              value: 'no_laporan',
-              value_text: e.laporan.no_laporan
-            },
-            {
-              key: '2',
-              value: 'keterangan',
-              value_text: 'Proses Menjawab Pengajuan Keberatan'
-            },
-            {
-              key: '3',
-              value: 'pt',
-              value_text: e.laporan.pt.nama
-            },
-            {
-              key: '4',
-              value: 'masa',
-              value_text: `menjawab pengajuan keberatan tersisa ${dayLeft} hari lagi.`
-            }
+            { key: '1', value: 'no_laporan', value_text: e.laporan.no_laporan },
+            { key: '2', value: 'keterangan', value_text: 'Proses Menjawab Pengajuan Keberatan' },
+            { key: '3', value: 'pt', value_text: e.laporan.pt.nama },
+            { key: '4', value: 'masa', value_text: `menjawab pengajuan keberatan tersisa ${dayLeft} hari lagi.` }
           ])
-          const contacts = await kontakModel.find({
-            'role.id': {
-              $in: [PTB_ADMIN, PTB_DIKTI, PTB_LLDIKTI]
-            }
-          })
+          count++
+          const contacts = await kontakModel.find({ 'role.id': { $in: [PTB_ADMIN, PTB_DIKTI, PTB_LLDIKTI] } })
           await logModel.create({
-            aktivitas: `Server berhasil mengirim notifikasi Whatsapp kepada ${contacts.map((e) => e.nama).join(', ')} dengan Nomor Laporan ${e.laporan.no_laporan} terhadap ${e.laporan.pt.nama} untuk Mengajukan Keberatan`
+            aktivitas: `Server berhasil mengirim notifikasi Whatsapp kepada ${contacts.map((c) => c.nama).join(', ')} dengan Nomor Laporan ${e.laporan.no_laporan} terhadap ${e.laporan.pt.nama} untuk Mengajukan Keberatan`
           })
         } catch (error) {
-          return response.error(res, {
-            message: 'Notifikasi gagal terkirim',
-            error: error.message
-          })
+          errors.push({ laporan: e.laporan.no_laporan, error: error.message })
+          await logModel.create({ aktivitas: `Server gagal mengirim notifikasi Whatsapp untuk Laporan ${e.laporan.no_laporan}, Error: ${error.message}` })
         }
       }
     })
   )
 
-  let message = 'Tidak ada notifikasi yang dikirim'
-  if (notif.length) message = 'Notifikasi berhasil terkirim'
-  return response.success(res, {
-    message
-  })
+  let message = count === 0 ? 'Tidak ada notifikasi yang dikirim' : `Notifikasi berhasil terkirim (${count}/${dataSanksi.length})`
+  return response.success(res, { message, data: { sent: count, total: dataSanksi.length, errors } })
 })
 
 exports.reminderBanding = handleError(async (req, res) => {
@@ -178,59 +156,40 @@ exports.reminderBanding = handleError(async (req, res) => {
     })
     .populate('user')
     .populate('laporan')
-  const notif = await Promise.all(
+
+  let count = 0
+  const errors = []
+
+  await Promise.all(
     dataSanksi.map(async (e) => {
-      const dayLeft = moment(e.batas_waktu.jawaban_banding).diff(
-        new Date(),
-        'days'
-      )
+      const dayLeft = moment(e.batas_waktu.jawaban_banding).diff(new Date(), 'days')
       if (dayLeft > 0 && dayLeft <= 7) {
         try {
           await notifWA(TEMPLATE_REMINDER, [
-            {
-              key: '1',
-              value: 'no_laporan',
-              value_text: e.laporan.no_laporan
-            },
-            {
-              key: '2',
-              value: 'keterangan',
-              value_text: 'Proses Menjawab Pengajuan Banding'
-            },
-            {
-              key: '3',
-              value: 'pt',
-              value_text: e.laporan.pt.nama
-            },
-            {
-              key: '4',
-              value: 'masa',
-              value_text: `menjawab pengajuan banding tersisa ${dayLeft} hari lagi.`
-            }
+            { key: '1', value: 'no_laporan', value_text: e.laporan.no_laporan },
+            { key: '2', value: 'keterangan', value_text: 'Proses Menjawab Pengajuan Banding' },
+            { key: '3', value: 'pt', value_text: e.laporan.pt.nama },
+            { key: '4', value: 'masa', value_text: `menjawab pengajuan banding tersisa ${dayLeft} hari lagi.` }
           ])
+          count++
           const contacts = await kontakModel.find({
-            'role.id': {
-              $in: [PTB_ADMIN, PTB_DIKTI, PTB_LLDIKTI]
-            }
+            'role.id': { $in: [PTB_ADMIN, PTB_DIKTI, PTB_LLDIKTI] }
           })
           await logModel.create({
             aktivitas: `Server berhasil mengirim notifikasi Whatsapp kepada ${contacts.map((e) => e.nama).join(', ')} dengan Nomor Laporan ${e.laporan.no_laporan} terhadap ${e.laporan.pt.nama} untuk Mengajukan Banding`
           })
         } catch (error) {
-          return response.error(res, {
-            message: 'Notifikasi gagal terkirim',
-            error: error.message
+          errors.push({ laporan: e.laporan.no_laporan, error: error.message })
+          await logModel.create({
+            aktivitas: `Server gagal mengirim notifikasi Whatsapp untuk Laporan ${e.laporan.no_laporan}, Error: ${error.message}`
           })
         }
       }
     })
   )
 
-  let message = 'Tidak ada notifikasi yang dikirim'
-  if (notif.length) message = 'Notifikasi berhasil terkirim'
-  return response.success(res, {
-    message
-  })
+  let message = count === 0 ? 'Tidak ada notifikasi yang dikirim' : `Notifikasi berhasil terkirim (${count}/${dataSanksi.length})`
+  return response.success(res, { message, data: { sent: count, total: dataSanksi.length, errors } })
 })
 
 exports.updateStatusSanksi = handleError(async (req, res) => {

+ 57 - 0
middleware/requestLogger.js

@@ -0,0 +1,57 @@
+const { logInfo, logError } = require('../utils/logger')
+
+const SENSITIVE_KEYS = ['password', 'token', 'otp', 'authorization']
+
+const sanitize = (obj) => {
+  if (!obj || typeof obj !== 'object') return obj
+  const clone = { ...obj }
+  Object.keys(clone).forEach((key) => {
+    if (SENSITIVE_KEYS.includes(key.toLowerCase())) clone[key] = '[REDACTED]'
+  })
+  return clone
+}
+
+/**
+ * Logs every request (method, url, body, query, params) and its response
+ * (status code, body) to application.log. Errors (4xx/5xx or thrown) are
+ * logged via logError with full detail.
+ */
+const middleware = (req, res, next) => {
+  const start = Date.now()
+  const originalJson = res.json.bind(res)
+
+  res.json = (body) => {
+    const durationMs = Date.now() - start
+    const meta = {
+      method: req.method,
+      url: req.originalUrl,
+      status: res.statusCode,
+      durationMs,
+      requestBody: sanitize(req.body),
+      requestQuery: sanitize(req.query),
+      responseBody: body,
+    }
+
+    if (res.statusCode >= 400) {
+      logError('HTTP', `${req.method} ${req.originalUrl} failed`, meta)
+    } else {
+      logInfo('HTTP', `${req.method} ${req.originalUrl}`, meta)
+    }
+
+    return originalJson(body)
+  }
+
+  next()
+}
+
+middleware.logUncaughtError = (req, err) => {
+  logError('HTTP_UNCAUGHT', `${req.method} ${req.originalUrl} threw`, {
+    method: req.method,
+    url: req.originalUrl,
+    requestBody: sanitize(req.body),
+    error: err.message,
+    stack: err.stack,
+  })
+}
+
+module.exports = middleware

+ 28 - 0
model/waJob.model.js

@@ -0,0 +1,28 @@
+const mongoose = require('mongoose')
+const { Schema } = mongoose
+
+module.exports = mongoose.model(
+  'WaJob',
+  new Schema(
+    {
+      templateId: { type: String, required: true },
+      contacts: { type: Array, default: [] },
+      body: { type: Array, default: [] },
+      buttons: { type: Array, default: [] },
+      context: { type: String, default: '' },
+      status: {
+        type: String,
+        enum: ['pending', 'processing', 'sent', 'failed'],
+        default: 'pending',
+        index: true,
+      },
+      attempt: { type: Number, default: 0 },
+      nextRunAt: { type: Date, default: Date.now, index: true },
+      claimedAt: { type: Date, default: null },
+      lastError: { type: String, default: null },
+      response: { type: Schema.Types.Mixed, default: null },
+    },
+    { timestamps: true }
+  ),
+  'wa_job'
+)

+ 13 - 19
services/v2/notifikasi.service.js

@@ -1,35 +1,29 @@
 const kontakModel = require('../../model/kontak.model')
-const { notifWA } = require('../../utils/v1/notifFunction')
 const { TEMPLATE_LAPORAN } = require('../../utils/constanta')
 const logModel = require('../../model/log.model')
+const { enqueueWa } = require('../waJob.service')
 
 exports.sendWaCreateLaporan = async ({ user, pt, keterangan, no_laporan }) => {
   let contacts = await kontakModel.find()
-  contacts = contacts.map((e) => e.nama).join(', ')
+  const contactNames = contacts.map((e) => e.nama).join(', ')
+  const formattedContacts = contacts.map((e) => ({ name: e.nama, number: e.no_hp }))
+
   try {
-    const notif = await notifWA(TEMPLATE_LAPORAN, [
-      {
-        key: '1',
-        value: 'nama',
-        value_text: user.nama,
-      },
+    const body = [
+      { key: '1', value: 'nama', value_text: user.nama },
       { key: '2', value: 'pt', value_text: pt[0].nama },
       { key: '3', value: 'keterangan', value_text: keterangan },
       { key: '4', value: 'no_laporan', value_text: no_laporan },
-    ])
+    ]
+
+    await enqueueWa(TEMPLATE_LAPORAN, formattedContacts, body, 'CREATE_LAPORAN')
 
-    if (notif[0].status === 'success') {
-      await logModel.create({
-        aktivitas: `Server berhasil mengirim notif wa kepada ${contacts} untuk Pembuatan Laporan`,
-      })
-    } else {
-      await logModel.create({
-        aktivitas: `Server gagal mengirim notif wa kepada ${contacts} untuk Pembuatan Laporan, Error: ${JSON.stringify(notif)}`,
-      })
-    }
+    await logModel.create({
+      aktivitas: `Job notif wa Pembuatan Laporan berhasil dijadwalkan untuk ${contactNames}`,
+    })
   } catch (error) {
     await logModel.create({
-      aktivitas: `Server gagal mengirim notif wa kepada ${contacts} untuk Pembuatan Laporan, Error: ${error.message}`,
+      aktivitas: `Gagal menjadwalkan job notif wa Pembuatan Laporan untuk ${contactNames}, Error: ${error.message}`,
     })
   }
 }

+ 24 - 10
services/v2/pddikti.service.js

@@ -2,6 +2,7 @@ const axios = require('../../utils/axios')
 const coba = require('../../utils/coba')
 const qs = require('qs')
 const { PRODUCTION } = require('../../utils/constanta')
+const { logInfo, logError } = require('../../utils/logger')
 
 /**
  *
@@ -188,21 +189,34 @@ exports.updatePDDIKTI = ({
  * @param buttons
  * @returns {Promise<*>}
  */
-exports.whatsapp = (templateId, contacts, data, buttons = []) => {
+exports.whatsapp = async (templateId, contacts, data, buttons = []) => {
   let token
   let url = 'https://api.kemdiktisaintek.go.id:8243/qontak/1.0/send'
   if (coba.decrypt(process.env.CXQSB) === PRODUCTION) {
     url = 'https://api2.kemdiktisaintek.go.id/qontak/1.0/send'
     token = coba.decrypt(process.env.R3AOP)
   }
-  return axios.post(
-    url,
-    {
+
+  const payload = {
+    templateId,
+    contacts,
+    body: data,
+    buttons: buttons.map((e, index) => ({ ...e, index })),
+  }
+
+  logInfo('WA_SEND', 'Attempting to send WA message', { url, templateId, contactsCount: contacts.length, contacts })
+
+  try {
+    const res = await axios.post(url, payload, token)
+    logInfo('WA_SEND', 'WA message sent successfully', { templateId, response: res })
+    return res
+  } catch (error) {
+    logError('WA_SEND', 'Failed to send WA message', {
       templateId,
-      contacts,
-      body: data,
-      buttons: buttons.map((e, index) => ({ ...e, index })),
-    },
-    token
-  )
+      error: error.message,
+      response: error.response?.data || null,
+      status: error.response?.status || null
+    })
+    throw error
+  }
 }

+ 125 - 0
services/waJob.service.js

@@ -0,0 +1,125 @@
+const waJobModel = require('../model/waJob.model')
+const { logInfo, logError } = require('../utils/logger')
+
+/**
+ * Enqueue job kirim WA ke queue (asynchronous)
+ * Context: WA_CREATE_LAPORAN, WA_REMINDER, dll
+ */
+exports.enqueueWa = async (templateId, contacts, body, context = '', buttons = []) => {
+  try {
+    const job = await waJobModel.create({
+      templateId,
+      contacts,
+      body,
+      buttons,
+      context,
+      status: 'pending',
+      nextRunAt: new Date(),
+    })
+    logInfo('WA_JOB', 'Job enqueued', { jobId: job._id, context, contactsCount: contacts.length })
+    return job
+  } catch (err) {
+    logError('WA_JOB', 'Failed to enqueue WA job', { error: err.message, context })
+    throw err
+  }
+}
+
+/**
+ * Claim & process next pending job
+ * Menggunakan findOneAndUpdate untuk atomic lock (cegah double-process di multi-instance)
+ */
+exports.processNextJob = async () => {
+  try {
+    const job = await waJobModel.findOneAndUpdate(
+      {
+        status: 'pending',
+        nextRunAt: { $lte: new Date() },
+      },
+      {
+        $set: {
+          status: 'processing',
+          claimedAt: new Date(),
+        },
+      },
+      { new: true, sort: { nextRunAt: 1 } }
+    )
+
+    return job
+  } catch (err) {
+    logError('WA_JOB', 'Failed to claim next job', { error: err.message })
+    return null
+  }
+}
+
+/**
+ * Mark job sukses
+ */
+exports.markJobSuccess = async (jobId, response) => {
+  try {
+    await waJobModel.findByIdAndUpdate(jobId, {
+      status: 'sent',
+      response,
+      lastError: null,
+    })
+    logInfo('WA_JOB', 'Job marked as sent', { jobId })
+  } catch (err) {
+    logError('WA_JOB', 'Failed to mark job success', { error: err.message, jobId })
+  }
+}
+
+/**
+ * Mark job gagal, increment attempt, jadwal retry
+ */
+exports.markJobFailed = async (jobId, error, attempt) => {
+  try {
+    const maxAttempt = 3
+    const backoffMs = Math.pow(2, attempt) * 1000 // 2s, 4s, 8s
+    const nextRunAt = new Date(Date.now() + backoffMs)
+    const willRetry = attempt < maxAttempt
+
+    await waJobModel.findByIdAndUpdate(jobId, {
+      status: willRetry ? 'pending' : 'failed',
+      lastError: error.message || String(error),
+      attempt,
+      nextRunAt: willRetry ? nextRunAt : null,
+      claimedAt: null,
+    })
+    logInfo('WA_JOB', `Job ${willRetry ? 'will retry' : 'permanently failed'}`, {
+      jobId,
+      attempt,
+      nextRunAtMs: willRetry ? backoffMs : null,
+      error: error.message,
+    })
+  } catch (err) {
+    logError('WA_JOB', 'Failed to mark job failed', { error: err.message, jobId })
+  }
+}
+
+/**
+ * Reset in-progress jobs saat startup (kalau server crashed saat processing)
+ */
+exports.resetStuckJobs = async () => {
+  try {
+    const result = await waJobModel.updateMany(
+      { status: 'processing', claimedAt: { $lt: new Date(Date.now() - 60 * 1000) } },
+      { $set: { status: 'pending', claimedAt: null } }
+    )
+    if (result.modifiedCount > 0) {
+      logInfo('WA_JOB', 'Reset stuck jobs', { count: result.modifiedCount })
+    }
+  } catch (err) {
+    logError('WA_JOB', 'Failed to reset stuck jobs', { error: err.message })
+  }
+}
+
+/**
+ * Get job status by ID
+ */
+exports.getJobStatus = async (jobId) => {
+  try {
+    return await waJobModel.findById(jobId)
+  } catch (err) {
+    logError('WA_JOB', 'Failed to get job status', { error: err.message, jobId })
+    return null
+  }
+}

+ 45 - 0
utils/logger.js

@@ -0,0 +1,45 @@
+const fs = require('fs');
+const path = require('path');
+
+const LOG_FILE = path.join(process.cwd(), 'application.log');
+
+/**
+ * Format log entry dengan timestamp dan metadata
+ */
+const formatLog = (level, context, message, meta = {}) => {
+  const timestamp = new Date().toISOString();
+  const metaStr = Object.keys(meta).length ? ` | ${JSON.stringify(meta)}` : '';
+  return `[${timestamp}] [${level}] [${context}] ${message}${metaStr}\n`;
+};
+
+/**
+ * Write ke file synchronously (simple, no dependencies)
+ * ponytail: pakai sync untuk guarantee write, upgrade to stream jika high volume
+ */
+const writeLog = (entry) => {
+  try {
+    fs.appendFileSync(LOG_FILE, entry, 'utf8');
+  } catch (err) {
+    console.error('[Logger] Failed to write log file:', err.message);
+  }
+};
+
+module.exports = {
+  logInfo: (context, message, meta = {}) => {
+    const entry = formatLog('INFO', context, message, meta);
+    console.log(entry.trim());
+    writeLog(entry);
+  },
+
+  logError: (context, message, meta = {}) => {
+    const entry = formatLog('ERROR', context, message, meta);
+    console.error(entry.trim());
+    writeLog(entry);
+  },
+
+  logWarn: (context, message, meta = {}) => {
+    const entry = formatLog('WARN', context, message, meta);
+    console.warn(entry.trim());
+    writeLog(entry);
+  },
+};

+ 32 - 21
utils/notifFunction.js

@@ -1,33 +1,44 @@
 const axios = require('../utils/axios')
 const kontakModel = require('../model/kontak.model')
+const { logInfo, logError } = require('./logger')
 
 exports.notifWA = async (templateId, data, where = {}) => {
   const kontak = await kontakModel.find(where)
   const contacts = kontak.map((e) => ({ name: e.nama, number: e.no_hp }))
-  const send = await axios.post(
-    'https://api.kemdiktisaintek.go.id:8243/qontak/1.0/send',
-    {
+  const url = 'https://api2.kemdiktisaintek.go.id/qontak/1.0/send'
+  
+  logInfo('WA_SEND_V1', 'Sending WA via notifWA v1', { templateId, contactsCount: contacts.length, contacts })
+
+  try {
+    const send = await axios.post(url, { templateId, contacts, body: data })
+    logInfo('WA_SEND_V1', 'WA sent successfully v1', { templateId, response: send })
+    return send
+  } catch (error) {
+    logError('WA_SEND_V1', 'Failed to send WA v1', {
       templateId,
-      contacts,
-      body: data,
-    }
-  )
-  return send
+      error: error.message,
+      response: error.response?.data || null
+    })
+    throw error
+  }
 }
 
 exports.notifWA2 = async (templateId, { nama, no_hp }, data) => {
-  const send = await axios.post(
-    'https://api.kemdiktisaintek.go.id:8243/qontak/1.0/send',
-    {
+  const url = 'https://api2.kemdiktisaintek.go.id/qontak/1.0/send'
+  const contacts = [{ name: nama, number: no_hp }]
+  
+  logInfo('WA_SEND_V1', 'Sending WA via notifWA2 v1', { templateId, contacts })
+
+  try {
+    const send = await axios.post(url, { templateId, contacts, body: data })
+    logInfo('WA_SEND_V1', 'WA2 sent successfully v1', { templateId, response: send })
+    return send
+  } catch (error) {
+    logError('WA_SEND_V1', 'Failed to send WA2 v1', {
       templateId,
-      contacts: [
-        {
-          name: nama,
-          number: no_hp,
-        },
-      ],
-      body: data,
-    }
-  )
-  return send
+      error: error.message,
+      response: error.response?.data || null
+    })
+    throw error
+  }
 }

+ 23 - 7
utils/v1/notifFunction.js

@@ -1,25 +1,41 @@
 const kontakModel = require('../../model/kontak.model')
-const pddiktiService = require('../../services/v2/pddikti.service')
-const { PTB_DIKTI } = require('../constanta')
+const { PTB_DIKTI, SUCCESS } = require('../constanta')
+const { enqueueWa } = require('../../services/waJob.service')
+const { logError } = require('../logger')
 
 /**
+ * Enqueue WA job ke background worker, bukan kirim langsung.
+ * Return shape tetap [{ status }] supaya caller lama (notif[0].status === SUCCESS) tidak perlu berubah.
  *
  * @param templateId
  * @param data
  * @param where {object?}
- * @returns {Promise<*>}
+ * @returns {Promise<Array<{status: string, jobId?: string}>>}
  */
 exports.notifWA = async (templateId, data, where = {}) => {
   const kontak = await kontakModel.find({ ...where, 'role.id': PTB_DIKTI })
   const contacts = kontak.map((e) => ({ name: e.nama, number: e.no_hp }))
-  return pddiktiService.whatsapp(templateId, contacts, data)
+  try {
+    const job = await enqueueWa(templateId, contacts, data, 'notifWA')
+    return [{ status: SUCCESS, jobId: job._id }]
+  } catch (error) {
+    logError('WA_SEND_V1', 'Failed to enqueue notifWA', { templateId, error: error.message })
+    return [{ status: 'failed', error: error.message }]
+  }
 }
 
 /**
- *
  * @param templateId
  * @param contacts
  * @param data
- * @returns {Promise<*>}
+ * @returns {Promise<Array<{status: string, jobId?: string}>>}
  */
-exports.notifWA2 = async (templateId, contacts, data) => pddiktiService.whatsapp(templateId, contacts, data)
+exports.notifWA2 = async (templateId, contacts, data) => {
+  try {
+    const job = await enqueueWa(templateId, contacts, data, 'notifWA2')
+    return [{ status: SUCCESS, jobId: job._id }]
+  } catch (error) {
+    logError('WA_SEND_V1', 'Failed to enqueue notifWA2', { templateId, error: error.message })
+    return [{ status: 'failed', error: error.message }]
+  }
+}

+ 49 - 0
worker/waWorker.js

@@ -0,0 +1,49 @@
+const pddiktiService = require('../services/v2/pddikti.service')
+const waJobService = require('../services/waJob.service')
+const { logInfo, logError } = require('../utils/logger')
+
+let isRunning = false
+let timer = null
+
+const processJobs = async () => {
+  if (isRunning) return
+  isRunning = true
+
+  try {
+    const job = await waJobService.processNextJob()
+    if (job) {
+      logInfo('WA_WORKER', 'Processing job', { jobId: job._id, templateId: job.templateId })
+      try {
+        const response = await pddiktiService.whatsapp(
+          job.templateId,
+          job.contacts,
+          job.body,
+          job.buttons
+        )
+        await waJobService.markJobSuccess(job._id, response)
+      } catch (error) {
+        await waJobService.markJobFailed(job._id, error, job.attempt + 1)
+      }
+    }
+  } catch (err) {
+    logError('WA_WORKER', 'Error in worker tick', { error: err.message })
+  } finally {
+    isRunning = false
+  }
+}
+
+const startWorker = (intervalMs = 3000) => {
+  logInfo('WA_WORKER', `Starting WA Background Worker (interval: ${intervalMs}ms)`)
+  waJobService.resetStuckJobs()
+  timer = setInterval(processJobs, intervalMs)
+}
+
+const stopWorker = () => {
+  if (timer) clearInterval(timer)
+  logInfo('WA_WORKER', 'Stopped WA Background Worker')
+}
+
+module.exports = {
+  startWorker,
+  stopWorker,
+}