feat(wa): notifikasi pelanggan tiket + broadcast status layanan
All checks were successful
CI / test (push) Successful in 2m3s

- WA konfirmasi ke pelanggan saat tiket dibuat (web & APK)
- checkbox + target (semua/area/router) di Status Layanan
- WA gangguan/perbaikan saat mode berubah, WA layanan pulih saat NORMAL
- broadcast latar belakang dengan jeda acak, dedupe nomor, log [NoticeWA]
- versi 0.5.4
This commit is contained in:
oxpoe
2026-11-05 09:28:48 +07:00
parent 8231058f79
commit bc7f2bdb09
8 changed files with 233 additions and 5 deletions

View File

@@ -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

View File

@@ -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": {

View File

@@ -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 };
}

View File

@@ -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}`);

View File

@@ -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}`);

View File

@@ -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 };

View File

@@ -1 +1 @@
0.5.3
0.5.4

View File

@@ -68,6 +68,43 @@
<input class="fc" name="notice_note" id="n_note" value="<%= settings.notice_note || '' %>" placeholder="<%= t('admin.service_notice.note_ph', 'Kosongkan untuk teks bawaan') %>">
</div>
</div>
<div class="form-row" style="margin-top:4px">
<div class="fg">
<label class="fl" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" name="notice_wa" value="1" id="n_wa">
Kirim notifikasi WhatsApp ke pelanggan saat status berubah
</label>
<div class="hint">Terkirim otomatis saat mode diubah (gangguan/perbaikan, atau kembali NORMAL = layanan pulih).</div>
</div>
<div class="fg">
<label class="fl">Target Notifikasi</label>
<select class="fs" name="notice_wa_target" id="n_wa_target">
<option value="all">Semua pelanggan aktif</option>
<option value="area">Per area</option>
<option value="router">Per router</option>
</select>
</div>
</div>
<div class="form-row" id="n_wa_extra" style="display:none">
<div class="fg" id="n_wa_area_wrap">
<label class="fl">Area</label>
<select class="fs" name="notice_wa_area">
<option value="">— pilih area —</option>
<% (areas || []).forEach(function(a){ %>
<option value="<%= a %>"><%= a %></option>
<% }); %>
</select>
</div>
<div class="fg" id="n_wa_router_wrap" style="display:none">
<label class="fl">Router</label>
<select class="fs" name="notice_wa_router">
<option value="">— pilih router —</option>
<% (routers || []).forEach(function(r){ %>
<option value="<%= r.id %>"><%= r.name %></option>
<% }); %>
</select>
</div>
</div>
</div>
<div class="card-bd" style="border-top:1px solid var(--border);padding-top:16px;text-align:right">
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> <%= t('common.save', 'Simpan') %></button>
@@ -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();
</script>
</body>
</html>