diff --git a/CHANGELOG.md b/CHANGELOG.md index a3cd576..253f5dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/). +## [0.5.4] — 2026-11-05 — "wa-notice" + +### Ditambahkan +- **WA ke pelanggan saat tiket gangguan dibuat** (web & aplikasi): konfirmasi laporan diterima beserta nomor tiket `#TCK-…`. +- **Notifikasi WA pada Status Layanan**: centang "Kirim notifikasi WhatsApp" saat mode berubah ke **Gangguan Masal**/**Perbaikan**, dengan target semua pelanggan aktif, per area, atau per router. +- **WA "Layanan Kembali Normal"** otomatis saat mode dikembalikan ke NORMAL. +- Pengiriman broadcast berjalan di latar belakang dengan jeda acak mengikuti `whatsapp_broadcast_delay`, nomor duplikat digabung, dan hasilnya dicatat di log (`[NoticeWA]`). + ## [0.5.3] — 2026-11-05 — "forge-lime" ### Ditambahkan diff --git a/package.json b/package.json index 5c78875..a219331 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-isp", - "version": "0.5.3", + "version": "0.5.4", "description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)", "main": "app-customer.js", "scripts": { diff --git a/routes/adminPortal.js b/routes/adminPortal.js index d7d9943..5a7cfcd 100644 --- a/routes/adminPortal.js +++ b/routes/adminPortal.js @@ -4015,13 +4015,16 @@ router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.u }); router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => { + let routers = []; + try { routers = db.prepare('SELECT id, name FROM routers WHERE is_active = 1 ORDER BY name ASC').all(); } catch (_) {} res.render('admin/service_notice', { title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice', - settings: getSettings(), msg: flashMsg(req) + settings: getSettings(), msg: flashMsg(req), + areas: customerSvc.getAllCustomerAreas(), routers }); }); -router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), (req, res) => { +router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), async (req, res) => { try { const allowed = ['NORMAL', 'GANGGUAN_MASAL', 'PERBAIKAN']; let mode = String(req.body.notice_mode || 'NORMAL').trim().toUpperCase(); @@ -4029,6 +4032,7 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se let interval = parseInt(req.body.notice_interval_hours, 10); if (!interval || interval < 1) interval = 5; if (interval > 168) interval = 168; + const prevMode = String(getSetting('notice_mode', 'NORMAL') || 'NORMAL').toUpperCase(); saveSettings({ notice_mode: mode, notice_start: String(req.body.notice_start || '').trim(), @@ -4038,7 +4042,27 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se const noticeSvc = require('../services/serviceNoticeService'); const n = noticeSvc.computeNotice(); const detail = n.active ? (n.label + (n.estimateText ? ' — estimasi ' + n.estimateText : '')) : 'Normal (tanpa pengumuman)'; - req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail }; + let waInfo = ''; + if (req.body.notice_wa === '1') { + if (mode === prevMode) { + waInfo = ' Notifikasi WA dilewati (mode tidak berubah).'; + } else { + try { + const r = await require('../services/serviceNoticeNotifyService').broadcastNotice({ + mode, + target: String(req.body.notice_wa_target || 'all'), + area: String(req.body.notice_wa_area || ''), + routerId: parseInt(req.body.notice_wa_router, 10) || 0, + notice: n + }); + if (r.ok) waInfo = ` Notifikasi WA dikirim ke ${r.total} pelanggan (latar belakang).`; + else waInfo = ` Notifikasi WA tidak dikirim: ${r.reason === 'empty' ? 'tidak ada nomor valid' : r.reason === 'busy' ? 'proses lain masih berjalan' : 'WhatsApp belum aktif'}.`; + } catch (waErr) { + waInfo = ' Notifikasi WA gagal: ' + waErr.message; + } + } + } + req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail + '.' + waInfo }; } catch (e) { req.session._msg = { type: 'error', text: 'Gagal menyimpan status layanan: ' + e.message }; } diff --git a/routes/customerAPI.js b/routes/customerAPI.js index 65dd55e..57a0e8c 100644 --- a/routes/customerAPI.js +++ b/routes/customerAPI.js @@ -3509,6 +3509,11 @@ router.post('/tickets/create', requireCustomerApiAuth, async (req, res) => { for (const digits of recipients) { try { await waNotify.sendText(digits, waMsg); } catch (_) {} } + + // WA konfirmasi ke pelanggan yang membuat tiket + try { + await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject); + } catch (_) {} } } catch (waErr) { logger.error(`[CustomerAPI] Ticket WA Notification Error: ${waErr.message}`); diff --git a/routes/customerPortal.js b/routes/customerPortal.js index 7438435..a6f9cb4 100644 --- a/routes/customerPortal.js +++ b/routes/customerPortal.js @@ -3002,6 +3002,11 @@ router.post('/tickets/create', uploadCustomer.array('photos', 5), async (req, re if (!shouldSendWa(key)) continue; await waNotify.sendText(digits, waMsg); } + + // WA konfirmasi ke pelanggan yang membuat tiket + try { + await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject); + } catch (_) {} } } catch (waErr) { logger.error(`[Ticket] WA Notification Error: ${waErr.message}`); diff --git a/services/serviceNoticeNotifyService.js b/services/serviceNoticeNotifyService.js new file mode 100644 index 0000000..78a1cc6 --- /dev/null +++ b/services/serviceNoticeNotifyService.js @@ -0,0 +1,136 @@ +'use strict'; +/** + * serviceNoticeNotifyService.js + * Notifikasi WhatsApp ke pelanggan untuk: + * - tiket gangguan yang baru dibuat + * - pengumuman gangguan/perbaikan dari halaman Status Layanan + * - pemberitahuan layanan pulih (mode kembali NORMAL) + * + * Pengiriman broadcast berjalan di latar belakang dengan jeda acak + * mengikuti setting whatsapp_broadcast_delay (anti-banned). + */ +const customerSvc = require('./customerService'); +const waNotify = require('./waNotifyService'); +const { getSetting } = require('../config/settingsManager'); +const { logger } = require('../config/logger'); + +let _running = false; + +function digits(p) { + return String(p || '').replace(/\D/g, ''); +} + +function companyName() { + return getSetting('company_header', 'OPEN-ISP') || 'OPEN-ISP'; +} + +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +function delayMs() { + const sec = Number(getSetting('whatsapp_broadcast_delay', 5)) || 5; + return Math.max(2000, sec * 1000) + Math.floor(Math.random() * 1500); +} + +function ticketMessage(customer, ticketId, subject) { + return `✅ *Laporan Diterima*\n\n` + + `Halo *${customer.name || 'Pelanggan'}*, laporan gangguan Anda sudah kami terima.\n\n` + + `🎫 *No. Tiket:* #TCK-${ticketId}\n` + + `📝 *Subjek:* ${subject}\n\n` + + `Tim teknisi akan segera menindaklanjuti. Kami akan mengabari lagi setelah laporan selesai.\n\n` + + `_${companyName()}_`; +} + +function noticeMessage(notice, customer) { + let text = `⚠️ *${notice.label || 'Informasi Layanan'}*\n\n` + + `Halo *${customer.name || 'Pelanggan'}*,\n${notice.text || ''}\n`; + if (notice.startText) text += `\n🕒 *Mulai:* ${notice.startText}`; + if (notice.estimateText) text += `\n⏳ *Estimasi Selesai:* ${notice.estimateText}`; + text += `\n\nMohon maaf atas ketidaknyamanannya.\n\n_${companyName()}_`; + return text; +} + +function recoveryMessage(customer) { + return `✅ *Layanan Kembali Normal*\n\n` + + `Halo *${customer.name || 'Pelanggan'}*, gangguan/perbaikan layanan sudah selesai dan internet Anda dapat digunakan kembali seperti biasa.\n\n` + + `Terima kasih atas kesabaran Anda.\n\n_${companyName()}_`; +} + +async function notifyTicketCreated(customer, ticketId, subject) { + try { + if (!getSetting('whatsapp_enabled', false)) return false; + const phone = digits(customer && customer.phone); + if (phone.length < 8) return false; + return await waNotify.sendText(phone, ticketMessage(customer, ticketId, subject || 'Gangguan Layanan')); + } catch (e) { + logger.error(`[NoticeWA] notifyTicketCreated: ${e.message}`); + return false; + } +} + +function selectTargets({ target, area, routerId }) { + let list = []; + try { + list = customerSvc.getAllCustomers( + '', + target === 'router' && routerId ? Number(routerId) : null, + 'active', + target === 'area' ? String(area || '') : '', + {} + ) || []; + } catch (e) { + logger.warn(`[NoticeWA] gagal memilih target: ${e.message}`); + return []; + } + const seen = new Set(); + const out = []; + for (const c of list) { + const phone = digits(c.phone); + if (phone.length >= 8 && !seen.has(phone)) { + seen.add(phone); + out.push({ ...c, phone }); + } + } + return out; +} + +/** + * Kirim pengumuman ke pelanggan aktif (latar belakang). + * @returns {{ok:boolean,total?:number,reason?:string}} + */ +async function broadcastNotice({ mode, target, area, routerId, notice }) { + if (_running) return { ok: false, reason: 'busy' }; + if (!getSetting('whatsapp_enabled', false)) return { ok: false, reason: 'wa_disabled' }; + + const recipients = selectTargets({ target: target || 'all', area, routerId }); + if (!recipients.length) return { ok: false, reason: 'empty' }; + + const isRecovery = mode === 'NORMAL'; + _running = true; + logger.info(`[NoticeWA] mulai mode=${mode} target=${target || 'all'} total=${recipients.length}`); + + (async () => { + let sent = 0; + let failed = 0; + for (const c of recipients) { + await sleep(delayMs()); + try { + const ok = await waNotify.sendText(c.phone, isRecovery ? recoveryMessage(c) : noticeMessage(notice, c)); + if (ok) sent++; else failed++; + } catch (e) { + failed++; + } + } + _running = false; + logger.info(`[NoticeWA] selesai mode=${mode} terkirim=${sent} gagal=${failed}`); + })(); + + return { ok: true, total: recipients.length }; +} + +function isRunning() { + return _running; +} + +module.exports = { notifyTicketCreated, broadcastNotice, isRunning }; diff --git a/version.txt b/version.txt index be14282..167b000 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.5.3 +0.5.4 \ No newline at end of file diff --git a/views/admin/service_notice.ejs b/views/admin/service_notice.ejs index af6d0c5..45f7f34 100644 --- a/views/admin/service_notice.ejs +++ b/views/admin/service_notice.ejs @@ -68,6 +68,43 @@ +
+
+ +
Terkirim otomatis saat mode diubah (gangguan/perbaikan, atau kembali NORMAL = layanan pulih).
+
+
+ + +
+
+
@@ -140,6 +177,19 @@ [els.mode, els.start, els.interval, els.note].forEach(function(el){ el.addEventListener(ev, render); }); }); render(); + + var waTarget = document.getElementById('n_wa_target'); + var waExtra = document.getElementById('n_wa_extra'); + var waArea = document.getElementById('n_wa_area_wrap'); + var waRouter = document.getElementById('n_wa_router_wrap'); + function renderWaTarget(){ + var v = waTarget.value; + waExtra.style.display = v === 'all' ? 'none' : 'flex'; + waArea.style.display = v === 'area' ? '' : 'none'; + waRouter.style.display = v === 'router' ? '' : 'none'; + } + waTarget.addEventListener('change', renderWaTarget); + renderWaTarget();