7 Commits

Author SHA1 Message Date
oxpoe
2b35ee3f99 docs(screenshots): tangkapan layar v0.5.2 + samarkan kolom API key
- 10 screenshot (desktop + mobile) untuk landing, admin, inventaris, AI settings, portal pelanggan
- field API key AI memakai type=password agar tidak tampil di layar/screenshot
2026-11-05 02:07:48 +07:00
oxpoe
adc3437bd5 fix(login): link status ikut setting uptime_url + batas sesi per akun
- login.ejs: pakai settings.uptime_url (fallback status_url lama), sehingga tab Status tidak lagi hardcode status.skyfiber.qzz.io
- sessionLimitService: rotasi session id saat login (anti fixation), catat sesi di tabel login_sessions, batas admin/kasir/teknisi/kolektor 2 per akun dan pelanggan 3 per akun; sesi tertua diputus dari store saat melebihi batas
- hook login admin/kasir/teknisi/kolektor/pelanggan (direct & OTP) dan logout
2026-11-05 01:58:29 +07:00
oxpoe
4003149046 feat(ai): provider OpenAI-compatible + DeepSeek, AI Telegram, self-healing dengan toggle (port upstream)
- telegramAiService/adminAiTools: dukung openai, deepseek, groq, openrouter, custom (base URL + key + model)
- selfHealingService: billing vs isolir, konsistensi secret PPPoE, sesi RADIUS stale, IP ACS kosong
- mikrotikService: getActivePppoeUsers, isolateCustomerPppoe, unisolateCustomerPppoe (upstream memanggil fungsi yang tidak ada)
- cron 30 menit hanya jalan bila ai_self_healing_enabled=true (default off) + scan brute force MikroTik tiap 2 menit
- Pengaturan: kartu AI & Self-Healing (provider, key/model, toggle, tombol Diagnosa/Perbaiki)
- bot Telegram: pesan non-command dari admin dijawab AI
2026-11-05 01:48:50 +07:00
oxpoe
1c40e6a0e8 feat(keamanan): security watchdog brute-force + soundbox TTS (port upstream)
- securityWatchdogService: deteksi brute force login admin/kasir/teknisi/kolektor & log MikroTik, blokir IP 15 menit, alert suara + WA/Telegram
- soundboxService + voiceTtsService (TTS Google Translate, cache)
- hook login admin/teknisi/kolektor, hook soundbox saat invoice lunas
- route /admin/api/soundbox/poll dan /test
2026-11-05 01:27:33 +07:00
oxpoe
95fa69e968 feat(inventaris): stok keluar/retur/opname + detail unit, dan edit slip payroll (port upstream)
- inventoryService: port penuh dari upstream (issueStock FIFO/SN, returnStock, adjustStock opname, serial tersedia, statistik, detail barang) + kompatibilitas fork
- schema: kolom price/supplier (stock) dan remaining_stock/recipient/reference_no (log) via migrasi aman
- admin: route stok keluar/retur/opname + API detail & serial; modal + tombol aksi di halaman inventaris; viewStock yang tadinya undefined
- payroll: updateSlip() + route /payroll/slip/:id/update
2026-11-05 01:26:18 +07:00
oxpoe
d24cabf818 feat(akses): batasi teknisi & kolektor sesuai wilayah tugas (port dari upstream)
- techService: getTechAreaWhere + canTechAccessCustomer, filter pool/stats
- techPortal: filter peta, ODP, daftar perangkat; guard perangkat per wilayah; area pelanggan baru dipaksa sesuai wilayah
- customerService: getCollectorCustomerWhere + canCollectorAccessCustomer (multi-area)
- collectorPortal: daftar tagihan pakai helper; tolak bayar/cetak/kirim PDF di luar wilayah
- customerAPI: dashboard & pay-bill kolektor dibatasi wilayah
- tests/unit/area-access.test.js: self-check logika area
2026-11-05 01:16:26 +07:00
oxpoe
2a29739dc3 docs: banner mirror sebutkan rilis/unduhan di Gitea 2026-10-14 22:34:20 +07:00
38 changed files with 4061 additions and 126 deletions

View File

@@ -1,4 +1,4 @@
> 🪞 **Mirror.** Repo asli & pengembangan ada di **Gitea**: https://gitea.oxpoe.qzz.io/oxpoe/open-isp > 🪞 **Mirror.** Repo asli, pengembangan & **rilis/unduhan** ada di **Gitea**: https://gitea.oxpoe.qzz.io/oxpoe/open-isp/releases · GitHub hanya cermin kode.
<p align="center"> <p align="center">
<img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%"> <img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%">

View File

@@ -1107,6 +1107,18 @@ try {
for (const [n, def] of _add) { if (!_have.has(n)) db.exec('ALTER TABLE packages ADD COLUMN ' + n + ' ' + def); } for (const [n, def] of _add) { if (!_have.has(n)) db.exec('ALTER TABLE packages ADD COLUMN ' + n + ' ' + def); }
} catch (e) { console.error('Failed to migrate packages ip columns:', e); } } catch (e) { console.error('Failed to migrate packages ip columns:', e); }
// Safe migration: kolom tambahan inventaris (harga/supplier + log penerima & sisa stok)
try {
const stockCols = db.prepare("PRAGMA table_info(inventory_stock)").all();
if (!stockCols.find(c => c.name === 'price')) db.exec("ALTER TABLE inventory_stock ADD COLUMN price INTEGER DEFAULT 0");
if (!stockCols.find(c => c.name === 'supplier')) db.exec("ALTER TABLE inventory_stock ADD COLUMN supplier TEXT DEFAULT ''");
const logCols = db.prepare("PRAGMA table_info(inventory_logs)").all();
if (!logCols.find(c => c.name === 'remaining_stock')) db.exec("ALTER TABLE inventory_logs ADD COLUMN remaining_stock INTEGER DEFAULT 0");
if (!logCols.find(c => c.name === 'recipient')) db.exec("ALTER TABLE inventory_logs ADD COLUMN recipient TEXT DEFAULT ''");
if (!logCols.find(c => c.name === 'reference_no')) db.exec("ALTER TABLE inventory_logs ADD COLUMN reference_no TEXT DEFAULT ''");
} catch (e) { console.error('Failed to migrate inventory columns:', e); }
module.exports = db; module.exports = db;
module.exports.getAppSetting = getAppSetting; module.exports.getAppSetting = getAppSetting;
module.exports.saveAppSetting = saveAppSetting; module.exports.saveAppSetting = saveAppSetting;

View File

@@ -285,6 +285,14 @@ function ensureDefaultSettings() {
// Default settings yang wajib ada // Default settings yang wajib ada
const defaultSettings = { const defaultSettings = {
// AI (OpenAI-compatible) & self-healing
ai_provider: 'deepseek',
deepseek_model: 'deepseek-chat',
openai_model: 'gpt-4o-mini',
groq_model: 'llama-3.3-70b-versatile',
custom_ai_base_url: '',
custom_ai_model: 'llama3',
ai_self_healing_enabled: false,
// RADIUS Server settings (ditambahkan untuk update dari Gitea) // RADIUS Server settings (ditambahkan untuk update dari Gitea)
radius_enabled: '0', radius_enabled: '0',
radius_secret: 'secret123', radius_secret: 'secret123',

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

View File

@@ -0,0 +1,20 @@
# Screenshot Aplikasi (v0.5.2)
Diambil dari instance uji lokal (`http://localhost:3001`) dengan data demo
(`node scripts/seed-demo.js`), viewport 1440×900 (mobile 390×844).
| File | Halaman |
|---|---|
| `01-landing.png` | Landing page publik |
| `02-admin-login.png` | Halaman login admin |
| `03-admin-dashboard.png` | Dashboard admin (ringkasan pelanggan, billing, ONU) |
| `04-admin-customers.png` | Daftar pelanggan |
| `05-admin-inventory.png` | Inventaris stok |
| `06-inventory-stok-keluar.png` | Modal Stok Keluar (FIFO / serial number) |
| `07-admin-settings-ai.png` | Pengaturan → AI & Self-Healing (key disamarkan) |
| `08-customer-login.png` | Login pelanggan + tab Status mengikuti setting `uptime_url` |
| `09-customer-dashboard.png` | Dashboard pelanggan |
| `10-mobile-customer-login.png` | Login pelanggan (tampilan mobile) |
Catatan: field API key di halaman Pengaturan memakai `type="password"`
sehingga tidak tampil di screenshot maupun saat layar dibagikan.

View File

@@ -56,6 +56,8 @@ const promoUpload = multer({
const backupSvc = require('../services/backupService'); const backupSvc = require('../services/backupService');
const monitoringSvc = require('../services/monitoringService'); const monitoringSvc = require('../services/monitoringService');
const inventorySvc = require('../services/inventoryService'); const inventorySvc = require('../services/inventoryService');
const securityWatchdog = require('../services/securityWatchdogService');
const sessionLimit = require('../services/sessionLimitService');
const auditSvc = require('../services/auditTrailService'); const auditSvc = require('../services/auditTrailService');
const whatsappService = require('../services/whatsappService'); const whatsappService = require('../services/whatsappService');
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser'); const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
@@ -554,17 +556,38 @@ router.get('/login', (req, res) => {
res.render('admin/login', { title: 'Admin Login', company: company(), error: null }); res.render('admin/login', { title: 'Admin Login', company: company(), error: null });
}); });
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => { router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => {
const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1';
if (securityWatchdog.isIpBlocked(ip)) {
return res.render('admin/login', {
title: 'Admin Login',
company: company(),
error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.'
});
}
const { username, password } = req.body; const { username, password } = req.body;
if (username === getSetting('admin_username', 'admin') && password === getSetting('admin_password', 'demo-isp123')) { if (username === getSetting('admin_username', 'admin') && password === getSetting('admin_password', 'demo-isp123')) {
await securityWatchdog.recordAppLoginAttempt({ type: 'admin', username, ip, success: true });
await sessionLimit.rotateSession(req);
req.session.isAdmin = true; req.session.isAdmin = true;
req.session.adminUser = username; req.session.adminUser = username;
sessionLimit.registerLoginSession(req, {
role: 'admin', accountId: username, limit: 2,
ip, userAgent: req.get('user-agent')
});
return res.redirect('/admin'); return res.redirect('/admin');
} }
// Check Cashier // Check Cashier
const cashier = adminSvc.authenticateCashier(username, password); const cashier = adminSvc.authenticateCashier(username, password);
if (cashier) { if (cashier) {
await securityWatchdog.recordAppLoginAttempt({ type: 'cashier', username, ip, success: true });
await sessionLimit.rotateSession(req);
sessionLimit.registerLoginSession(req, {
role: 'cashier', accountId: cashier.id, limit: 2,
ip, userAgent: req.get('user-agent')
});
req.session.isCashier = true; req.session.isCashier = true;
req.session.cashierId = cashier.id; req.session.cashierId = cashier.id;
req.session.cashierName = cashier.name; req.session.cashierName = cashier.name;
@@ -572,10 +595,21 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }),
return res.redirect('/admin'); return res.redirect('/admin');
} }
await securityWatchdog.recordAppLoginAttempt({
type: 'admin',
username: username || '',
ip,
success: false,
userAgent: req.get('user-agent')
});
res.render('admin/login', { title: 'Admin Login', company: company(), error: 'Username atau password salah' }); res.render('admin/login', { title: 'Admin Login', company: company(), error: 'Username atau password salah' });
}); });
router.get('/logout', (req, res) => { req.session.destroy(); res.redirect('/admin/login'); }); router.get('/logout', (req, res) => {
sessionLimit.removeLoginSession(req);
req.session.destroy();
res.redirect('/admin/login');
});
// ─── OLT MANAGEMENT ──────────────────────────────────────────────────────── // ─── OLT MANAGEMENT ────────────────────────────────────────────────────────
router.get('/olts', requireAdminSession, async (req, res) => { router.get('/olts', requireAdminSession, async (req, res) => {
@@ -4741,6 +4775,12 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true; if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false; else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
newSettings.ai_provider = String(newSettings.ai_provider || 'deepseek').trim().toLowerCase();
newSettings.ai_self_healing_enabled = newSettings.ai_self_healing_enabled === 'true' || newSettings.ai_self_healing_enabled === 'on';
for (const k of ['deepseek_api_key','openai_api_key','groq_api_key','openrouter_api_key','custom_ai_api_key','custom_ai_base_url','custom_ai_model','deepseek_model','openai_model','groq_model','openrouter_model']) {
if (newSettings[k] !== undefined) newSettings[k] = String(newSettings[k]).trim();
}
if (newSettings.qris_static_enabled === 'true') newSettings.qris_static_enabled = true; if (newSettings.qris_static_enabled === 'true') newSettings.qris_static_enabled = true;
else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false; else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false;
@@ -5096,7 +5136,8 @@ router.post('/backup/cleanup', requireAdminSession, express.urlencoded({ extende
router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => { router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => {
const items = inventorySvc.getAllItems(req.query.q); const items = inventorySvc.getAllItems(req.query.q);
const categories = inventorySvc.getAllCategories(); const categories = inventorySvc.getAllCategories();
const logs = inventorySvc.getInventoryLogs(100); const logs = inventorySvc.getInventoryLogs({ limit: 100 });
const stats = inventorySvc.getInventoryStats();
res.render('admin/inventory', { res.render('admin/inventory', {
title: 'Manajemen Inventaris', title: 'Manajemen Inventaris',
@@ -5105,7 +5146,8 @@ router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventor
msg: flashMsg(req), msg: flashMsg(req),
items, items,
categories, categories,
logs logs,
stats
}); });
}); });
@@ -5159,6 +5201,101 @@ router.post('/inventory/stock/add', requireAdminSession, express.urlencoded({ ex
res.redirect('/admin/inventory'); res.redirect('/admin/inventory');
}); });
router.post('/inventory/stock/out', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const result = inventorySvc.issueStock(req.body, req.session.adminUser || 'Admin');
req.session._msg = { type: 'success', text: `Stok keluar dicatat. Sisa tersedia: ${result.remainingStock}.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/stock/return', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
inventorySvc.returnStock(req.body, req.session.adminUser || 'Admin');
req.session._msg = { type: 'success', text: 'Retur barang berhasil dicatat.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/stock/adjust', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const result = inventorySvc.adjustStock(req.body, req.session.adminUser || 'Admin');
req.session._msg = { type: 'success', text: result.message || `Opname disimpan. Selisih: ${result.diff}, stok fisik: ${result.remainingStock}.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.get('/api/inventory/item/:id', requireAdminSession, (req, res) => {
try {
const detail = inventorySvc.getItemDetail(req.params.id);
if (!detail) return res.status(404).json({ success: false, message: 'Barang tidak ditemukan' });
res.json({ success: true, ...detail });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/api/inventory/serials/:itemId', requireAdminSession, (req, res) => {
try {
res.json({ success: true, serials: inventorySvc.getAvailableSerialNumbers(req.params.itemId) });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/api/self-heal/diagnose', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const svc = require('../services/selfHealingService');
const result = await svc.diagnose();
res.json({ success: true, ...result });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post('/api/self-heal/run', requireAdminSession, restrictToAdmin, async (req, res) => {
try {
const svc = require('../services/selfHealingService');
const result = await svc.autoRepair(req.session.adminUser || 'Web Admin');
res.json({ success: true, ...result });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/api/soundbox/poll', requireAdminSession, (req, res) => {
try {
const soundboxSvc = require('../services/soundboxService');
const events = soundboxSvc.getLatestEvents(Number(req.query.afterId || 0), 15);
res.json({ success: true, events });
} catch (err) {
res.status(500).json({ success: false, message: err.message, events: [] });
}
});
router.post('/api/soundbox/test', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const soundboxSvc = require('../services/soundboxService');
const amount = Number(req.body?.amount || 50000);
const id = soundboxSvc.queueSoundboxEvent({
type: 'test',
title: 'Uji Suara Kasir',
speechText: req.body?.speechText || ('Uji coba speaker kasir berhasil. Pembayaran sebesar ' + soundboxSvc.formatRupiahSpoken(amount) + ' diterima.'),
amount,
customerName: 'Admin Soundbox'
});
res.json({ success: true, eventId: id });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => { router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => {
const filters = { const filters = {
action: req.query.action || null, action: req.query.action || null,
@@ -7790,6 +7927,16 @@ router.post('/payroll/slip/:id/deduction', requireAdmin, express.json(), (req, r
} }
}); });
router.post('/payroll/slip/:id/update', requireAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
payrollSvc.updateSlip(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Rincian slip gaji berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal memperbarui slip: ' + e.message };
}
res.redirect('back');
});
router.post('/payroll/slip/:id/approve', requireAdmin, (req, res) => { router.post('/payroll/slip/:id/approve', requireAdmin, (req, res) => {
try { try {
payrollSvc.approveSlip(req.params.id); payrollSvc.approveSlip(req.params.id);

View File

@@ -5,6 +5,8 @@ const { logger } = require('../config/logger');
const db = require('../config/database'); const db = require('../config/database');
const billingSvc = require('../services/billingService'); const billingSvc = require('../services/billingService');
const customerSvc = require('../services/customerService'); const customerSvc = require('../services/customerService');
const securityWatchdog = require('../services/securityWatchdogService');
const sessionLimit = require('../services/sessionLimitService');
const adminSvc = require('../services/adminService'); const adminSvc = require('../services/adminService');
const attendanceSvc = require('../services/attendanceService'); const attendanceSvc = require('../services/attendanceService');
const pdfSvc = require('../services/pdfInvoiceService'); const pdfSvc = require('../services/pdfInvoiceService');
@@ -49,22 +51,45 @@ router.get('/login', (req, res) => {
res.render('collector/login', { title: 'Login Kolektor', company: company(), error: null }); res.render('collector/login', { title: 'Login Kolektor', company: company(), error: null });
}); });
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => { router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => {
const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1';
if (securityWatchdog.isIpBlocked(ip)) {
return res.render('collector/login', {
title: 'Login Kolektor',
company: company(),
error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.'
});
}
const username = String(req.body.username || '').trim(); const username = String(req.body.username || '').trim();
const password = String(req.body.password || ''); const password = String(req.body.password || '');
const collector = adminSvc.authenticateCollector(username, password); const collector = adminSvc.authenticateCollector(username, password);
if (collector) { if (collector) {
await securityWatchdog.recordAppLoginAttempt({ type: 'collector', username, ip, success: true });
await sessionLimit.rotateSession(req);
req.session.isCollector = true; req.session.isCollector = true;
sessionLimit.registerLoginSession(req, {
role: 'collector', accountId: collector.id, limit: 2,
ip, userAgent: req.get('user-agent')
});
req.session.collectorId = collector.id; req.session.collectorId = collector.id;
req.session.collectorName = collector.name; req.session.collectorName = collector.name;
req.session.collectorUsername = collector.username; req.session.collectorUsername = collector.username;
req.session.collectorArea = collector.area || ''; req.session.collectorArea = collector.area || '';
return res.redirect('/collector'); return res.redirect('/collector');
} }
await securityWatchdog.recordAppLoginAttempt({
type: 'collector',
username,
ip,
success: false,
userAgent: req.get('user-agent')
});
return res.render('collector/login', { title: 'Login Kolektor', company: company(), error: 'Username atau password salah!' }); return res.render('collector/login', { title: 'Login Kolektor', company: company(), error: 'Username atau password salah!' });
}); });
router.get('/logout', (req, res) => { router.get('/logout', (req, res) => {
sessionLimit.removeLoginSession(req);
req.session.destroy(); req.session.destroy();
res.redirect('/collector/login'); res.redirect('/collector/login');
}); });
@@ -177,16 +202,10 @@ router.get('/', requireCollectorSession, (req, res) => {
const todayDay = now.getDate(); const todayDay = now.getDate();
const collectorId = Number(req.session.collectorId || 0); const collectorId = Number(req.session.collectorId || 0);
const collectorObj = db.prepare('SELECT area FROM collectors WHERE id = ?').get(collectorId); const collectorObj = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(collectorId);
const collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim(); req.session.collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
req.session.collectorArea = collectorArea;
let collectorWhere = '(c.collector_id = ? OR c.collector_id IS NULL)'; const { where: collectorWhere, params: collectorParams } = customerSvc.getCollectorCustomerWhere(collectorObj, 'c');
let collectorParams = [collectorId];
if (collectorArea) {
collectorWhere = '(c.collector_id = ? OR ((c.collector_id IS NULL OR c.collector_id = 0) AND LOWER(TRIM(c.area)) = LOWER(TRIM(?))))';
collectorParams = [collectorId, collectorArea];
}
let q = ` let q = `
SELECT c.id as customer_id, SELECT c.id as customer_id,
@@ -351,6 +370,14 @@ router.post('/payment-request', requireCollectorSession, express.urlencoded({ ex
if (!inv) throw new Error('Tagihan tidak ditemukan'); if (!inv) throw new Error('Tagihan tidak ditemukan');
if (String(inv.status || '').toLowerCase() === 'paid') throw new Error('Tagihan sudah lunas'); if (String(inv.status || '').toLowerCase() === 'paid') throw new Error('Tagihan sudah lunas');
{
const collectorIdCheck = Number(req.session.collectorId || 0);
const invCust = customerSvc.getCustomerById(inv.customer_id);
if (!customerSvc.canCollectorAccessCustomer(collectorIdCheck, invCust)) {
throw new Error('Tagihan ini di luar wilayah/tugas Anda');
}
}
const existingPending = db.prepare(` const existingPending = db.prepare(`
SELECT id FROM collector_payment_requests SELECT id FROM collector_payment_requests
WHERE invoice_id = ? AND status = 'pending' WHERE invoice_id = ? AND status = 'pending'
@@ -472,6 +499,9 @@ router.get('/invoice/:id/print-thermal', requireCollectorSession, (req, res) =>
const customer = customerSvc.getCustomerById(invoice.customer_id); const customer = customerSvc.getCustomerById(invoice.customer_id);
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan'); if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
if (!customerSvc.canCollectorAccessCustomer(Number(req.session.collectorId || 0), customer)) {
return res.status(403).send('Invoice di luar wilayah tugas Anda');
}
const settings = getSettings(); const settings = getSettings();
res.render('collector/print_thermal', { res.render('collector/print_thermal', {
@@ -498,6 +528,9 @@ router.post('/invoice/:id/send-pdf-wa', requireCollectorSession, async (req, res
const customer = customerSvc.getCustomerById(inv.customer_id); const customer = customerSvc.getCustomerById(inv.customer_id);
if (!customer || !customer.phone) return res.json({ success: false, message: 'Nomor HP pelanggan tidak valid' }); if (!customer || !customer.phone) return res.json({ success: false, message: 'Nomor HP pelanggan tidak valid' });
if (!customerSvc.canCollectorAccessCustomer(Number(req.session.collectorId || 0), customer)) {
return res.json({ success: false, message: 'Invoice di luar wilayah tugas Anda' });
}
const settings = getSettings(); const settings = getSettings();
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings); const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);

View File

@@ -740,6 +740,13 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
const curMonth = now.getMonth() + 1; const curMonth = now.getMonth() + 1;
const curYear = now.getFullYear(); const curYear = now.getFullYear();
const isFullAdmin = req.collector?.role === 'admin' || req.collector?.role === 'root';
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
const collector = collectorId
? db.prepare('SELECT id, name, area, auto_approve FROM collectors WHERE id = ?').get(collectorId)
: null;
const colFilter = (!isFullAdmin && collector) ? customerSvc.getCollectorCustomerWhere(collector, 'c') : null;
let q = ` let q = `
SELECT c.id, c.name, c.phone, c.address, c.status, c.area, c.isolate_day, c.pppoe_username, SELECT c.id, c.name, c.phone, c.address, c.status, c.area, c.isolate_day, c.pppoe_username,
p.name as package_name, p.price as package_price, p.name as package_name, p.price as package_price,
@@ -752,6 +759,11 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
`; `;
const params = [curMonth, curYear]; const params = [curMonth, curYear];
if (colFilter) {
q += ` AND ${colFilter.where}`;
params.push(...colFilter.params);
}
if (filterStatus === 'unpaid') { if (filterStatus === 'unpaid') {
q += ` AND (i.status = 'unpaid' OR i.status IS NULL)`; q += ` AND (i.status = 'unpaid' OR i.status IS NULL)`;
} else if (filterStatus === 'today') { } else if (filterStatus === 'today') {
@@ -770,6 +782,7 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
q += ` ORDER BY (CASE WHEN c.status = 'suspended' THEN 0 WHEN i.status = 'unpaid' THEN 1 ELSE 2 END), c.name ASC LIMIT 150`; q += ` ORDER BY (CASE WHEN c.status = 'suspended' THEN 0 WHEN i.status = 'unpaid' THEN 1 ELSE 2 END), c.name ASC LIMIT 150`;
const list = db.prepare(q).all(...params) || []; const list = db.prepare(q).all(...params) || [];
const statsWhere = colFilter ? ` WHERE ${colFilter.where}` : '';
const stats = db.prepare(` const stats = db.prepare(`
SELECT SELECT
SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) THEN 1 ELSE 0 END) as unpaid_count, SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) THEN 1 ELSE 0 END) as unpaid_count,
@@ -779,7 +792,8 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
FROM customers c FROM customers c
LEFT JOIN packages p ON p.id = c.package_id LEFT JOIN packages p ON p.id = c.package_id
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ? LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
`).get(todayDay, curMonth, curYear) || {}; ${statsWhere}
`).get(todayDay, curMonth, curYear, ...(colFilter ? colFilter.params : [])) || {};
res.json({ res.json({
success: true, success: true,
@@ -819,7 +833,17 @@ router.post('/app/collector/pay-bill', requireCollectorApiAuth, async (req, res)
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' }); if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' });
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0); const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
const collector = collectorId ? db.prepare('SELECT id, name, auto_approve FROM collectors WHERE id = ?').get(collectorId) : null; const collector = collectorId ? db.prepare('SELECT id, name, area, auto_approve FROM collectors WHERE id = ?').get(collectorId) : null;
const isFullAdmin = req.collector?.role === 'admin' || req.collector?.role === 'root';
if (!isFullAdmin && collector) {
const cust = customerSvc.getCustomerById(inv.customer_id);
if (!cust) return res.status(404).json({ success: false, message: 'Pelanggan tidak ditemukan' });
if (!customerSvc.canCollectorAccessCustomer(collector, cust)) {
return res.status(403).json({ success: false, message: 'Akses ditolak: pelanggan di luar wilayah/tugas Anda' });
}
}
const collectorName = collector?.name || req.collector?.name || 'Kolektor Lapangan'; const collectorName = collector?.name || req.collector?.name || 'Kolektor Lapangan';
const isAutoApprove = collector && collector.auto_approve === 1; const isAutoApprove = collector && collector.auto_approve === 1;

View File

@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const sessionLimit = require('../services/sessionLimitService');
const router = express.Router(); const router = express.Router();
const customerDevice = require('../services/customerDeviceService'); const customerDevice = require('../services/customerDeviceService');
const { getSettingsWithCache, getNowLocal, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal } = require('../config/settingsManager'); const { getSettingsWithCache, getNowLocal, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal } = require('../config/settingsManager');
@@ -1791,6 +1792,14 @@ router.post('/login', loginRateLimiter, async (req, res) => {
// --- DIRECT LOGIN --- // --- DIRECT LOGIN ---
logger.info('[Login] Login direct berhasil.'); logger.info('[Login] Login direct berhasil.');
await sessionLimit.rotateSession(req);
sessionLimit.registerLoginSession(req, {
role: 'customer',
accountId: customer ? customer.id : String(customerPhone || '').replace(/\D/g, ''),
limit: 3,
ip: req.ip || req.connection?.remoteAddress || '',
userAgent: req.get('user-agent')
});
req.session.phone = customerPhone; // Nomor telepon untuk findCustomerByAny() req.session.phone = customerPhone; // Nomor telepon untuk findCustomerByAny()
req.session.pppoe_username = pppoeUsername; // PPPoE username untuk GenieACS & MikroTik req.session.pppoe_username = pppoeUsername; // PPPoE username untuk GenieACS & MikroTik
if (customer && customer.status === 'suspended') { if (customer && customer.status === 'suspended') {
@@ -1805,7 +1814,7 @@ router.get('/login-otp', (req, res) => {
res.render('login_otp', { error: null, settings, phone: req.session.pending_login.phone }); res.render('login_otp', { error: null, settings, phone: req.session.pending_login.phone });
}); });
router.post('/login-otp', loginRateLimiter, (req, res) => { router.post('/login-otp', loginRateLimiter, async (req, res) => {
const { otp } = req.body; const { otp } = req.body;
const settings = getSettingsWithCache(); const settings = getSettingsWithCache();
const pending = req.session.pending_login; const pending = req.session.pending_login;
@@ -1824,6 +1833,15 @@ router.post('/login-otp', loginRateLimiter, (req, res) => {
if (otp === pending.otp) { if (otp === pending.otp) {
logger.info('[Login] OTP berhasil diverifikasi.'); logger.info('[Login] OTP berhasil diverifikasi.');
await sessionLimit.rotateSession(req);
const custForLimit = customerSvc.findCustomerByAny(pending.phone);
sessionLimit.registerLoginSession(req, {
role: 'customer',
accountId: custForLimit ? custForLimit.id : String(pending.phone || '').replace(/\D/g, ''),
limit: 3,
ip: req.ip || req.connection?.remoteAddress || '',
userAgent: req.get('user-agent')
});
req.session.phone = pending.phone; // Nomor telepon customer req.session.phone = pending.phone; // Nomor telepon customer
req.session.pppoe_username = pending.pppoeUsername; // PPPoE username untuk GenieACS & MikroTik req.session.pppoe_username = pending.pppoeUsername; // PPPoE username untuk GenieACS & MikroTik
delete req.session.pending_login; delete req.session.pending_login;
@@ -2591,6 +2609,7 @@ router.post('/change-tag', async (req, res) => {
}); });
router.post('/logout', (req, res) => { router.post('/logout', (req, res) => {
sessionLimit.removeLoginSession(req);
req.session.destroy(() => { req.session.destroy(() => {
res.redirect('/customer/login'); res.redirect('/customer/login');
}); });

View File

@@ -1,6 +1,8 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const techSvc = require('../services/techService'); const techSvc = require('../services/techService');
const securityWatchdog = require('../services/securityWatchdogService');
const sessionLimit = require('../services/sessionLimitService');
const customerSvc = require('../services/customerService'); const customerSvc = require('../services/customerService');
const odpSvc = require('../services/odpService'); const odpSvc = require('../services/odpService');
const { getSetting, getNowLocal, getCurrentDateInTimezone, getNowLocalISO, formatDateLocal, getSettings, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager'); const { getSetting, getNowLocal, getCurrentDateInTimezone, getNowLocalISO, formatDateLocal, getSettings, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager');
@@ -140,19 +142,42 @@ router.get('/login', (req, res) => {
res.render('tech/login', { title: 'Teknisi Login', company: company(), error: null }); res.render('tech/login', { title: 'Teknisi Login', company: company(), error: null });
}); });
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => { router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => {
const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1';
if (securityWatchdog.isIpBlocked(ip)) {
return res.render('tech/login', {
title: 'Teknisi Login',
company: company(),
error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.'
});
}
const { username, password } = req.body; const { username, password } = req.body;
const tech = techSvc.authenticate(username, password); const tech = techSvc.authenticate(username, password);
if (tech) { if (tech) {
await securityWatchdog.recordAppLoginAttempt({ type: 'technician', username, ip, success: true });
await sessionLimit.rotateSession(req);
req.session.isTechnician = true; req.session.isTechnician = true;
sessionLimit.registerLoginSession(req, {
role: 'technician', accountId: tech.id, limit: 2,
ip, userAgent: req.get('user-agent')
});
req.session.techId = tech.id; req.session.techId = tech.id;
req.session.techName = tech.name; req.session.techName = tech.name;
return res.redirect('/tech'); return res.redirect('/tech');
} }
await securityWatchdog.recordAppLoginAttempt({
type: 'technician',
username: username || '',
ip,
success: false,
userAgent: req.get('user-agent')
});
res.render('tech/login', { title: 'Teknisi Login', company: company(), error: 'Username atau password salah!' }); res.render('tech/login', { title: 'Teknisi Login', company: company(), error: 'Username atau password salah!' });
}); });
router.get('/logout', (req, res) => { router.get('/logout', (req, res) => {
sessionLimit.removeLoginSession(req);
req.session.destroy(); req.session.destroy();
res.redirect('/tech/login'); res.redirect('/tech/login');
}); });
@@ -176,7 +201,7 @@ router.get('/', requireTechSession, (req, res) => {
// --- OPEN TICKETS (Pool) --- // --- OPEN TICKETS (Pool) ---
router.get('/pool', requireTechSession, (req, res) => { router.get('/pool', requireTechSession, (req, res) => {
const openTickets = techSvc.getOpenTickets(); const openTickets = techSvc.getOpenTickets(req.session.techId);
res.render('tech/pool', { res.render('tech/pool', {
title: 'Tiket Baru', title: 'Tiket Baru',
company: company(), company: company(),
@@ -201,8 +226,15 @@ router.get('/history', requireTechSession, (req, res) => {
// --- NETWORK MAP --- // --- NETWORK MAP ---
router.get('/map', requireTechSession, (req, res) => { router.get('/map', requireTechSession, (req, res) => {
const customers = customerSvc.getAllCustomers(); const tech = techSvc.getTechById(req.session.techId);
const odps = odpSvc.getAllOdps(); let customers = customerSvc.getAllCustomers();
let odps = odpSvc.getAllOdps();
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
if (filter && filter.isRestricted) {
customers = customers.filter(c => filter.areas.includes(String(c.area || '').trim().toLowerCase()));
odps = odps.filter(o => !o.area || filter.areas.includes(String(o.area || '').trim().toLowerCase()));
}
res.render('tech/map', { res.render('tech/map', {
title: 'Peta Jaringan', title: 'Peta Jaringan',
@@ -346,7 +378,12 @@ router.get('/monitoring', requireTechSession, async (req, res) => {
// --- CREATE CUSTOMER (Technician) --- // --- CREATE CUSTOMER (Technician) ---
router.get('/customers/new', requireTechSession, (req, res) => { router.get('/customers/new', requireTechSession, (req, res) => {
const packages = customerSvc.getAllPackages(); const packages = customerSvc.getAllPackages();
const odps = odpSvc.getAllOdps(); let odps = odpSvc.getAllOdps();
const tech = techSvc.getTechById(req.session.techId);
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
if (filter && filter.isRestricted) {
odps = odps.filter(o => !o.area || filter.areas.includes(String(o.area || '').trim().toLowerCase()));
}
const routers = mikrotikService.getAllRouters(); const routers = mikrotikService.getAllRouters();
const olts = oltSvc.getAllOlts(); const olts = oltSvc.getAllOlts();
res.render('tech/create_customer', { res.render('tech/create_customer', {
@@ -366,8 +403,20 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (!name) throw new Error('Nama pelanggan wajib diisi'); if (!name) throw new Error('Nama pelanggan wajib diisi');
const tech = techSvc.getTechById(req.session.techId);
const areaFilter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
let areaInput = String(req.body.area || '').trim();
if (areaFilter && areaFilter.isRestricted) {
if (!areaInput) {
areaInput = areaFilter.areas[0];
} else if (!areaFilter.areas.includes(areaInput.toLowerCase())) {
throw new Error(`Anda hanya dapat mendaftarkan pelanggan di wilayah tugas Anda (${tech.area})`);
}
}
const customerData = { const customerData = {
name, name,
area: areaInput,
phone: String(req.body.phone || '').trim(), phone: String(req.body.phone || '').trim(),
email: String(req.body.email || '').trim(), email: String(req.body.email || '').trim(),
nik: String(req.body.nik || '').trim(), nik: String(req.body.nik || '').trim(),
@@ -461,6 +510,16 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
// API Endpoints for Technician // API Endpoints for Technician
const customerDevice = require('../services/customerDeviceService'); const customerDevice = require('../services/customerDeviceService');
// Teknisi hanya boleh menyentuh perangkat pelanggan di wilayahnya.
function checkTechTagAccess(req, tag) {
const tech = req.session?.techId ? techSvc.getTechById(req.session.techId) : null;
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
if (!filter || !filter.isRestricted) return true;
const cust = customerSvc.findCustomerByAny(tag);
if (!cust) return true;
return filter.areas.includes(String(cust.area || '').trim().toLowerCase());
}
router.get('/api/mikrotik/pppoe-users', requireTechSession, async (req, res) => { router.get('/api/mikrotik/pppoe-users', requireTechSession, async (req, res) => {
try { try {
const routerId = req.query.routerId ? Number(req.query.routerId) : null; const routerId = req.query.routerId ? Number(req.query.routerId) : null;
@@ -499,10 +558,15 @@ router.get('/api/odps/:id/ports', requireTechSession, (req, res) => {
router.get('/api/devices', requireTechSession, async (req, res) => { router.get('/api/devices', requireTechSession, async (req, res) => {
try { try {
const { search, status, acs, limit = 999999, offset = 0 } = req.query; const { search, status, acs, limit = 999999, offset = 0 } = req.query;
const customers = db.prepare('SELECT id, name, phone, pppoe_username, genieacs_tag FROM customers').all(); const tech = techSvc.getTechById(req.session.techId);
const areaFilter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
const customers = db.prepare('SELECT id, name, phone, pppoe_username, genieacs_tag, area FROM customers').all();
const allowedCustomerIds = new Set();
const byPppoe = new Map(); const byPppoe = new Map();
const byTag = new Map(); const byTag = new Map();
for (const c of customers) { for (const c of customers) {
if (areaFilter && areaFilter.isRestricted && !areaFilter.areas.includes(String(c.area || '').trim().toLowerCase())) continue;
allowedCustomerIds.add(c.id);
const pu = String(c.pppoe_username || '').trim().toLowerCase(); const pu = String(c.pppoe_username || '').trim().toLowerCase();
const tg = String(c.genieacs_tag || '').trim(); const tg = String(c.genieacs_tag || '').trim();
if (pu) byPppoe.set(pu, c); if (pu) byPppoe.set(pu, c);
@@ -549,6 +613,10 @@ router.get('/api/devices', requireTechSession, async (req, res) => {
}; };
}); });
if (areaFilter && areaFilter.isRestricted) {
devices = devices.filter(d => d.customerId && allowedCustomerIds.has(d.customerId));
}
if (search) { if (search) {
const s = search.toLowerCase(); const s = search.toLowerCase();
devices = devices.filter(d => devices = devices.filter(d =>
@@ -575,6 +643,7 @@ router.get('/api/devices', requireTechSession, async (req, res) => {
router.get('/api/device/:tag', requireTechSession, async (req, res) => { router.get('/api/device/:tag', requireTechSession, async (req, res) => {
try { try {
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
const data = await customerDevice.getCustomerDeviceData(req.params.tag); const data = await customerDevice.getCustomerDeviceData(req.params.tag);
if (!data || data.status === 'Tidak ditemukan') return res.status(404).json({ error: 'Device not found' }); if (!data || data.status === 'Tidak ditemukan') return res.status(404).json({ error: 'Device not found' });
res.json(data); res.json(data);
@@ -584,6 +653,7 @@ router.get('/api/device/:tag', requireTechSession, async (req, res) => {
}); });
router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (req, res) => { router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (req, res) => {
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
const { ssid } = req.body; const { ssid } = req.body;
if (!ssid) return res.status(400).json({ error: 'SSID required' }); if (!ssid) return res.status(400).json({ error: 'SSID required' });
const ok = await customerDevice.updateSSID(req.params.tag, ssid); const ok = await customerDevice.updateSSID(req.params.tag, ssid);
@@ -616,6 +686,7 @@ router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (
}); });
router.post('/api/device/:tag/password', requireTechSession, express.json(), async (req, res) => { router.post('/api/device/:tag/password', requireTechSession, express.json(), async (req, res) => {
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
const { password } = req.body; const { password } = req.body;
if (!password || password.length < 8) return res.status(400).json({ error: 'Password minimal 8 karakter' }); if (!password || password.length < 8) return res.status(400).json({ error: 'Password minimal 8 karakter' });
const ok = await customerDevice.updatePassword(req.params.tag, password); const ok = await customerDevice.updatePassword(req.params.tag, password);
@@ -648,6 +719,7 @@ router.post('/api/device/:tag/password', requireTechSession, express.json(), asy
}); });
router.post('/api/device/:tag/reboot', requireTechSession, async (req, res) => { router.post('/api/device/:tag/reboot', requireTechSession, async (req, res) => {
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
const result = await customerDevice.requestReboot(req.params.tag); const result = await customerDevice.requestReboot(req.params.tag);
res.json(result); res.json(result);
}); });

587
services/adminAiTools.js Normal file
View File

@@ -0,0 +1,587 @@
const db = require('../config/database');
const customerSvc = require('./customerService');
const billingSvc = require('./billingService');
const mikrotikService = require('./mikrotikService');
const customerDevice = require('./customerDeviceService');
const agentSvc = require('./agentService');
const { getSetting, getCurrentDateInTimezone } = require('../config/settingsManager');
const { logger } = require('../config/logger');
/**
* Deklarasi Tools (Function Calling) untuk Google Gemini API
*/
const adminToolDeclarations = [
{
name: 'add_customer',
description: 'Mendaftarkan / menambah pelanggan internet baru ke database billing dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
name: { type: 'STRING', description: 'Nama lengkap pelanggan' },
phone: { type: 'STRING', description: 'Nomor WhatsApp atau telepon pelanggan' },
packageName: { type: 'STRING', description: 'Nama paket langganan (misal: BRONZE, SILVER, GOLD, dll)' },
area: { type: 'STRING', description: 'Nama area / dusun / lokasi pelanggan (opsional)' },
address: { type: 'STRING', description: 'Alamat lengkap rumah pelanggan (opsional)' },
pppoeUsername: { type: 'STRING', description: 'Username akun PPPoE untuk dial MikroTik (opsional)' },
pppoePassword: { type: 'STRING', description: 'Password akun PPPoE (opsional)' }
},
required: ['name', 'phone']
}
},
{
name: 'delete_customer_request',
description: 'Meminta konfirmasi penghapusan data pelanggan secara permanen dari sistem',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, nomor WhatsApp, atau username PPPoE yang ingin dihapus' }
},
required: ['target']
}
},
{
name: 'check_modem_status',
description: 'Mengecek kondisi modem ONU, daya redaman optik sinyal (RX Power), status PPPoE, dan uptime pelanggan via GenieACS dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, nomor telepon, akun PPPoE, atau tag modem ONU yang ingin dicek' }
}
}
},
{
name: 'reboot_onu',
description: 'Me-reboot / restart modem ONU pelanggan dari jarak jauh via GenieACS TR-069',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID, no HP, akun PPPoE, atau tag ONU' }
},
required: ['target']
}
},
{
name: 'update_wifi_password',
description: 'Mengubah kata sandi WiFi modem ONU pelanggan via GenieACS TR-069',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, no HP, akun PPPoE, atau tag ONU' },
newPassword: { type: 'STRING', description: 'Kata sandi WiFi baru (minimal 8 karakter)' }
},
required: ['target', 'newPassword']
}
},
{
name: 'update_wifi_ssid',
description: 'Mengubah nama SSID WiFi modem ONU pelanggan via GenieACS TR-069',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, no HP, akun PPPoE, atau tag ONU' },
newSsid: { type: 'STRING', description: 'Nama SSID WiFi baru' }
},
required: ['target', 'newSsid']
}
},
{
name: 'mark_invoice_paid',
description: 'Menandai lunas / melunasi tagihan invoice pelanggan dan cabut isolir jika ada',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'ID invoice (angka), nama pelanggan, no HP, atau username PPPoE' }
},
required: ['target']
}
},
{
name: 'isolate_customer',
description: 'Mengisolir / suspend / memutus sementara layanan internet pelanggan di billing dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, no HP, atau username PPPoE' }
},
required: ['target']
}
},
{
name: 'unisolate_customer',
description: 'Membuka isolir / mengaktifkan kembali layanan internet pelanggan di billing dan MikroTik',
parameters: {
type: 'OBJECT',
properties: {
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, no HP, atau username PPPoE' }
},
required: ['target']
}
},
{
name: 'kick_pppoe_user',
description: 'Memutus paksa / kick sesi active PPPoE atau Hotspot pelanggan di router MikroTik',
parameters: {
type: 'OBJECT',
properties: {
username: { type: 'STRING', description: 'Username PPPoE atau nama pelanggan' }
},
required: ['username']
}
},
{
name: 'create_hotspot_voucher',
description: 'Membuat / generate voucher hotspot baru di router MikroTik (Mikhmon profile)',
parameters: {
type: 'OBJECT',
properties: {
profile: { type: 'STRING', description: 'Nama profil paket voucher (misal: 1jam, 5jam, 24jam, dll)' },
count: { type: 'INTEGER', description: 'Jumlah voucher yang ingin dibuat (opsional, default 1)' }
},
required: ['profile']
}
},
{
name: 'generate_invoices',
description: 'Menjalankan generate invoice tagihan bulanan seluruh pelanggan',
parameters: {
type: 'OBJECT',
properties: {
month: { type: 'INTEGER', description: 'Bulan (1-12)' },
year: { type: 'INTEGER', description: 'Tahun (misal: 2026)' }
},
required: ['month', 'year']
}
},
{
name: 'check_digiflazz_balance',
description: 'Mengecek sisa saldo deposit Digiflazz (PPOB / Pulsa) Admin',
parameters: {
type: 'OBJECT',
properties: {}
}
},
{
name: 'topup_agent',
description: 'Mentransfer / top up saldo agen reseller voucher atau pembayaran',
parameters: {
type: 'OBJECT',
properties: {
agentKey: { type: 'STRING', description: 'Username agent, nama agent, no HP, atau ID agent' },
amount: { type: 'INTEGER', description: 'Nominal saldo yang ditransfer (angka dalam Rupiah)' },
note: { type: 'STRING', description: 'Catatan transfer (opsional)' }
},
required: ['agentKey', 'amount']
}
},
{
name: 'auto_heal_network',
description: 'Menjalankan diagnosa dan perbaikan mandiri otomatis (Self-Healing) pada seluruh jaringan, router MikroTik (sinkronisasi secret PPPoE & isolir), status pelanggan billing, dan sesi yang macet',
parameters: {
type: 'OBJECT',
properties: {
mode: { type: 'STRING', description: 'Mode perbaikan: "repair" untuk langsung memperbaiki otomatis, atau "scan" untuk diagnosa saja (opsional, default: "repair")' }
}
}
},
{
name: 'check_security_status',
description: 'Mengecek status keamanan sistem, apakah ada anomali serangan brute force pada portal login aplikasi maupun router MikroTik, serta IP penyerang yang diblokir',
parameters: {
type: 'OBJECT',
properties: {}
}
}
];
/**
* Helper untuk mencari pelanggan secara fleksibel (Nama, HP, PPPoE, ID, Tag)
*/
function findCustomerHelper(query) {
if (!query) return null;
const q = String(query).trim();
let cust = customerSvc.findCustomerByAny(q);
if (cust) return cust;
const byExactName = db.prepare('SELECT id FROM customers WHERE LOWER(name) = LOWER(?) LIMIT 1').get(q);
if (byExactName) return customerSvc.getCustomerById(byExactName.id);
const candidates = customerSvc.getAllCustomers(q) || [];
if (candidates.length === 1) return customerSvc.getCustomerById(candidates[0].id);
if (candidates.length > 1) {
const exact = candidates.find(c => c.name.toLowerCase() === q.toLowerCase());
if (exact) return customerSvc.getCustomerById(exact.id);
return customerSvc.getCustomerById(candidates[0].id);
}
return null;
}
/**
* Eksekutor Tool / Fungsi Perintah WhatsApp Admin
*/
async function executeAdminTool(toolName, args, extraContext = {}) {
const { reply, sock, lidStore } = extraContext;
logger.info(`[Admin AI Tool] Memulai eksekusi tool "${toolName}" dengan args: ${JSON.stringify(args)}`);
try {
switch (toolName) {
// 1. Tambah Pelanggan
case 'add_customer': {
const { name, phone, packageName, area, address, pppoeUsername, pppoePassword } = args;
if (!name || !phone) {
return 'Mohon sebutkan minimal nama dan nomor WhatsApp pelanggan yang ingin didaftarkan.';
}
const allPkgs = customerSvc.getAllPackages();
let pkg = null;
if (packageName) {
pkg = allPkgs.find(p => p.name.toLowerCase() === packageName.toLowerCase().trim()) ||
allPkgs.find(p => p.name.toLowerCase().includes(packageName.toLowerCase().trim()));
}
if (!pkg && allPkgs.length > 0) {
pkg = allPkgs[0];
}
if (!pkg) {
return 'Mohon maaf, saat ini belum ada paket layanan internet yang aktif di database.';
}
let cleanPppoe = (pppoeUsername || name.toLowerCase().replace(/[^a-z0-9]/g, '')).trim();
if (!cleanPppoe) cleanPppoe = 'user' + Math.floor(1000 + Math.random() * 9000);
const existing = db.prepare('SELECT id FROM customers WHERE pppoe_username = ?').get(cleanPppoe);
if (existing) {
cleanPppoe += Math.floor(10 + Math.random() * 89);
}
const cleanPass = (pppoePassword || cleanPppoe).trim();
const routerRow = db.prepare('SELECT id FROM routers WHERE is_active = 1 LIMIT 1').get();
const routerId = routerRow ? routerRow.id : null;
const today = new Date().toISOString().slice(0, 10);
const newCustData = {
name: name.trim(),
phone: String(phone).replace(/\D/g, ''),
email: '',
address: address ? String(address).trim() : '',
area: area ? String(area).trim() : '',
package_id: pkg.id,
router_id: routerId,
connection_type: 'pppoe',
pppoe_username: cleanPppoe,
pppoe_password: cleanPass,
status: 'active',
install_date: today,
auto_isolate: 1,
isolate_day: 10
};
const res = customerSvc.createCustomer(newCustData);
const newId = res.lastInsertRowid;
if (routerId) {
try {
await mikrotikService.createPppoeSecret({
username: cleanPppoe,
password: cleanPass,
profile: pkg.name,
routerId: routerId
});
} catch (err) {
logger.warn(`[Admin AI Tool] Buat secret mikrotik: ${err.message}`);
}
}
try {
billingSvc.ensureCurrentInvoice(newId);
} catch (_) {}
return `Pelanggan baru atas nama ${name} dengan nomor WhatsApp ${newCustData.phone} berhasil didaftarkan dengan paket ${pkg.name}. Akun internet PPPoE dan tagihan periode berjalan sudah dibuatkan secara otomatis.`;
}
// 2. Hapus Pelanggan (Permintaan Konfirmasi)
case 'delete_customer_request': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin dihapus.';
const cust = findCustomerHelper(target);
if (!cust) {
return `Pelanggan atas nama ${target} tidak ditemukan di sistem. Silakan pastikan nama atau nomornya sudah sesuai.`;
}
return `Apakah Anda yakin ingin menghapus data pelanggan ${cust.name}? Tindakan ini bersifat permanen. Jika setuju, silakan konfirmasi dengan ucapan: ya hapus ${cust.id}.`;
}
// 3. Cek Status Modem & Redaman
case 'check_modem_status': {
const { target } = args || {};
if (!target || target.toLowerCase() === 'semua' || target.toLowerCase() === 'general') {
return 'Secara umum kondisi jaringan dan modem pelanggan yang aktif terpantau normal dan lancar. Jika Anda ingin mengecek kondisi modem pelanggan tertentu, silakan sebutkan nama pelanggannya.';
}
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
let onuData = null;
try {
onuData = await Promise.race([
customerDevice.getCustomerDeviceData(tagToken),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 4000))
]).catch(() => null);
} catch (_) {}
const pppoeUser = cust?.pppoe_username ? cust.pppoe_username.toLowerCase() : '';
let pppoeSession = null;
if (pppoeUser) {
try {
const activeSessions = await mikrotikService.getActivePppoeUsers();
pppoeSession = (activeSessions || []).find(s => (s.user || '').toLowerCase() === pppoeUser);
} catch (_) {}
}
const name = cust ? cust.name : target;
if (!onuData && !pppoeSession) {
return `Perangkat modem untuk pelanggan ${name} saat ini belum terdeteksi aktif di sistem GenieACS maupun router MikroTik. Pastikan modem telah menyala dan kabel fiber optik terhubung dengan baik.`;
}
let redamanDesc = '';
if (onuData?.rxPower) {
const rx = parseFloat(onuData.rxPower);
if (!isNaN(rx)) {
if (rx > -23) {
redamanDesc = `Kualitas sinyal redaman optiknya sangat bagus di minus ${Math.abs(rx)} desibel.`;
} else if (rx >= -27) {
redamanDesc = `Kualitas sinyal redaman optiknya cukup baik di minus ${Math.abs(rx)} desibel.`;
} else {
redamanDesc = `Kualitas sinyal redaman optiknya agak lemah di minus ${Math.abs(rx)} desibel, perlu diperhatikan.`;
}
} else {
redamanDesc = `Kualitas daya sinyal tercatat ${onuData.rxPower}.`;
}
}
let pppoeDesc = pppoeSession ? 'Koneksi internet terpantau online lancar.' : 'Akun internet terpantau belum dial up ke router.';
return `Kondisi modem milik ${name} saat ini terpantau normal dan aktif. ${redamanDesc} ${pppoeDesc}`.trim();
}
// 4. Reboot Modem ONU
case 'reboot_onu': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau tag modem yang ingin dinyalakan ulang.';
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
const targetDevice = await customerDevice.resolveDeviceToken(tagToken);
if (!targetDevice) {
return `Perangkat modem untuk ${target} tidak ditemukan di GenieACS.`;
}
await customerDevice.requestReboot(tagToken);
return `Perintah restart untuk modem milik ${cust ? cust.name : target} berhasil dikirim. Modem akan mulai menyala ulang dalam beberapa saat.`;
}
// 5. Update Password WiFi
case 'update_wifi_password': {
const { target, newPassword } = args;
if (!target || !newPassword) return 'Mohon sebutkan nama pelanggan dan kata sandi wifi baru.';
if (String(newPassword).length < 8) return 'Kata sandi wifi baru minimal harus delapan karakter.';
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
const ok = await customerDevice.updatePassword(tagToken, String(newPassword).trim());
if (ok) {
return `Kata sandi wifi untuk pelanggan ${cust ? cust.name : target} berhasil diperbarui menjadi ${newPassword}. Pengaturan sudah diterapkan ke modem.`;
} else {
return `Gagal mengubah kata sandi wifi untuk ${target}. Pastikan modem pelanggan sedang dalam kondisi online.`;
}
}
// 6. Update SSID WiFi
case 'update_wifi_ssid': {
const { target, newSsid } = args;
if (!target || !newSsid) return 'Mohon sebutkan nama pelanggan dan nama wifi baru.';
const cust = findCustomerHelper(target);
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
const ok = await customerDevice.updateSSID(tagToken, String(newSsid).trim());
if (ok) {
return `Nama wifi untuk pelanggan ${cust ? cust.name : target} berhasil diganti menjadi ${newSsid}. Perangkat modem sudah diperbarui.`;
} else {
return `Gagal mengubah nama wifi untuk ${target}. Pastikan modem pelanggan sedang aktif terhubung.`;
}
}
// 7. Melunasi Tagihan
case 'mark_invoice_paid': {
const { target } = args;
if (!target) return 'Mohon sebutkan ID invoice atau nama pelanggan yang tagihannya ingin dilunasi.';
const { executeAdminLunas } = await import('./whatsappBot.mjs');
let capturedReply = '';
const localReply = async (msg) => { capturedReply = msg; };
await executeAdminLunas(target, localReply, sock, lidStore);
return `Tagihan untuk ${target} telah berhasil dilunasi di sistem dan layanan sudah aktif normal.`;
}
// 8. Isolir Pelanggan
case 'isolate_customer': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin diisolir.';
const { executeAdminIsolir } = await import('./whatsappBot.mjs');
let capturedReply = '';
const localReply = async (msg) => { capturedReply = msg; };
await executeAdminIsolir(target, localReply);
return `Layanan internet untuk pelanggan ${target} telah berhasil diisolir sementara.`;
}
// 9. Buka Isolir Pelanggan
case 'unisolate_customer': {
const { target } = args;
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin dibuka isolirnya.';
const { executeAdminBuka } = await import('./whatsappBot.mjs');
let capturedReply = '';
const localReply = async (msg) => { capturedReply = msg; };
await executeAdminBuka(target, localReply);
return `Isolir untuk pelanggan ${target} telah berhasil dibuka. Koneksi internet kini sudah normal kembali.`;
}
// 10. Kick PPPoE / Hotspot User
case 'kick_pppoe_user': {
const { username } = args;
if (!username) return 'Mohon sebutkan nama pengguna yang ingin diputus sesinya.';
let targetUser = username.trim();
const cust = findCustomerHelper(username);
if (cust && cust.pppoe_username) {
targetUser = cust.pppoe_username;
}
const pk = await mikrotikService.kickPppoeUser(targetUser);
const hk = await mikrotikService.kickHotspotUser(targetUser);
if (pk || hk) {
return `Sesi aktif untuk ${targetUser} telah berhasil diputus dari router. Perangkat akan terhubung kembali secara otomatis.`;
} else {
return `Sesi aktif untuk ${targetUser} tidak ditemukan di router.`;
}
}
// 11. Buat Voucher Hotspot
case 'create_hotspot_voucher': {
const { profile, count } = args;
if (!profile) return 'Mohon sebutkan nama profil paket voucher hotspot yang ingin dibuat.';
const numVouchers = Math.min(Math.max(parseInt(count, 10) || 1, 1), 10);
const created = [];
const now = getCurrentDateInTimezone();
const dateStr = `${String(now.getDate()).padStart(2, '0')}/${String(now.getMonth() + 1).padStart(2, '0')}/${now.getFullYear()}`;
for (let i = 0; i < numVouchers; i++) {
const code = Math.floor(1000 + Math.random() * 9000).toString();
const comment = `vc ${code} ${dateStr} AI`;
await mikrotikService.addHotspotUser({
name: code,
password: code,
profile: profile,
comment: comment
});
created.push(code);
}
return `Sebanyak ${created.length} voucher hotspot dengan paket ${profile} telah berhasil dibuat. Kode voucher pertama adalah ${created[0]}.`;
}
// 12. Generate Tagihan Bulanan
case 'generate_invoices': {
const { month, year } = args;
const m = parseInt(month, 10) || (new Date().getMonth() + 1);
const y = parseInt(year, 10) || new Date().getFullYear();
const r = await billingSvc.generateMonthlyInvoices(m, y);
return `Pembuatan tagihan bulanan untuk periode bulan ${m} tahun ${y} telah selesai. Sebanyak ${r?.created || 0} tagihan baru berhasil diproses.`;
}
// 13. Cek Saldo Digiflazz
case 'check_digiflazz_balance': {
const r = await agentSvc.digiflazzCheckBalance();
const dep = Number(r?.deposit || 0).toLocaleString('id-ID');
return `Sisa saldo deposit Digiflazz Anda saat ini tercatat sebesar ${dep} rupiah dan berstatus aktif.`;
}
// 14. Top Up Saldo Agent
case 'topup_agent': {
const { agentKey, amount, note } = args;
const nom = Number(String(amount || '').replace(/[^\d]/g, ''));
if (!nom || nom <= 0) return 'Nominal top up harus berupa angka lebih dari nol.';
const rawKey = String(agentKey || '').trim();
let agent = agentSvc.getAgentById(parseInt(rawKey, 10)) ||
agentSvc.getAgentByUsername(rawKey) ||
agentSvc.getAgentByPhone(rawKey);
if (!agent) {
const all = agentSvc.getAllAgents();
agent = all.find(a => String(a.name || '').toLowerCase().includes(rawKey.toLowerCase()));
}
if (!agent) return `Data agen ${agentKey} tidak ditemukan di sistem.`;
const r = agentSvc.topupAgent(agent.id, nom, note || 'Topup via Voice AI', 'Admin Voice AI');
return `Top up saldo untuk agen ${agent.name} sebesar ${nom.toLocaleString('id-ID')} rupiah telah berhasil diproses. Saldo saat ini menjadi ${Number(r.after || 0).toLocaleString('id-ID')} rupiah.`;
}
// 15. Self-Healing & Network Auto-Repair
case 'auto_heal_network': {
const { mode } = args || {};
const selfHealingSvc = require('./selfHealingService');
const isRepair = String(mode || 'repair').toLowerCase() !== 'scan';
const res = await (isRepair ? selfHealingSvc.autoRepair('Admin AI Command') : selfHealingSvc.diagnose());
const found = res.stats.anomaliesFound;
const fixed = res.stats.anomaliesRepaired;
const routers = res.stats.routersChecked;
if (found === 0) {
return `Pemeriksaan mandiri selesai. Seluruh sistem billing, status pelanggan, dan ${routers} router MikroTik terpantau normal dan tersinkronisasi sempurna tanpa kendala.`;
}
if (isRepair) {
const repairList = res.repairs.slice(0, 3).map(r => r.replace(/\[[^\]]+\]\s*/, '')).join('. ');
return `Proses perbaikan mandiri selesai. Sistem mendeteksi ${found} anomali dan berhasil memperbaiki ${fixed} masalah secara otomatis: ${repairList}.`;
} else {
return `Pemeriksaan sistem mendeteksi ${found} anomali yang perlu disinkronkan. Anda dapat memerintahkan perbaikan dengan ucapan: ai perbaiki jaringan.`;
}
}
// 16. Cek Status Keamanan & Brute Force Anomaly
case 'check_security_status': {
const securityWatchdog = require('./securityWatchdogService');
const overview = securityWatchdog.getSecurityOverview();
const blockedCount = overview.activeBlockedIps ? overview.activeBlockedIps.length : 0;
const recentAlerts = overview.recentAlerts || [];
if (blockedCount === 0 && recentAlerts.length === 0) {
return 'Status keamanan sistem saat ini aman dan terkendali. Tidak terdeteksi adanya anomali serangan brute force pada portal login aplikasi maupun router MikroTik.';
}
let resp = `Laporan keamanan sistem: Saat ini status sistem adalah ${overview.status === 'warning' ? 'waspada' : 'aman'}. `;
if (blockedCount > 0) {
const ipList = overview.activeBlockedIps.map(b => `${b.ip} (tersisa ${b.remainingSeconds} detik)`).join(', ');
resp += `Terdapat ${blockedCount} alamat i pe yang sedang diblokir sementara karena percobaan login berulang kali: ${ipList}. `;
}
if (recentAlerts.length > 0) {
resp += `Tercatat ${recentAlerts.length} riwayat insiden keamanan terbaru pada audit sistem.`;
}
return resp;
}
default:
return `Fungsi ${toolName} belum tersedia di sistem.`;
}
} catch (err) {
logger.error(`[Admin AI Tool] Error saat eksekusi ${toolName}: ${err.message}`);
return `Mohon maaf, terjadi kendala saat memproses permintaan: ${err.message}`;
}
}
module.exports = {
adminToolDeclarations,
executeAdminTool
};

View File

@@ -397,6 +397,19 @@ function markAsPaid(invoiceId, paidByName, notes, actor = null) {
UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=? AND status != 'paid' UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=? AND status != 'paid'
`).run(paidByName || 'Admin', finalNotes, invId); `).run(paidByName || 'Admin', finalNotes, invId);
if (result.changes > 0) {
try {
const soundboxSvc = require('./soundboxService');
const cust = db.prepare('SELECT name FROM customers WHERE id=?').get(existing.customer_id);
soundboxSvc.queueSoundboxEvent({
type: 'invoice',
title: 'Tagihan #' + invId,
amount: existing.amount,
customerName: cust?.name || 'Pelanggan'
});
} catch (e) { /* soundbox opsional */ }
}
if (result.changes > 0 && existing.customer_id) { if (result.changes > 0 && existing.customer_id) {
renewCustomerPrepaidValidity(existing.customer_id, 1); renewCustomerPrepaidValidity(existing.customer_id, 1);
try { db.prepare("UPDATE customers SET unpaid_streak = 0, deactivate_cause = '', deactivated_at = NULL WHERE id = ?").run(existing.customer_id); } catch (e) {} try { db.prepare("UPDATE customers SET unpaid_streak = 0, deactivate_cause = '', deactivated_at = NULL WHERE id = ?").run(existing.customer_id); } catch (e) {}

View File

@@ -808,6 +808,33 @@ function startCronJobs() {
} catch (e) { logger.error('[CRON] Retensi notif webhook error: ' + e.message); } } catch (e) { logger.error('[CRON] Retensi notif webhook error: ' + e.message); }
}, { timezone: getSetting('timezone', 'Asia/Jakarta') }); }, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// AI Self-Healing & Network Auto-Repair (tiap 30 menit, bisa dimatikan dari Pengaturan)
cron.schedule('*/30 * * * *', async () => {
const enabled = getSetting('ai_self_healing_enabled', false);
if (enabled !== true && enabled !== 'true') return;
try {
const selfHealingSvc = require('./selfHealingService');
const res = await selfHealingSvc.autoRepair('AI Autonomous Watchdog');
if (res && res.stats && res.stats.anomaliesRepaired > 0) {
logger.info('[CRON-AI-HEAL] Memperbaiki ' + res.stats.anomaliesRepaired + ' anomali: ' + (res.repairs || []).join('; '));
}
} catch (e) {
logger.error('[CRON-AI-HEAL] Error: ' + e.message);
}
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
// Security Watchdog - scan login gagal MikroTik (tiap 2 menit)
cron.schedule('*/2 * * * *', async () => {
try {
const securityWatchdog = require('./securityWatchdogService');
if (typeof securityWatchdog.scanMikrotikLoginFailures === 'function') {
await securityWatchdog.scanMikrotikLoginFailures();
}
} catch (e) {
logger.error('[CRON-SECURITY-WATCHDOG] Error: ' + e.message);
}
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
logger.info('[CRON] Semua tugas penjadwalan telah aktif.'); logger.info('[CRON] Semua tugas penjadwalan telah aktif.');
} }

View File

@@ -71,6 +71,59 @@ function calculateExpiredAt(startDateStr, durationDays = 30) {
return `${y}-${m}-${day} 23:59:59`; return `${y}-${m}-${day} 23:59:59`;
} }
/**
* Batas wilayah tugas kolektor (dukung multi-area dipisah koma).
* area kosong / "semua" / "all" = tanpa batas wilayah.
*/
function getCollectorCustomerWhere(collector, tableAlias = 'c') {
let col = collector;
if (typeof collector === 'number' || typeof collector === 'string') {
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
}
const colId = Number(col?.id || 0);
const areas = String(col?.area || '')
.split(',')
.map(a => a.trim().toLowerCase())
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
if (areas.length > 0) {
const placeholders = areas.map(() => '?').join(', ');
return {
where: `(LOWER(TRIM(${tableAlias}.area)) IN (${placeholders}) AND (${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0))`,
params: [...areas, colId],
isRestricted: true,
areas,
};
}
return {
where: `(${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0)`,
params: [colId],
isRestricted: false,
areas: [],
};
}
function canCollectorAccessCustomer(collector, customer) {
if (!collector || !customer) return false;
let col = collector;
if (typeof collector === 'number' || typeof collector === 'string') {
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
}
const colId = Number(col?.id || 0);
const custColId = Number(customer?.collector_id || 0);
if (custColId > 0 && custColId !== colId) return false;
const areas = String(col?.area || '')
.split(',')
.map(a => a.trim().toLowerCase())
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
if (areas.length > 0) {
const custArea = String(customer?.area || '').trim().toLowerCase();
return !!custArea && areas.includes(custArea);
}
return true;
}
function getAllCustomers(search = '', routerId = null, filterStatus = '', filterArea = '', opts = {}) { function getAllCustomers(search = '', routerId = null, filterStatus = '', filterArea = '', opts = {}) {
const now = getCurrentDateInTimezone(); const now = getCurrentDateInTimezone();
const month = now.getMonth() + 1; const month = now.getMonth() + 1;
@@ -955,7 +1008,7 @@ Dihapus: ${removed.join(', ') || '-'}`);
} }
module.exports = { module.exports = {
getAllCustomers, getAllCustomerAreas, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats, getAllCustomers, getAllCustomerAreas, getCollectorCustomerWhere, canCollectorAccessCustomer, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
getAllPackages, getPackageById, createPackage, updatePackage, deletePackage, getAllPackages, getPackageById, createPackage, updatePackage, deletePackage,
suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath, suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath,
resetPromoCyclesUsed, getEffectiveRouterId resetPromoCyclesUsed, getEffectiveRouterId

View File

@@ -1,42 +1,111 @@
/** /**
* Service: Manajemen Inventaris / Gudang (Warehouse) * Service: Manajemen Inventaris / Gudang (Warehouse)
* Sistem Pelacakan Stok, Pemasukan, Pengeluaran & Sisa Stok Terintegrasi
*/ */
const db = require('../config/database'); const db = require('../config/database');
const { logger } = require('../config/logger'); const { logger } = require('../config/logger');
// ─── DEFAULT CATEGORIES SEEDER ───────────────────────────────────────────
function seedDefaultCategories() {
try {
const count = db.prepare('SELECT count(*) as c FROM inventory_categories').get().c;
if (count === 0) {
const defaults = [
['Router & ONT', 'Modem ONT GPON/EPON, Router WiFi, Access Point, Wireless Bridge'],
['Kabel & Drop Core', 'Kabel Fiber Optic Drop Core 1-2 Core, Kabel LAN UTP Cat5e/Cat6'],
['Aksesoris Fiber Optic', 'Patchcord, Pigtail, Adapter SC, ODP, OTB, Roset FO, Joint Closure, Protection Sleeve'],
['Aksesoris LAN & Jaringan', 'Konektor RJ45, Switch Hub, Barrel LAN, Faceplate, Modular Jack'],
['Aksesoris Tiang & Outdoor', 'Klem Buaya, Span Wartel, Hook S, Stainless Band, Bracket Tiang'],
['Power & Adaptor', 'Adaptor 12V 1A/2A, POE Injector, UPS Mini DC, Kabel Power']
];
const insert = db.prepare('INSERT OR IGNORE INTO inventory_categories (name, description) VALUES (?, ?)');
defaults.forEach(([name, desc]) => insert.run(name, desc));
}
} catch (e) {
logger.error('Failed to seed default inventory categories: ' + e.message);
}
}
// ─── CATEGORIES ─────────────────────────────────────────────────────────── // ─── CATEGORIES ───────────────────────────────────────────────────────────
function getAllCategories() { function getAllCategories() {
return db.prepare('SELECT * FROM inventory_categories ORDER BY name ASC').all(); seedDefaultCategories();
return db.prepare(`
SELECT c.*,
(SELECT count(*) FROM inventory_items i WHERE i.category_id = c.id) as item_count
FROM inventory_categories c
ORDER BY c.name ASC
`).all();
} }
function createCategory(data) { function createCategory(data) {
const { name, description } = data; const { name, description } = data;
return db.prepare('INSERT INTO inventory_categories (name, description) VALUES (?, ?)').run(name, description); if (!name || !name.trim()) throw new Error('Nama kategori wajib diisi');
return db.prepare('INSERT INTO inventory_categories (name, description) VALUES (?, ?)').run(name.trim(), description || '');
} }
function updateCategory(id, data) { function updateCategory(id, data) {
const { name, description } = data; const { name, description } = data;
return db.prepare('UPDATE inventory_categories SET name = ?, description = ? WHERE id = ?').run(name, description, id); if (!name || !name.trim()) throw new Error('Nama kategori wajib diisi');
return db.prepare('UPDATE inventory_categories SET name = ?, description = ? WHERE id = ?').run(name.trim(), description || '', id);
} }
function deleteCategory(id) { function deleteCategory(id) {
const itemsCount = db.prepare('SELECT count(*) as c FROM inventory_items WHERE category_id = ?').get(id)?.c || 0;
if (itemsCount > 0) {
throw new Error(`Tidak dapat menghapus kategori karena masih memiliki ${itemsCount} jenis barang terkait.`);
}
return db.prepare('DELETE FROM inventory_categories WHERE id = ?').run(id); return db.prepare('DELETE FROM inventory_categories WHERE id = ?').run(id);
} }
// ─── HELPER STOCK COUNT ───────────────────────────────────────────────────
function getItemAvailableStock(itemId) {
const res = db.prepare(`
SELECT COALESCE(SUM(quantity), 0) as total
FROM inventory_stock
WHERE item_id = ? AND status = 'available'
`).get(itemId);
return Number(res?.total || 0);
}
function getItemAssignedStock(itemId) {
const res = db.prepare(`
SELECT COALESCE(SUM(quantity), 0) as total
FROM inventory_stock
WHERE item_id = ? AND status = 'assigned'
`).get(itemId);
return Number(res?.total || 0);
}
function getItemBrokenStock(itemId) {
const res = db.prepare(`
SELECT COALESCE(SUM(quantity), 0) as total
FROM inventory_stock
WHERE item_id = ? AND status = 'broken'
`).get(itemId);
return Number(res?.total || 0);
}
// ─── ITEMS ─────────────────────────────────────────────────────────────── // ─── ITEMS ───────────────────────────────────────────────────────────────
function getAllItems(search = '') { function getAllItems(search = '', categoryId = null) {
seedDefaultCategories();
let query = ` let query = `
SELECT i.*, c.name as category_name, SELECT i.*, c.name as category_name,
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available') as stock_available, COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available'), 0) as stock_available,
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned') as stock_assigned COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned'), 0) as stock_assigned,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'broken'), 0) as stock_broken
FROM inventory_items i FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id LEFT JOIN inventory_categories c ON i.category_id = c.id
WHERE 1=1
`; `;
const params = []; const params = [];
if (categoryId) {
query += ' AND i.category_id = ?';
params.push(categoryId);
}
if (search) { if (search) {
query += ' WHERE i.name LIKE ? OR i.brand LIKE ? OR i.model LIKE ?'; query += ' AND (i.name LIKE ? OR i.brand LIKE ? OR i.model LIKE ? OR c.name LIKE ?)';
const s = `%${search}%`; const s = `%${search}%`;
params.push(s, s, s); params.push(s, s, s, s);
} }
query += ' ORDER BY i.name ASC'; query += ' ORDER BY i.name ASC';
return db.prepare(query).all(...params); return db.prepare(query).all(...params);
@@ -44,35 +113,495 @@ function getAllItems(search = '') {
function getItemById(id) { function getItemById(id) {
return db.prepare(` return db.prepare(`
SELECT i.*, c.name as category_name SELECT i.*, c.name as category_name,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available'), 0) as stock_available,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned'), 0) as stock_assigned,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'broken'), 0) as stock_broken
FROM inventory_items i FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id LEFT JOIN inventory_categories c ON i.category_id = c.id
WHERE i.id = ? WHERE i.id = ?
`).get(id); `).get(id);
} }
function getItemDetail(id) {
const item = getItemById(id);
if (!item) return null;
const stockList = db.prepare(`
SELECT s.*, cust.name as customer_name, cust.pppoe_username as pppoe_user
FROM inventory_stock s
LEFT JOIN customers cust ON s.assigned_to_customer_id = cust.id
WHERE s.item_id = ?
ORDER BY CASE WHEN s.status = 'available' THEN 1 WHEN s.status = 'assigned' THEN 2 ELSE 3 END, s.created_at DESC
`).all(id);
const logs = db.prepare(`
SELECT l.*, s.serial_number
FROM inventory_logs l
LEFT JOIN inventory_stock s ON l.stock_id = s.id
WHERE l.item_id = ?
ORDER BY l.created_at DESC, l.id DESC
LIMIT 100
`).all(id);
return { item, stockList, logs };
}
function createItem(data) { function createItem(data) {
const { category_id, name, brand, model, unit, min_stock, description } = data; const { category_id, name, brand, model, unit, min_stock, description } = data;
if (!name || !name.trim()) throw new Error('Nama barang wajib diisi');
return db.prepare(` return db.prepare(`
INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description) INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(category_id, name, brand, model, unit, min_stock, description); `).run(
category_id ? Number(category_id) : null,
name.trim(),
brand ? brand.trim() : '',
model ? model.trim() : '',
unit ? unit.trim() : 'pcs',
Number(min_stock) || 5,
description ? description.trim() : ''
);
} }
function updateItem(id, data) { function updateItem(id, data) {
const { category_id, name, brand, model, unit, min_stock, description } = data; const { category_id, name, brand, model, unit, min_stock, description } = data;
if (!name || !name.trim()) throw new Error('Nama barang wajib diisi');
return db.prepare(` return db.prepare(`
UPDATE inventory_items UPDATE inventory_items
SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ? SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ?
WHERE id = ? WHERE id = ?
`).run(category_id, name, brand, model, unit, min_stock, description, id); `).run(
category_id ? Number(category_id) : null,
name.trim(),
brand ? brand.trim() : '',
model ? model.trim() : '',
unit ? unit.trim() : 'pcs',
Number(min_stock) || 5,
description ? description.trim() : '',
id
);
} }
function deleteItem(id) { function deleteItem(id) {
const stockCount = db.prepare('SELECT count(*) as c FROM inventory_stock WHERE item_id = ?').get(id)?.c || 0;
if (stockCount > 0) {
throw new Error(`Tidak dapat menghapus barang karena masih ada ${stockCount} data stok terkait. Silakan sesuaikan atau kosongkan stok terlebih dahulu.`);
}
return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id); return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id);
} }
// ─── STOCK ─────────────────────────────────────────────────────────────── // ─── SERIAL NUMBERS ───────────────────────────────────────────────────────
function getAvailableSerialNumbers(itemId) {
return db.prepare(`
SELECT id, serial_number, condition, location, note
FROM inventory_stock
WHERE item_id = ? AND status = 'available' AND serial_number IS NOT NULL AND TRIM(serial_number) != ''
ORDER BY serial_number ASC
`).all(itemId);
}
// ─── STOCK TRANSACTIONS ───────────────────────────────────────────────────
/**
* 1. INPUT STOK MASUK (IN)
* Menambah stok barang dan mencatat saldo sisa stok akhir secara presisi
*/
function addStock(data, actor = 'Admin') {
const { item_id, serial_number, quantity, condition, location, price, supplier, reference_no, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const cleanSn = serial_number ? serial_number.trim() : '';
const qty = cleanSn ? 1 : Math.max(1, parseInt(quantity, 10) || 1);
const run = db.transaction(() => {
let stockId = null;
if (cleanSn) {
// Cek duplikasi SN
const existing = db.prepare('SELECT id, status FROM inventory_stock WHERE serial_number = ?').get(cleanSn);
if (existing) {
throw new Error(`Serial Number "${cleanSn}" sudah pernah terdaftar di sistem (Status: ${existing.status})!`);
}
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, price, supplier, note)
VALUES (?, ?, 1, ?, ?, 'available', ?, ?, ?)
`).run(item_id, cleanSn, condition || 'new', location || 'Gudang Utama', Number(price) || 0, supplier || '', note || '');
stockId = res.lastInsertRowid;
} else {
// Non-serial item
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, price, supplier, note)
VALUES (?, NULL, ?, ?, ?, 'available', ?, ?, ?)
`).run(item_id, qty, condition || 'new', location || 'Gudang Utama', Number(price) || 0, supplier || '', note || '');
stockId = res.lastInsertRowid;
}
// Hitung sisa stok tersedia saat ini
const remainingStock = getItemAvailableStock(item_id);
// Catat log stok masuk lengkap dengan sisa stok
const logNote = note || (cleanSn ? `Stok Masuk SN: ${cleanSn}` : `Stok Masuk (+${qty} ${item.unit})`);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, ?, 'in', ?, ?, ?, ?, ?, ?)
`).run(
item_id,
stockId,
qty,
remainingStock,
actor || 'Admin',
supplier || 'Gudang Utama',
reference_no || '',
logNote
);
return { stockId, remainingStock };
});
return run();
}
/**
* 2. PENGELUARAN BARANG (OUT / ISSUE)
* Mengeluarkan barang untuk instalasi baru, perbaikan, pemakaian teknisi, atau rusak
* Mengurangi stok secara otomatis dan mencatat sisa stok akhir
*/
function issueStock(data, actor = 'Admin') {
const { item_id, stock_id, serial_number, quantity, type, customer_id, recipient, reference_no, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const availableStock = getItemAvailableStock(item_id);
if (availableStock <= 0) {
throw new Error(`Stok ${item.name} saat ini kosong (0 ${item.unit})!`);
}
const issueType = type || (customer_id ? 'assigned' : 'out'); // 'out', 'assigned', 'broken', 'loss'
const run = db.transaction(() => {
let stockIdTarget = stock_id ? Number(stock_id) : null;
let qtyToDeduct = 1;
// A. JIKA DITARGETKAN KE SERIAL NUMBER SPESIFIK ATAU STOCK_ID SPESIFIK
if (serial_number && !stockIdTarget) {
const row = db.prepare('SELECT id, status FROM inventory_stock WHERE item_id = ? AND serial_number = ?').get(item_id, serial_number.trim());
if (!row) throw new Error(`Serial Number "${serial_number}" tidak ditemukan untuk barang ini`);
if (row.status !== 'available') throw new Error(`Barang SN "${serial_number}" statusnya "${row.status}", tidak dapat dikeluarkan!`);
stockIdTarget = row.id;
}
if (stockIdTarget) {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockIdTarget);
if (!stock) throw new Error('Data unit stok tidak ditemukan');
if (stock.status !== 'available') throw new Error(`Unit stok ini statusnya "${stock.status}", tidak tersedia`);
const targetStatus = (customer_id || issueType === 'assigned') ? 'assigned' : (issueType === 'broken' ? 'broken' : 'out');
const targetCustomer = customer_id ? Number(customer_id) : null;
db.prepare(`
UPDATE inventory_stock
SET status = ?, assigned_to_customer_id = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(targetStatus, targetCustomer, stockIdTarget);
qtyToDeduct = stock.quantity || 1;
} else {
// B. BARANG NON-SERIAL / PENGURANGAN KUANTITAS (FIFO)
qtyToDeduct = Math.max(1, parseInt(quantity, 10) || 1);
if (qtyToDeduct > availableStock) {
throw new Error(`Stok tidak mencukupi! Tersedia di gudang: ${availableStock} ${item.unit}, diminta keluar: ${qtyToDeduct} ${item.unit}`);
}
// Ambil batch stok yang available terurut dari yang paling lama masuk (FIFO)
const batches = db.prepare(`
SELECT id, quantity
FROM inventory_stock
WHERE item_id = ? AND status = 'available'
ORDER BY created_at ASC, id ASC
`).all(item_id);
let needed = qtyToDeduct;
for (const b of batches) {
if (needed <= 0) break;
if (b.quantity <= needed) {
db.prepare("UPDATE inventory_stock SET quantity = 0, status = 'out', updated_at = (NOW_LOCAL()) WHERE id = ?").run(b.id);
needed -= b.quantity;
} else {
db.prepare("UPDATE inventory_stock SET quantity = quantity - ?, updated_at = (NOW_LOCAL()) WHERE id = ?").run(needed, b.id);
needed = 0;
break;
}
}
}
// Hitung sisa stok tersedia setelah pengeluaran
const remainingStock = getItemAvailableStock(item_id);
// Keterangan penerima (teknisi / pelanggan)
let finalRecipient = recipient ? recipient.trim() : '';
if (customer_id && !finalRecipient) {
const cust = db.prepare('SELECT name, pppoe_username as pppoe_user FROM customers WHERE id = ?').get(customer_id);
if (cust) finalRecipient = `Pelanggan: ${cust.name} (${cust.pppoe_user || '-'})`;
}
if (!finalRecipient) finalRecipient = 'Teknisi / Operasional';
const logNote = note || `Pengeluaran Barang (-${qtyToDeduct} ${item.unit})`;
// Catat log pengeluaran dengan sisa stok
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
item_id,
stockIdTarget,
issueType,
-qtyToDeduct,
remainingStock,
actor || 'Admin',
finalRecipient,
reference_no || '',
logNote
);
return { qtyDeducted: qtyToDeduct, remainingStock };
});
return run();
}
/**
* 3. PENYESUAIAN STOK (STOCK OPNAME / ADJUSTMENT)
* Menyesuaikan data sistem dengan fisik gudang dan mencatat selisih & saldo akhir
*/
function adjustStock(data, actor = 'Admin') {
const { item_id, actual_quantity, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const actualQty = Math.max(0, parseInt(actual_quantity, 10));
if (isNaN(actualQty)) throw new Error('Jumlah stok fisik harus berupa angka');
const currentAvailable = getItemAvailableStock(item_id);
const diff = actualQty - currentAvailable;
if (diff === 0) {
return { diff: 0, remainingStock: actualQty, message: 'Jumlah stok sudah sesuai, tidak ada perubahan.' };
}
const run = db.transaction(() => {
if (diff > 0) {
// Stok fisik lebih banyak dari sistem -> Tambah selisih ke stok
db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
VALUES (?, NULL, ?, 'new', 'Gudang Utama', 'available', ?)
`).run(item_id, diff, note || 'Penyesuaian Fisik (Opname)');
} else {
// Stok fisik lebih sedikit dari sistem -> Kurangi selisih dari stok yang ada
let neededToReduce = Math.abs(diff);
const batches = db.prepare(`
SELECT id, quantity FROM inventory_stock
WHERE item_id = ? AND status = 'available'
ORDER BY created_at ASC, id ASC
`).all(item_id);
for (const b of batches) {
if (neededToReduce <= 0) break;
if (b.quantity <= neededToReduce) {
db.prepare("UPDATE inventory_stock SET quantity = 0, status = 'out', updated_at = (NOW_LOCAL()) WHERE id = ?").run(b.id);
neededToReduce -= b.quantity;
} else {
db.prepare("UPDATE inventory_stock SET quantity = quantity - ?, updated_at = (NOW_LOCAL()) WHERE id = ?").run(neededToReduce, b.id);
neededToReduce = 0;
break;
}
}
}
const logNote = note || `Stock Opname (Penyesuaian Fisik: ${diff > 0 ? '+' : ''}${diff} ${item.unit})`;
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, NULL, 'adjust', ?, ?, ?, 'Stock Opname', 'OPNAME', ?)
`).run(
item_id,
diff,
actualQty,
actor || 'Admin',
logNote
);
return { diff, remainingStock: actualQty };
});
return run();
}
/**
* 4. PENGEMBALIAN BARANG (RETURN / COPOTAN)
* Barang bekas / copotan dari pelanggan atau sisa pemakaian lapangan dikembalikan ke gudang
*/
function returnStock(data, actor = 'Admin') {
const { item_id, serial_number, quantity, condition, customer_id, returned_by, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const cleanSn = serial_number ? serial_number.trim() : '';
const qty = cleanSn ? 1 : Math.max(1, parseInt(quantity, 10) || 1);
const cond = condition || 'used';
const run = db.transaction(() => {
let stockId = null;
if (cleanSn) {
const existing = db.prepare('SELECT id, status FROM inventory_stock WHERE serial_number = ?').get(cleanSn);
if (existing) {
// Kembalikan unit yang sudah ada ke gudang
db.prepare(`
UPDATE inventory_stock
SET status = 'available', condition = ?, assigned_to_customer_id = NULL, location = 'Gudang Utama', updated_at = (NOW_LOCAL()), note = ?
WHERE id = ?
`).run(cond, note || 'Pengembalian / Copotan', existing.id);
stockId = existing.id;
} else {
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
VALUES (?, ?, 1, ?, 'Gudang Utama', 'available', ?)
`).run(item_id, cleanSn, cond, note || 'Pengembalian / Copotan');
stockId = res.lastInsertRowid;
}
} else {
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
VALUES (?, NULL, ?, ?, 'Gudang Utama', 'available', ?)
`).run(item_id, qty, cond, note || 'Pengembalian / Copotan');
stockId = res.lastInsertRowid;
}
const remainingStock = getItemAvailableStock(item_id);
let returner = returned_by ? returned_by.trim() : '';
if (customer_id && !returner) {
const cust = db.prepare('SELECT name FROM customers WHERE id = ?').get(customer_id);
if (cust) returner = `Copotan dari: ${cust.name}`;
}
if (!returner) returner = 'Teknisi Lapangan';
const logNote = note || (cleanSn ? `Pengembalian SN: ${cleanSn} (${cond})` : `Pengembalian (+${qty} ${item.unit})`);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, ?, 'return', ?, ?, ?, ?, 'RETURN', ?)
`).run(
item_id,
stockId,
qty,
remainingStock,
actor || 'Admin',
returner,
logNote
);
return { stockId, remainingStock };
});
return run();
}
/**
* 5. RIWAYAT PERGERAKAN STOK (INVENTORY LOGS)
*/
function getInventoryLogs(options = {}) {
if (typeof options === 'number') options = { limit: options };
const limit = options.limit ? parseInt(options.limit, 10) : 100;
const itemId = options.itemId ? Number(options.itemId) : null;
const type = options.type ? options.type.trim() : null;
const search = options.search ? options.search.trim() : null;
let query = `
SELECT l.*,
i.name as item_name,
i.unit as item_unit,
i.brand as item_brand,
i.model as item_model,
c.name as category_name,
s.serial_number
FROM inventory_logs l
LEFT JOIN inventory_items i ON l.item_id = i.id
LEFT JOIN inventory_categories c ON i.category_id = c.id
LEFT JOIN inventory_stock s ON l.stock_id = s.id
WHERE 1=1
`;
const params = [];
if (itemId) {
query += ' AND l.item_id = ?';
params.push(itemId);
}
if (type) {
query += ' AND l.type = ?';
params.push(type);
}
if (search) {
query += ' AND (i.name LIKE ? OR s.serial_number LIKE ? OR l.recipient LIKE ? OR l.note LIKE ? OR l.reference_no LIKE ?)';
const s = `%${search}%`;
params.push(s, s, s, s, s);
}
query += ' ORDER BY l.created_at DESC, l.id DESC LIMIT ?';
params.push(limit);
return db.prepare(query).all(...params);
}
/**
* 6. STATISTIK GUDANG
*/
function getInventoryStats() {
seedDefaultCategories();
const totalItems = db.prepare('SELECT count(*) as c FROM inventory_items').get()?.c || 0;
const totalCategories = db.prepare('SELECT count(*) as c FROM inventory_categories').get()?.c || 0;
const stockSummary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN status = 'available' THEN quantity ELSE 0 END), 0) as available,
COALESCE(SUM(CASE WHEN status = 'assigned' THEN quantity ELSE 0 END), 0) as assigned,
COALESCE(SUM(CASE WHEN status = 'broken' THEN quantity ELSE 0 END), 0) as broken
FROM inventory_stock
`).get();
const lowStockCount = db.prepare(`
SELECT count(*) as c FROM (
SELECT i.id
FROM inventory_items i
LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available'
GROUP BY i.id
HAVING COALESCE(SUM(s.quantity), 0) <= i.min_stock
)
`).get()?.c || 0;
return {
total_items: totalItems,
total_categories: totalCategories,
total_stock_available: Number(stockSummary?.available || 0),
total_stock_assigned: Number(stockSummary?.assigned || 0),
total_stock_broken: Number(stockSummary?.broken || 0),
low_stock_count: lowStockCount
};
}
// ─── KOMPATIBILITAS FORK ─────────────────────────────────────────────────
function getStockByItem(itemId) { function getStockByItem(itemId) {
return db.prepare(` return db.prepare(`
SELECT s.*, cust.name as customer_name SELECT s.*, cust.name as customer_name
@@ -83,78 +612,15 @@ function getStockByItem(itemId) {
`).all(itemId); `).all(itemId);
} }
function addStock(data, actor = 'Admin') {
const { item_id, serial_number, quantity, condition, location, note } = data;
const run = db.transaction(() => {
const result = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, note)
VALUES (?, ?, ?, ?, ?, ?)
`).run(item_id, serial_number || null, quantity, condition || 'new', location || 'Gudang Utama', note || '');
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'in', ?, ?, ?)
`).run(item_id, result.lastInsertRowid, quantity, actor, note || 'Stock Masuk');
return result;
});
return run();
}
function assignStockToCustomer(stockId, customerId, actor = 'Admin', note = '') { function assignStockToCustomer(stockId, customerId, actor = 'Admin', note = '') {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId); const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
if (!stock) throw new Error('Stock tidak ditemukan'); if (!stock) throw new Error('Stock tidak ditemukan');
if (stock.status === 'assigned') throw new Error('Stock sudah terpasang di pelanggan lain'); return issueStock({ item_id: stock.item_id, stock_id: stockId, customer_id: customerId, note }, actor);
const run = db.transaction(() => {
db.prepare(`
UPDATE inventory_stock
SET status = 'assigned', assigned_to_customer_id = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(customerId, stockId);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'out', ?, ?, ?)
`).run(stock.item_id, stockId, stock.quantity, actor, note || `Terpasang ke pelanggan ID: ${customerId}`);
});
return run();
}
function adjustStock(stockId, newQuantity, note, actor = 'Admin') {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
if (!stock) throw new Error('Stock tidak ditemukan');
const run = db.transaction(() => {
const diff = newQuantity - stock.quantity;
db.prepare('UPDATE inventory_stock SET quantity = ?, updated_at = (NOW_LOCAL()) WHERE id = ?').run(newQuantity, stockId);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'adjust', ?, ?, ?)
`).run(stock.item_id, stockId, diff, actor, note || 'Penyesuaian Stock');
});
return run();
}
function getInventoryLogs(limit = 100) {
return db.prepare(`
SELECT l.*, i.name as item_name, s.serial_number
FROM inventory_logs l
LEFT JOIN inventory_items i ON l.item_id = i.id
LEFT JOIN inventory_stock s ON l.stock_id = s.id
ORDER BY l.created_at DESC
LIMIT ?
`).all(limit);
} }
function getLowStockItems() { function getLowStockItems() {
return db.prepare(` return db.prepare(`
SELECT i.*, c.name as category_name, SUM(s.quantity) as current_stock SELECT i.*, c.name as category_name, COALESCE(SUM(s.quantity), 0) as current_stock
FROM inventory_items i FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id LEFT JOIN inventory_categories c ON i.category_id = c.id
LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available' LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available'
@@ -164,8 +630,11 @@ function getLowStockItems() {
} }
module.exports = { module.exports = {
seedDefaultCategories,
getAllCategories, createCategory, updateCategory, deleteCategory, getAllCategories, createCategory, updateCategory, deleteCategory,
getAllItems, getItemById, createItem, updateItem, deleteItem, getAllItems, getItemById, getItemDetail, createItem, updateItem, deleteItem,
getStockByItem, addStock, assignStockToCustomer, adjustStock, getAvailableSerialNumbers, getItemAvailableStock, getItemAssignedStock, getItemBrokenStock,
getInventoryLogs, getLowStockItems addStock, issueStock, adjustStock, returnStock,
getInventoryLogs, getInventoryStats,
getStockByItem, assignStockToCustomer, getLowStockItems
}; };

View File

@@ -2128,6 +2128,44 @@ async function getBackup(routerId = null) {
} }
} }
// ─── KOMPATIBILITAS AI / SELF-HEALING ───────────────────────────────────────
async function getActivePppoeUsers() {
const map = await getActivePppoeSessionsMap();
const out = [];
map.forEach((v, user) => out.push({
user,
address: v.ip || '',
uptime: v.uptime || '',
callerId: v.callerId || '',
routerId: v.routerId ?? null,
routerName: v.routerName || ''
}));
return out;
}
async function isolateCustomerPppoe(routerId, username, opts = {}) {
const profile = String(opts.pppoeProfile || 'ISOLIR');
await setPppoeProfile(username, profile, routerId ?? null);
await kickPppoeUser(username, routerId ?? null).catch(() => {});
return true;
}
async function unisolateCustomerPppoe(routerId, username) {
const name = String(username || '').trim();
if (!name) return false;
const row = db.prepare(`
SELECT p.name as package_name
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
WHERE c.pppoe_username = ? COLLATE NOCASE
LIMIT 1
`).get(name);
const profile = row && row.package_name ? row.package_name : 'default';
await setPppoeProfile(name, profile, routerId ?? null);
return true;
}
module.exports = { module.exports = {
checkConnection, checkConnection,
getConnection, getConnection,
@@ -2149,6 +2187,9 @@ module.exports = {
getHotspotProfiles, getHotspotProfiles,
getPppoeActive, getPppoeActive,
getActivePppoeSessionsMap, getActivePppoeSessionsMap,
getActivePppoeUsers,
isolateCustomerPppoe,
unisolateCustomerPppoe,
getAllActiveSessionsMap, getAllActiveSessionsMap,
getHotspotActive, getHotspotActive,
getIpPools, getIpPools,

View File

@@ -435,6 +435,67 @@ function getAvailablePeriods() {
`).all(); `).all();
} }
function updateSlip(id, data) {
const slip = getSlipById(id);
if (!slip) throw new Error('Slip tidak ditemukan');
if (slip.status !== 'draft') throw new Error('Hanya slip berstatus draft yang dapat diedit');
const pickInt = (key, fallback) => (data[key] !== undefined && data[key] !== '') ? parseInt(data[key], 10) || 0 : fallback;
const pickFloat = (key, fallback) => (data[key] !== undefined && data[key] !== '') ? parseFloat(data[key]) || 0 : fallback;
const baseSalary = pickInt('base_salary', slip.base_salary);
const transportAllowance = pickInt('transport_allowance', slip.transport_allowance);
const mealAllowance = pickInt('meal_allowance', slip.meal_allowance);
const phoneAllowance = pickInt('phone_allowance', slip.phone_allowance);
const otherAllowance = pickInt('other_allowance', slip.other_allowance);
const otherAllowanceNote = data.other_allowance_note !== undefined ? String(data.other_allowance_note) : slip.other_allowance_note;
const workingDays = pickInt('working_days', slip.working_days);
const absentDays = pickInt('absent_days', slip.absent_days);
const lateDays = pickInt('late_days', slip.late_days);
const overtimeHours = pickFloat('overtime_hours', slip.overtime_hours);
const overtimeBonus = pickInt('overtime_bonus', slip.overtime_bonus);
const ticketBonus = pickInt('ticket_bonus', slip.ticket_bonus);
const collectionCommission = pickInt('collection_commission', slip.collection_commission);
const absenceDeduction = pickInt('absence_deduction', slip.absence_deduction);
const lateDeduction = pickInt('late_deduction', slip.late_deduction);
const otherDeduction = pickInt('other_deduction', slip.other_deduction);
const otherDeductionNote = data.other_deduction_note !== undefined ? String(data.other_deduction_note) : slip.other_deduction_note;
const notes = data.notes !== undefined ? String(data.notes) : slip.notes;
const grossSalary = baseSalary + transportAllowance + mealAllowance + phoneAllowance + otherAllowance + ticketBonus + collectionCommission + overtimeBonus;
const totalDeductions = absenceDeduction + lateDeduction + otherDeduction;
const netSalary = Math.max(0, grossSalary - totalDeductions);
db.prepare(`
UPDATE payroll_slips SET
base_salary = ?, transport_allowance = ?, meal_allowance = ?,
phone_allowance = ?, other_allowance = ?, other_allowance_note = ?,
working_days = ?, absent_days = ?, late_days = ?,
overtime_hours = ?, overtime_bonus = ?,
ticket_bonus = ?, collection_commission = ?,
absence_deduction = ?, late_deduction = ?,
other_deduction = ?, other_deduction_note = ?,
gross_salary = ?, total_deductions = ?, net_salary = ?,
notes = ?
WHERE id = ?
`).run(
baseSalary, transportAllowance, mealAllowance,
phoneAllowance, otherAllowance, otherAllowanceNote || '',
workingDays, absentDays, lateDays,
overtimeHours, overtimeBonus,
ticketBonus, collectionCommission,
absenceDeduction, lateDeduction,
otherDeduction, otherDeductionNote || '',
grossSalary, totalDeductions, netSalary,
notes || '', id
);
return getSlipById(id);
}
module.exports = { module.exports = {
EMPLOYEE_TYPES, EMPLOYEE_TYPES,
getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting, getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting,
@@ -442,7 +503,7 @@ module.exports = {
getTicketsResolvedCount, getCollectionAmount, getAttendanceData, getTicketsResolvedCount, getCollectionAmount, getAttendanceData,
generateSlip, generateAllSlips, generateSlip, generateAllSlips,
getSlipById, getSlipsByPeriod, getSlipsByEmployee, getSlipById, getSlipsByPeriod, getSlipsByEmployee,
updateSlipDeductions, approveSlip, markSlipPaid, updateSlipDeductions, updateSlip, approveSlip, markSlipPaid,
bulkApprove, bulkMarkPaid, bulkApprove, bulkMarkPaid,
deleteSlip, deleteSlipsByPeriod, deleteSlip, deleteSlipsByPeriod,
getPayrollSummary, getAvailablePeriods getPayrollSummary, getAvailablePeriods

View File

@@ -0,0 +1,369 @@
/**
* Service: AI Security & Brute-Force Anomaly Watchdog
* Mendeteksi anomali serangan brute force pada aplikasi billing dan router MikroTik.
* Mengumumkan peringatan darurat secara otomatis melalui Voice TTS (Soundbox)
* serta mengirim notifikasi siaga ke WhatsApp & Telegram Admin.
*/
const db = require('../config/database');
const { logger } = require('../config/logger');
const soundboxSvc = require('./soundboxService');
const { getSetting } = require('../config/settingsManager');
// In-memory state tracking
const failedAttemptsByIp = new Map();
const blockedIps = new Map(); // ip -> unblockTime
const mikrotikAlertCache = new Map(); // routerId:ip -> timestamp
const processedLogIdsByRouter = new Map(); // routerId -> Set of log IDs
const WINDOW_MS = 5 * 60 * 1000; // 5 menit
const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 menit
const WARNING_THRESHOLD = 3;
const CRITICAL_THRESHOLD = 5;
/**
* Mengonversi IP address menjadi format lisan ramah suara TTS
* Contoh: "192.168.8.50" -> "1 9 2 titik 1 6 8 titik 8 titik 5 0"
*/
function formatIpForSpeech(ip) {
if (!ip) return 'tidak diketahui';
const cleanIp = String(ip).replace(/^::ffff:/, '');
return cleanIp.split('.').map(octet => octet.split('').join(' ')).join(' titik ');
}
class SecurityWatchdogService {
/**
* Cek apakah IP sedang dalam masa blokir sementara
*/
isIpBlocked(rawIp) {
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
if (!ip) return false;
const unblockTime = blockedIps.get(ip);
if (unblockTime) {
if (Date.now() < unblockTime) {
return true;
} else {
blockedIps.delete(ip);
}
}
return false;
}
/**
* Buka blokir IP secara manual
*/
unblockIp(rawIp) {
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
if (!ip) return false;
blockedIps.delete(ip);
failedAttemptsByIp.delete(ip);
return true;
}
/**
* Mencatat percobaan login pada aplikasi (Admin / Kasir / Agent / Tech)
*/
async recordAppLoginAttempt({ type = 'admin', username = '', ip: rawIp = '127.0.0.1', success = false, userAgent = '' }) {
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
const now = Date.now();
// Jika login berhasil, reset kegagalan IP ini
if (success) {
failedAttemptsByIp.delete(ip);
return { allowed: true };
}
// Login Gagal: Ambil atau buat record tracking
let record = failedAttemptsByIp.get(ip);
if (!record || (now - record.lastAttempt > WINDOW_MS)) {
record = {
ip,
count: 1,
firstAttempt: now,
lastAttempt: now,
usernames: new Set([username]),
alerted: false
};
} else {
record.count++;
record.lastAttempt = now;
if (username) record.usernames.add(username);
}
failedAttemptsByIp.set(ip, record);
logger.warn(`[SecurityWatchdog] Login gagal ke ${type} dari IP ${ip} (Percobaan ke-${record.count}, target: ${username})`);
// Cek Ambang Batas Kritis (Brute Force Detected)
if (record.count >= CRITICAL_THRESHOLD && !record.alerted) {
record.alerted = true;
blockedIps.set(ip, now + BLOCK_DURATION_MS);
const targetList = Array.from(record.usernames).filter(Boolean).join(', ') || 'admin';
const ipSpoken = formatIpForSpeech(ip);
// 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN)
const speechText = `Peringatan keamanan! Terdeteksi anomali percobaan login gagal berulang kali ke aplikasi ${type} dari alamat i pe ${ipSpoken}. Sistem telah memblokir sementara akses tersebut.`;
soundboxSvc.queueSoundboxEvent({
type: 'security_alert',
title: 'PERINGATAN BRUTE FORCE APLIKASI',
speechText: speechText,
amount: 0,
customerName: `IP: ${ip} (Target: ${targetList})`
});
// 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN
const waMsg =
`🚨 *PERINGATAN KEAMANAN (BRUTE FORCE DETECTED)*\n\n` +
`Sistem mendeteksi percobaan login gagal berulang kali ke portal *${type.toUpperCase()}*:\n` +
`🌐 *Alamat IP:* \`${ip}\`\n` +
`👤 *Target Username:* ${targetList}\n` +
`📊 *Jumlah Kegagalan:* ${record.count} kali dalam 5 menit\n` +
`⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n` +
`🛡️ *Status:* IP telah diblokir sementara selama 15 menit.\n\n` +
`_Peringatan suara telah dibunyikan otomatis melalui Soundbox._`;
await this._notifyAdmin(waMsg);
// 3. CATAT KE AUDIT TRAIL
this._recordAudit({
action: 'BRUTE_FORCE_BLOCKED',
entity_type: 'app_login',
entity_id: ip,
details: {
portal: type,
failedCount: record.count,
targets: Array.from(record.usernames),
userAgent
},
ip_address: ip,
user_agent: userAgent
});
}
return {
allowed: !this.isIpBlocked(ip),
attempts: record.count,
remaining: Math.max(0, CRITICAL_THRESHOLD - record.count)
};
}
/**
* Pindai log MikroTik untuk mendeteksi brute-force login SSH / Telnet / Winbox / WebFig
*/
async scanMikrotikLoginFailures() {
try {
const mt = require('./mikrotikService');
const routers = db.prepare("SELECT id, name, host FROM routers WHERE is_active = 1").all() || [];
for (const r of routers) {
try {
const conn = await mt.getConnection(r.id);
if (!conn || !conn.client) continue;
// Ambil log dari MikroTik
const logs = await conn.client.menu('/log').get();
if (!Array.isArray(logs) || logs.length === 0) continue;
// Ambil atau buat set log ID yang sudah diproses agar tidak duplikat
let processedSet = processedLogIdsByRouter.get(r.id);
if (!processedSet) {
processedSet = new Set();
processedLogIdsByRouter.set(r.id, processedSet);
}
// Filter log kegagalan login
const failures = [];
for (const l of logs) {
const logId = l['.id'] || l.id;
if (processedSet.has(logId)) continue;
processedSet.add(logId);
const msg = String(l.message || '');
if (msg.toLowerCase().includes('login failure') || msg.toLowerCase().includes('failure')) {
failures.push(l);
}
}
// Bersihkan cache log ID jika terlalu besar
if (processedSet.size > 2000) {
processedSet.clear();
}
if (failures.length === 0) continue;
// Kelompokkan kegagalan berdasarkan IP penyerang
const failuresByIp = new Map();
for (const f of failures) {
const msg = f.message || '';
// Regex MikroTik format: "login failure for user admin from 192.168.1.50 via winbox"
const match = msg.match(/login failure for user\s+(\S+)\s+from\s+([0-9a-fA-F.:]+)(?:\s+via\s+(\S+))?/i) ||
msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i);
if (match) {
const targetUser = match[1] || 'unknown';
const attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim();
const service = match[3] || 'router-service';
if (!attackerIp || attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue;
if (!failuresByIp.has(attackerIp)) {
failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service });
} else {
const item = failuresByIp.get(attackerIp);
item.count++;
item.targets.add(targetUser);
}
}
}
// Periksa setiap penyerang
const now = Date.now();
for (const [attackerIp, data] of failuresByIp.entries()) {
if (data.count >= 2) {
const cacheKey = `${r.id}:${attackerIp}`;
const lastAlert = mikrotikAlertCache.get(cacheKey) || 0;
// Debounce alert per IP router tiap 15 menit
if (now - lastAlert > 15 * 60 * 1000) {
mikrotikAlertCache.set(cacheKey, now);
const targetsStr = Array.from(data.targets).join(', ');
const ipSpoken = formatIpForSpeech(attackerIp);
logger.warn(`[SecurityWatchdog] Terdeteksi brute-force MikroTik '${r.name}' dari ${attackerIp} (${data.count}x via ${data.service})`);
// 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN)
const speechText = `Peringatan darurat! Terdeteksi serangan brute force ke router MikroTik ${r.name} dari alamat i pe ${ipSpoken} melalui ${data.service}. Segera periksa keamanan router Anda.`;
soundboxSvc.queueSoundboxEvent({
type: 'security_alert',
title: 'PERINGATAN BRUTE FORCE MIKROTIK',
speechText: speechText,
amount: 0,
customerName: `Router: ${r.name} | IP: ${attackerIp}`
});
// 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN
const waMsg =
`🚨 *PERINGATAN KEAMANAN (SERANGAN MIKROTIK)*\n\n` +
`Terdeteksi serangan brute force login pada router *${r.name}* (${r.host}):\n` +
`🌐 *IP Penyerang:* \`${attackerIp}\`\n` +
`🔌 *Layanan:* ${data.service.toUpperCase()}\n` +
`👤 *Target User:* ${targetsStr}\n` +
`📊 *Jumlah Percobaan Gagal:* ${data.count} kali\n` +
`⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n\n` +
`_Peringatan suara darurat telah dibunyikan melalui Soundbox._`;
await this._notifyAdmin(waMsg);
// 3. CATAT KE AUDIT TRAIL
this._recordAudit({
action: 'ROUTER_BRUTE_FORCE',
entity_type: 'mikrotik_login',
entity_id: `${r.name}:${attackerIp}`,
details: {
router: r.name,
routerHost: r.host,
attackerIp,
service: data.service,
count: data.count,
targets: Array.from(data.targets)
},
ip_address: attackerIp
});
}
}
}
} catch (err) {
logger.warn(`[SecurityWatchdog] Scan router ${r.name} error: ${err.message}`);
}
}
} catch (e) {
logger.error(`[SecurityWatchdog] Scan MikroTik failures fatal: ${e.message}`);
}
}
/**
* Ringkasan status keamanan saat ini
*/
getSecurityOverview() {
const blockedList = [];
const now = Date.now();
for (const [ip, unblockTime] of blockedIps.entries()) {
if (now < unblockTime) {
blockedList.push({ ip, remainingSeconds: Math.ceil((unblockTime - now) / 1000) });
}
}
let recentAudit = [];
try {
recentAudit = db.prepare(`
SELECT * FROM audit_trail
WHERE action IN ('BRUTE_FORCE_BLOCKED', 'ROUTER_BRUTE_FORCE')
ORDER BY id DESC LIMIT 10
`).all() || [];
} catch (_) {}
return {
status: blockedList.length > 0 ? 'warning' : 'secure',
activeBlockedIps: blockedList,
recentAlerts: recentAudit,
timestamp: new Date().toISOString()
};
}
/**
* Helper: Kirim notifikasi ke WhatsApp dan Telegram Admin
*/
async _notifyAdmin(message) {
try {
const adminPhone = getSetting('admin_phone') || getSetting('company_phone');
if (adminPhone) {
const { sendWA, whatsappStatus } = await import('./whatsappBot.mjs');
if (whatsappStatus && whatsappStatus.connection === 'open') {
await sendWA(adminPhone, message);
}
}
// Telegram Admin Notification
const adminChatId = getSetting('telegram_admin_chat_id') || getSetting('telegram_chat_id');
const botToken = getSetting('telegram_bot_token');
if (adminChatId && botToken) {
const axios = require('axios');
await axios.post(`https://api.telegram.org/bot${botToken}/sendMessage`, {
chat_id: adminChatId,
text: message,
parse_mode: 'Markdown'
}).catch(() => {});
}
} catch (err) {
logger.warn(`[SecurityWatchdog] Gagal kirim notifikasi siaga: ${err.message}`);
}
}
/**
* Helper: Catat audit log
*/
_recordAudit({ action, entity_type, entity_id, details, ip_address, user_agent }) {
try {
const audit = require('./auditTrailService');
if (audit && typeof audit.logAuditTrail === 'function') {
audit.logAuditTrail({
action,
entity_type,
entity_id,
actor_type: 'system',
actor_id: 'security-watchdog',
actor_name: 'AI Security Watchdog',
details,
ip_address,
user_agent
});
}
} catch (_) {}
}
}
const securityWatchdogService = new SecurityWatchdogService();
module.exports = securityWatchdogService;

View File

@@ -0,0 +1,346 @@
/**
* Service: Autonomous Self-Healing & Network Auto-Repair Engine
* Memindai anomali dan secara otomatis memperbaiki ketidaksesuaian antara
* Billing System, Router MikroTik, GenieACS TR-069, dan RADIUS Server.
*/
const db = require('../config/database');
const mikrotikService = require('./mikrotikService');
const billingSvc = require('./billingService');
const customerSvc = require('./customerService');
const customerDevice = require('./customerDeviceService');
const { logger } = require('../config/logger');
let auditSvc = null;
try { auditSvc = require('./auditTrailService'); } catch (_) {}
let radiusSvc = null;
try { radiusSvc = require('./radiusServerService'); } catch (_) {}
class SelfHealingService {
/**
* Jalankan diagnosa lengkap tanpa eksekusi perbaikan (Dry Run / Scan Mode)
*/
async diagnose() {
return this.run({ autoRepair: false });
}
/**
* Jalankan diagnosa DAN perbaikan otomatis (Healing Mode)
*/
async autoRepair(actorName = 'AI Self-Healing Agent') {
return this.run({ autoRepair: true, actorName });
}
/**
* Mesin Utama Pemindaian & Perbaikan
*/
async run({ autoRepair = true, actorName = 'System' } = {}) {
const startTime = Date.now();
const result = {
timestamp: new Date().toISOString(),
mode: autoRepair ? 'repair' : 'scan_only',
actor: actorName,
stats: {
customersChecked: 0,
routersChecked: 0,
anomaliesFound: 0,
anomaliesRepaired: 0,
failedRepairs: 0
},
repairs: [],
issues: [],
warnings: [],
errors: []
};
try {
// ─────────────────────────────────────────────────────────────────
// 1. SINKRONISASI KONSISTENSI STATUS BILLING VS TAGIHAN PELANGGAN
// ─────────────────────────────────────────────────────────────────
await this._healBillingCustomerStatus(result, autoRepair);
// ─────────────────────────────────────────────────────────────────
// 2. SINKRONISASI MIKROTIK (PPPoE SECRETS, ISOLIR, FIREWALL)
// ─────────────────────────────────────────────────────────────────
await this._healMikrotikConsistency(result, autoRepair);
// ─────────────────────────────────────────────────────────────────
// 3. PEMBERSIHAN SESI RADIUS YANG STALE / KADALUARSA
// ─────────────────────────────────────────────────────────────────
await this._healRadiusSessions(result, autoRepair);
// ─────────────────────────────────────────────────────────────────
// 4. SINKRONISASI GENIEACS & TR-069 MODEM ONU
// ─────────────────────────────────────────────────────────────────
await this._healAcsDevices(result, autoRepair);
result.durationMs = Date.now() - startTime;
// Catat di Audit Trail jika ada aksi perbaikan yang dieksekusi
if (autoRepair && result.repairs.length > 0 && auditSvc && typeof auditSvc.logAuditTrail === 'function') {
try {
auditSvc.logAuditTrail({
action: 'UPDATE',
entity_type: 'self_healing',
entity_id: 'network_auto_repair',
actor_type: 'system',
actor_id: 'ai-agent',
actor_name: actorName,
details: {
repairedCount: result.stats.anomaliesRepaired,
repairs: result.repairs,
issues: result.issues
}
});
} catch (_) {}
}
logger.info(`[SelfHealing] Pemindaian selesai dalam ${result.durationMs}ms. Ditemukan: ${result.stats.anomaliesFound}, Diperbaiki: ${result.stats.anomaliesRepaired}`);
} catch (err) {
logger.error(`[SelfHealing] Fatal error during self-healing: ${err.message}`);
result.errors.push(`Fatal error: ${err.message}`);
}
return result;
}
/**
* 1. Perbaiki Inkonsistensi Status Billing Pelanggan
* - Pelanggan berstatus isolir/suspended padahal semua tagihannya sudah lunas -> Buka Isolir
*/
async _healBillingCustomerStatus(result, autoRepair) {
try {
// Cari pelanggan yang statusnya isolated/suspended tetapi TIDAK punya invoice unpaid
const query = `
SELECT c.id, c.name, c.status, c.pppoe_username, c.router_id,
COUNT(CASE WHEN i.status != 'paid' THEN 1 END) as unpaid_count
FROM customers c
LEFT JOIN invoices i ON c.id = i.customer_id
WHERE LOWER(c.status) IN ('isolated', 'suspended', 'isolir')
GROUP BY c.id
HAVING unpaid_count = 0
`;
const falseIsolated = db.prepare(query).all() || [];
for (const cust of falseIsolated) {
result.stats.anomaliesFound++;
const desc = `Pelanggan #${cust.id} (${cust.name}) berstatus '${cust.status}' padahal seluruh tagihannya telah LUNAS.`;
result.issues.push({ type: 'false_isolation', customerId: cust.id, name: cust.name, description: desc });
if (autoRepair) {
try {
// Ubah status ke active di database
db.prepare("UPDATE customers SET status = 'active', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(cust.id);
// Eksekusi buka isolir di MikroTik jika terhubung
if (cust.router_id && cust.pppoe_username) {
await mikrotikService.unisolateCustomerPppoe(cust.router_id, cust.pppoe_username).catch(() => {});
}
result.stats.anomaliesRepaired++;
result.repairs.push(`[Billing] Buka isolir & aktifkan pelanggan #${cust.id} (${cust.name}) karena tagihan sudah lunas.`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal memulihkan status pelanggan #${cust.id}: ${e.message}`);
}
}
}
} catch (e) {
logger.error(`[SelfHealing] Error healing billing status: ${e.message}`);
}
}
/**
* 2. Perbaiki Inkonsistensi MikroTik (Secrets, Profiles, Address-Lists)
*/
async _healMikrotikConsistency(result, autoRepair) {
try {
const routers = db.prepare("SELECT * FROM routers WHERE is_active = 1").all() || [];
result.stats.routersChecked = routers.length;
for (const r of routers) {
let isOnline = false;
try {
isOnline = await mikrotikService.checkConnection(r.id);
} catch (_) {}
if (!isOnline) {
result.warnings.push(`Router "${r.name}" (${r.host}) offline atau API port tidak merespons.`);
continue;
}
// Ambil data secrets dan active sessions dari router
let secrets = [];
try {
secrets = await mikrotikService.getPppoeSecrets(r.id) || [];
} catch (e) {
result.warnings.push(`Gagal membaca PPP Secrets dari router "${r.name}": ${e.message}`);
continue;
}
const secretMap = new Map();
for (const s of secrets) {
if (s.name) secretMap.set(s.name.toLowerCase(), s);
}
// Ambil daftar pelanggan yang dialokasikan ke router ini
const customers = db.prepare(`
SELECT c.*, p.name as package_name, p.billing_type as package_billing_type
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
WHERE c.router_id = ? AND c.pppoe_username IS NOT NULL AND c.pppoe_username != ''
`).all(r.id) || [];
result.stats.customersChecked += customers.length;
for (const cust of customers) {
const userLower = (cust.pppoe_username || '').trim().toLowerCase();
if (!userLower) continue;
const sec = secretMap.get(userLower);
const isActiveCustomer = String(cust.status || '').toLowerCase() === 'active';
const isIsolatedCustomer = ['isolated', 'suspended', 'isolir'].includes(String(cust.status || '').toLowerCase());
const targetProfile = cust.package_name || 'default';
// KASUS A: Pelanggan Aktif, tetapi Secret Belum Dibuat di MikroTik
if (isActiveCustomer && !sec) {
result.stats.anomaliesFound++;
const desc = `Pelanggan aktif #${cust.id} (${cust.name}) belum memiliki Secret PPPoE '${cust.pppoe_username}' di router "${r.name}".`;
result.issues.push({ type: 'missing_secret', customerId: cust.id, username: cust.pppoe_username, description: desc });
if (autoRepair) {
try {
await mikrotikService.createPppoeSecret({
username: cust.pppoe_username,
password: cust.pppoe_password || cust.pppoe_username,
profile: targetProfile,
routerId: r.id
});
result.stats.anomaliesRepaired++;
result.repairs.push(`[MikroTik] Dibuatkan Secret PPPoE baru '${cust.pppoe_username}' (Profile: ${targetProfile}) di router "${r.name}".`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal membuat secret '${cust.pppoe_username}': ${e.message}`);
}
}
}
// KASUS B: Pelanggan Aktif, tetapi Secret di MikroTik Disabled atau Masih Berprofil ISOLIR
else if (isActiveCustomer && sec) {
const isSecDisabled = sec.disabled === 'true' || sec.disabled === true;
const isSecIsolirProfile = String(sec.profile || '').toUpperCase().includes('ISOLIR');
if (isSecDisabled || isSecIsolirProfile) {
result.stats.anomaliesFound++;
const desc = `Pelanggan aktif #${cust.id} (${cust.name}) di MikroTik berstatus disabled (${isSecDisabled}) atau profil masih '${sec.profile}'.`;
result.issues.push({ type: 'active_but_blocked_secret', customerId: cust.id, username: cust.pppoe_username, description: desc });
if (autoRepair) {
try {
await mikrotikService.updatePppoeSecret({
username: cust.pppoe_username,
password: cust.pppoe_password || sec.password,
profile: targetProfile,
disabled: false,
routerId: r.id
});
// Buka juga dari firewall isolir list jika ada
await mikrotikService.unisolateCustomerPppoe(r.id, cust.pppoe_username).catch(() => {});
result.stats.anomaliesRepaired++;
result.repairs.push(`[MikroTik] Mengaktifkan kembali Secret '${cust.pppoe_username}' ke profil '${targetProfile}' di router "${r.name}".`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal memulihkan secret '${cust.pppoe_username}': ${e.message}`);
}
}
}
}
// KASUS C: Pelanggan Terisolir di Billing, tetapi di MikroTik Masih Aktif / Profil Normal
else if (isIsolatedCustomer && sec) {
const isSecDisabled = sec.disabled === 'true' || sec.disabled === true;
const isSecIsolirProfile = String(sec.profile || '').toUpperCase().includes('ISOLIR');
if (!isSecDisabled && !isSecIsolirProfile) {
result.stats.anomaliesFound++;
const desc = `Pelanggan terisolir #${cust.id} (${cust.name}) di MikroTik masih aktif dengan profil normal '${sec.profile}'.`;
result.issues.push({ type: 'isolated_not_enforced', customerId: cust.id, username: cust.pppoe_username, description: desc });
if (autoRepair) {
try {
await mikrotikService.isolateCustomerPppoe(r.id, cust.pppoe_username, { pppoeProfile: 'ISOLIR' });
await mikrotikService.kickPppoeUser(cust.pppoe_username, r.id).catch(() => {});
result.stats.anomaliesRepaired++;
result.repairs.push(`[MikroTik] Menerapkan isolir & kick sesi '${cust.pppoe_username}' di router "${r.name}".`);
} catch (e) {
result.stats.failedRepairs++;
result.errors.push(`Gagal menerapkan isolir '${cust.pppoe_username}': ${e.message}`);
}
}
}
}
}
}
} catch (e) {
logger.error(`[SelfHealing] Error healing MikroTik consistency: ${e.message}`);
}
}
/**
* 3. Bersihkan Sesi RADIUS yang Menggantung (Zombie / Stale)
*/
async _healRadiusSessions(result, autoRepair) {
try {
if (!radiusSvc) return;
const onlineCountBefore = (radiusSvc.getOnlineSessions() || []).length;
if (autoRepair && typeof radiusSvc.cleanupStaleSessions === 'function') {
const cleaned = await radiusSvc.cleanupStaleSessions();
if (cleaned > 0) {
result.stats.anomaliesFound += cleaned;
result.stats.anomaliesRepaired += cleaned;
result.repairs.push(`[RADIUS Server] Membersihkan ${cleaned} sesi accounting yang menggantung (stale).`);
}
}
} catch (e) {
logger.warn(`[SelfHealing] RADIUS session check: ${e.message}`);
}
}
/**
* 4. Sinkronisasi Perangkat ACS TR-069
*/
async _healAcsDevices(result, autoRepair) {
try {
const count = db.prepare("SELECT COUNT(*) as c FROM acs_devices WHERE ip_address IS NULL OR ip_address = ''").get();
if (count && count.c > 0) {
result.stats.anomaliesFound += count.c;
if (autoRepair) {
// Trigger update IP dari MikroTik active sessions
const activeSessionsMap = await mikrotikService.getActivePppoeSessionsMap().catch(() => new Map());
if (activeSessionsMap && activeSessionsMap.size > 0) {
let fixed = 0;
const devs = db.prepare("SELECT id, params FROM acs_devices WHERE ip_address IS NULL OR ip_address = ''").all() || [];
for (const d of devs) {
let p = {};
try { p = JSON.parse(d.params || '{}'); } catch (_) {}
const user = p['VirtualParameters.pppoeUsername'] || p['Device.PPP.Interface.1.Username'];
if (user && activeSessionsMap.has(user.toLowerCase())) {
const ip = activeSessionsMap.get(user.toLowerCase()).ip;
if (ip) {
db.prepare("UPDATE acs_devices SET ip_address = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(ip, d.id);
fixed++;
}
}
}
if (fixed > 0) {
result.stats.anomaliesRepaired += fixed;
result.repairs.push(`[ACS TR-069] Memperbarui IP address untuk ${fixed} modem ONU dari sesi aktif MikroTik.`);
}
}
}
}
} catch (e) {
logger.warn(`[SelfHealing] ACS check: ${e.message}`);
}
}
}
const selfHealingService = new SelfHealingService();
module.exports = selfHealingService;

View File

@@ -0,0 +1,132 @@
/**
* Service: batas jumlah sesi login per akun.
*
* - admin / kasir / teknisi / kolektor : maksimal 2 sesi aktif per akun
* - pelanggan : maksimal 3 sesi aktif per akun
*
* Sesi yang melebihi batas akan dihapus dari tabel `sessions` (cookie lama
* otomatis tidak berlaku), bukan sekadar diabaikan. Rotasi session id saat
* login mencegah session fixation.
*/
const db = require('../config/database');
const { logger } = require('../config/logger');
const DEFAULT_LIMITS = {
admin: 2,
cashier: 2,
technician: 2,
collector: 2,
customer: 3
};
try {
db.exec(`
CREATE TABLE IF NOT EXISTS login_sessions (
sid TEXT PRIMARY KEY,
role TEXT NOT NULL,
account_id TEXT NOT NULL,
ip TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_sessions_account ON login_sessions(role, account_id);
`);
} catch (e) {
logger.error('[SessionLimit] Gagal membuat tabel login_sessions: ' + e.message);
}
function now() { return Date.now(); }
/** Hapus baris yang sesi aslinya sudah tidak ada / kedaluwarsa. */
function purgeDead() {
try {
db.prepare(`
DELETE FROM login_sessions
WHERE sid NOT IN (SELECT sid FROM sessions WHERE expires_at IS NULL OR expires_at > ?)
`).run(now());
} catch (e) { /* tabel sessions belum ada saat init awal */ }
}
function rotateSession(req) {
return new Promise((resolve, reject) => {
const oldSid = req.sessionID;
req.session.regenerate((err) => {
if (err) return reject(err);
try {
if (oldSid && oldSid !== req.sessionID) {
db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(oldSid);
db.prepare('DELETE FROM sessions WHERE sid = ?').run(oldSid);
}
} catch (e) { /* abaikan */ }
resolve();
});
});
}
/**
* Catat sesi login dan terapkan batas maksimal.
* @returns {{ allowed: boolean, active: number, limit: number }}
*/
function registerLoginSession(req, { role, accountId, limit, ip = '', userAgent = '' }) {
const r = String(role || '').toLowerCase();
const acc = String(accountId == null ? '' : accountId);
const max = Number(limit) > 0 ? Number(limit) : (DEFAULT_LIMITS[r] || 2);
const sid = req.sessionID;
if (!sid || !r || !acc) return { allowed: false, active: 0, limit: max };
purgeDead();
const existing = db.prepare('SELECT sid FROM login_sessions WHERE sid = ?').get(sid);
if (existing) {
db.prepare('UPDATE login_sessions SET last_seen = ? WHERE sid = ?').run(now(), sid);
return { allowed: true, active: countActive(r, acc), limit: max };
}
const rows = db.prepare(
'SELECT sid FROM login_sessions WHERE role = ? AND account_id = ? ORDER BY created_at ASC'
).all(r, acc);
const excess = rows.length - max + 1;
if (excess > 0) {
for (const row of rows.slice(0, excess)) {
try {
db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(row.sid);
db.prepare('DELETE FROM sessions WHERE sid = ?').run(row.sid);
logger.info(`[SessionLimit] Sesi lama ${r}:${acc} diputus (${row.sid.slice(0, 8)}…) karena melebihi batas ${max}.`);
} catch (e) { /* abaikan */ }
}
}
db.prepare(`
INSERT INTO login_sessions (sid, role, account_id, ip, user_agent, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(sid, r, acc, String(ip || '').slice(0, 64), String(userAgent || '').slice(0, 255), now(), now());
return { allowed: true, active: countActive(r, acc), limit: max };
}
function countActive(role, accountId) {
try {
const row = db.prepare(
'SELECT COUNT(*) as c FROM login_sessions WHERE role = ? AND account_id = ?'
).get(String(role), String(accountId));
return Number(row?.c || 0);
} catch (e) { return 0; }
}
function removeLoginSession(req) {
try {
if (req.sessionID) db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(req.sessionID);
} catch (e) { /* abaikan */ }
}
module.exports = {
DEFAULT_LIMITS,
rotateSession,
registerLoginSession,
removeLoginSession,
countActive,
purgeDead
};

View File

@@ -0,0 +1,85 @@
const db = require('../config/database');
const { logger } = require('../config/logger');
// Pastikan tabel soundbox_events ada
try {
db.exec(`
CREATE TABLE IF NOT EXISTS soundbox_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
title TEXT NOT NULL,
speech_text TEXT NOT NULL,
amount INTEGER DEFAULT 0,
customer_name TEXT DEFAULT '',
is_read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_soundbox_created ON soundbox_events(created_at);
`);
} catch (e) {
logger.error('[Soundbox] Init table error: ' + e.message);
}
function formatRupiahSpoken(n) {
const num = Number(n || 0);
return num.toLocaleString('id-ID') + ' rupiah';
}
/**
* Memasukkan notifikasi pembayaran masuk ke antrean Soundbox
*/
function queueSoundboxEvent({ type = 'payment', title = 'Pembayaran Masuk', speechText = '', amount = 0, customerName = '' }) {
try {
if (!speechText) {
const spokenAmt = formatRupiahSpoken(amount);
if (type === 'voucher') {
speechText = `Pembayaran voucher hotspot ${customerName ? 'paket ' + customerName : ''} sebesar ${spokenAmt} berhasil diterima.`;
} else if (type === 'invoice') {
speechText = `Pembayaran tagihan ${customerName ? 'atas nama ' + customerName : ''} sebesar ${spokenAmt} berhasil lunas.`;
} else if (type === 'donation') {
speechText = `Donasi masuk sebesar ${spokenAmt} ${customerName ? 'dari ' + customerName : ''} berhasil diterima.`;
} else if (type === 'topup') {
speechText = `Top up saldo ${customerName ? 'atas nama ' + customerName : ''} sebesar ${spokenAmt} berhasil masuk.`;
} else {
speechText = `Pembayaran sebesar ${spokenAmt} berhasil diterima.`;
}
}
const res = db.prepare(`
INSERT INTO soundbox_events (type, title, speech_text, amount, customer_name, created_at)
VALUES (?, ?, ?, ?, ?, (NOW_LOCAL()))
`).run(type, title, speechText, Math.floor(amount || 0), customerName);
// Bersihkan entri lama jika > 200 agar database tetap ramping
db.prepare('DELETE FROM soundbox_events WHERE id < (SELECT COALESCE(MAX(id), 0) - 200 FROM soundbox_events)').run();
logger.info(`[Soundbox] Event #${res.lastInsertRowid} (${type}): ${speechText}`);
return res.lastInsertRowid;
} catch (err) {
logger.error('[Soundbox] Queue error: ' + err.message);
return null;
}
}
/**
* Mengambil event terbaru untuk polling browser
*/
function getLatestEvents(afterId = 0, limit = 10) {
try {
const after = Number(afterId || 0);
if (after > 0) {
return db.prepare('SELECT * FROM soundbox_events WHERE id > ? ORDER BY id ASC LIMIT ?').all(after, limit);
} else {
return db.prepare('SELECT * FROM soundbox_events ORDER BY id DESC LIMIT ?').all(limit).reverse();
}
} catch (err) {
logger.error('[Soundbox] Fetch error: ' + err.message);
return [];
}
}
module.exports = {
queueSoundboxEvent,
getLatestEvents,
formatRupiahSpoken
};

View File

@@ -9,20 +9,56 @@ function getTechById(id) {
return db.prepare('SELECT id, username, name, phone, area FROM technicians WHERE id = ?').get(id); return db.prepare('SELECT id, username, name, phone, area FROM technicians WHERE id = ?').get(id);
} }
/**
* Batas wilayah tugas teknisi.
* area kosong / "semua" / "all" = tanpa batas (semua wilayah).
*/
function getTechAreaWhere(technician, tableAlias = 'c') {
let tech = technician;
if (typeof technician === 'number' || typeof technician === 'string') {
tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(Number(technician));
}
const areas = String(tech?.area || '')
.split(',')
.map(a => a.trim().toLowerCase())
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
if (areas.length > 0) {
const placeholders = areas.map(() => '?').join(', ');
return {
where: `LOWER(TRIM(${tableAlias}.area)) IN (${placeholders})`,
params: areas,
isRestricted: true,
areas,
};
}
return { where: '1=1', params: [], isRestricted: false, areas: [] };
}
function canTechAccessCustomer(technician, customer) {
if (!technician) return false;
const filter = getTechAreaWhere(technician);
if (!filter.isRestricted) return true;
const custArea = String(customer?.area || '').trim().toLowerCase();
return !!custArea && filter.areas.includes(custArea);
}
function getTechStats(techId) { function getTechStats(techId) {
const total = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ?").get(techId).count; const total = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ?").get(techId).count;
const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'in_progress'").get(techId).count; const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'in_progress'").get(techId).count;
const resolved = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'resolved'").get(techId).count; const resolved = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'resolved'").get(techId).count;
// Ambil open tickets yang belum ada teknisinya atau di-assign ke teknisi ini const filter = techId ? getTechAreaWhere(techId, 'c') : null;
const open = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count; const open = filter && filter.isRestricted
? db.prepare(`SELECT COUNT(*) as count FROM tickets t JOIN customers c ON t.customer_id = c.id WHERE t.status = 'open' AND ${filter.where}`).get(...filter.params).count
: db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
return { total, open, inProgress, resolved }; return { total, open, inProgress, resolved };
} }
function getAssignedTickets(techId) { function getAssignedTickets(techId) {
return db.prepare(` return db.prepare(`
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
FROM tickets t FROM tickets t
JOIN customers c ON t.customer_id = c.id JOIN customers c ON t.customer_id = c.id
WHERE t.technician_id = ? AND t.status != 'resolved' WHERE t.technician_id = ? AND t.status != 'resolved'
@@ -30,19 +66,21 @@ function getAssignedTickets(techId) {
`).all(techId); `).all(techId);
} }
function getOpenTickets() { function getOpenTickets(techId = null) {
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
const areaWhere = filter && filter.isRestricted ? `AND ${filter.where}` : '';
return db.prepare(` return db.prepare(`
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
FROM tickets t FROM tickets t
JOIN customers c ON t.customer_id = c.id JOIN customers c ON t.customer_id = c.id
WHERE t.status = 'open' WHERE t.status = 'open' ${areaWhere}
ORDER BY t.created_at DESC ORDER BY t.created_at DESC
`).all(); `).all(...(filter && filter.isRestricted ? filter.params : []));
} }
function getResolvedTickets(techId) { function getResolvedTickets(techId) {
return db.prepare(` return db.prepare(`
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
FROM tickets t FROM tickets t
JOIN customers c ON t.customer_id = c.id JOIN customers c ON t.customer_id = c.id
WHERE t.technician_id = ? AND t.status = 'resolved' WHERE t.technician_id = ? AND t.status = 'resolved'
@@ -52,11 +90,27 @@ function getResolvedTickets(techId) {
function takeTicket(ticketId, techId) { function takeTicket(ticketId, techId) {
let validTechId = Number(techId); let validTechId = Number(techId);
const exists = db.prepare('SELECT id FROM technicians WHERE id = ?').get(validTechId); let tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(validTechId);
if (!exists) { if (!tech) {
const fallback = db.prepare('SELECT id FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get(); const fallback = db.prepare('SELECT id, area FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
if (fallback) validTechId = fallback.id; if (!fallback) throw new Error('Teknisi tidak ditemukan.');
validTechId = fallback.id;
tech = fallback;
} }
const ticket = db.prepare(`
SELECT t.id, t.customer_id, t.status, c.area as customer_area
FROM tickets t
LEFT JOIN customers c ON t.customer_id = c.id
WHERE t.id = ?
`).get(ticketId);
if (!ticket) throw new Error('Tiket tidak ditemukan.');
if (ticket.status !== 'open') throw new Error('Tiket sudah diambil oleh teknisi lain atau sudah selesai.');
if (ticket.customer_id && !canTechAccessCustomer(tech, { area: ticket.customer_area })) {
throw new Error(`Tiket ini berada di wilayah "${ticket.customer_area || 'Tanpa Area'}", di luar wilayah tugas Anda (${tech.area}).`);
}
db.prepare("UPDATE tickets SET technician_id = ?, status = 'in_progress', updated_at = (NOW_LOCAL()) WHERE id = ?").run(validTechId, ticketId); db.prepare("UPDATE tickets SET technician_id = ?, status = 'in_progress', updated_at = (NOW_LOCAL()) WHERE id = ?").run(validTechId, ticketId);
return validTechId; return validTechId;
} }
@@ -102,6 +156,8 @@ function createTechnician(data) {
module.exports = { module.exports = {
authenticate, authenticate,
getTechById, getTechById,
getTechAreaWhere,
canTechAccessCustomer,
getTechStats, getTechStats,
getAssignedTickets, getAssignedTickets,
getOpenTickets, getOpenTickets,

View File

@@ -0,0 +1,904 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { getSetting } = require('../config/settingsManager');
const { logger } = require('../config/logger');
const { adminToolDeclarations, executeAdminTool } = require('./adminAiTools');
// Cache untuk dokumen knowledge base
let cachedKnowledge = null;
let lastLoadTime = 0;
const KNOWLEDGE_RELOAD_INTERVAL = 3600000; // Reload tiap 1 jam jika ada update doc
/**
* Muat dan ringkas dokumentasi aplikasi agar efisien dalam prompt AI
*/
function loadKnowledgeBase() {
const now = Date.now();
if (cachedKnowledge && (now - lastLoadTime < KNOWLEDGE_RELOAD_INTERVAL)) {
return cachedKnowledge;
}
try {
const docPath = path.join(__dirname, '../FITUR_APLIKASI_LENGKAP.md');
if (fs.existsSync(docPath)) {
const fullDoc = fs.readFileSync(docPath, 'utf8');
// Ambil seluruh dokumentasi fitur & panduan pemecahan masalah (mendukung context besar Gemini)
cachedKnowledge = fullDoc.length > 350000 ? fullDoc.substring(0, 340000) + '\n\n...(dokumentasi dipotong untuk efisiensi context)...' : fullDoc;
lastLoadTime = now;
logger.info(`[TelegramAI] Knowledge base loaded (${cachedKnowledge.length} bytes).`);
return cachedKnowledge;
}
} catch (err) {
logger.warn(`[TelegramAI] Gagal memuat FITUR_APLIKASI_LENGKAP.md: ${err.message}`);
}
// Fallback ringkasan jika file tidak terbaca
cachedKnowledge = `
Aplikasi: RTRWNET Management & Billing System (Botlokal Customer Portal)
Tech Stack: Node.js (>=20.0.0), Express.js, SQLite (better-sqlite3), EJS, Baileys WA, RouterOS API, SNMP OLT, GenieACS TR-069.
Fitur Utama:
1. Billing & Invoicing: Auto generate invoice tiap tanggal billing, reminder WA otomatis (H-3, H-1, saat tempo), auto isolir jika jatuh tempo, denda telat, kode unik nominal pembayaran.
2. MikroTik: Multi-router support, sinkronisasi secret PPPoE & user hotspot, auto create profile, auto isolir via address-list / pool, parser script mikhmon.
3. GenieACS & TR-069: Auto provisioning ONU baru, cek redaman (optical power RX/TX), reboot ONU jarak jauh, ganti SSID dan sandi WiFi langsung via web/bot, status WAN & PPPoE uptime.
4. OLT & SNMP: Dukungan OLT EPON & GPON (HIOSO, VSOL, ZTE, dll), monitor port PON, auto-discovery unconfigured ONU.
5. Payment Gateway: Tripay, Midtrans, Xendit, Duitku, QRIS Dinamis & Statis, notifikasi mutasi otomatis via SMS/MacroDroid.
6. WhatsApp & Telegram: Notifikasi tagihan, broadcast massal, chatbot interaktif pelanggan, bot Telegram untuk admin (cek Mikrotik, reboot ONU, cari pelanggan, ringkasan keuangan) & asisten grup.
7. Portal Pelanggan: Cek tagihan tanpa login, riwayat invoice, ganti WiFi sendiri, beli voucher, tiket komplain gangguan.
8. FreeRADIUS Server: Otentikasi PPPoE/Hotspot, radius accounting, dynamic pool isolir.
`;
lastLoadTime = now;
return cachedKnowledge;
}
/**
* Ambil status live seluruh pelanggan, MikroTik, dan GenieACS untuk Admin WhatsApp / Telegram
*/
async function getAdminGlobalContext(userQuestion) {
try {
const db = require('../config/database');
const customerDevice = require('./customerDeviceService');
const mikrotikService = require('./mikrotikService');
// 1. Ambil ringkasan seluruh pelanggan dari SQLite
const query = `
SELECT
c.id, c.name, c.phone, c.status, c.pppoe_username, c.genieacs_tag, c.area,
p.name as package_name, p.price as package_price,
COUNT(CASE WHEN i.status != 'paid' THEN 1 END) as unpaid_count,
COALESCE(SUM(CASE WHEN i.status != 'paid' THEN i.amount ELSE 0 END), 0) as unpaid_amount
FROM customers c
LEFT JOIN packages p ON c.package_id = p.id
LEFT JOIN invoices i ON c.id = i.customer_id
GROUP BY c.id
ORDER BY c.id ASC
`;
const customers = db.prepare(query).all();
const total = customers.length;
const active = customers.filter(c => (c.status || '').toLowerCase() === 'active').length;
const isolated = customers.filter(c => ['isolated', 'suspended', 'isolir'].includes((c.status || '').toLowerCase())).length;
const inactive = customers.filter(c => ['inactive', 'nonaktif', 'terminated'].includes((c.status || '').toLowerCase())).length;
const unpaidCustomers = customers.filter(c => c.unpaid_count > 0);
const totalUnpaidAmount = unpaidCustomers.reduce((acc, c) => acc + (c.unpaid_amount || 0), 0);
let contextText = `=== RINGKASAN GLOBAL SISTEM RTRW-NET (REALTIME ADMIN CONTEXT) ===\n`;
contextText += `Total Pelanggan Terdaftar: ${total} pelanggan\n`;
contextText += `- Aktif: ${active} pelanggan\n`;
contextText += `- Terisolir / Suspended: ${isolated} pelanggan\n`;
contextText += `- Non-Aktif / Terminated: ${inactive} pelanggan\n`;
contextText += `Tunggakan Belum Lunas: ${unpaidCustomers.length} pelanggan (Total Tunggakan: Rp ${totalUnpaidAmount.toLocaleString('id-ID')})\n\n`;
// 2. Direktori ringkas seluruh pelanggan
contextText += `=== DIREKTORI SELURUH PELANGGAN (ID | Nama | No HP | Status | PPPoE | Paket | Tunggakan) ===\n`;
for (const c of customers) {
const unpaidStr = c.unpaid_count > 0 ? `${c.unpaid_count} tagihan (Rp ${c.unpaid_amount.toLocaleString('id-ID')})` : 'Lunas';
contextText += `[ID:${c.id}] ${c.name} | ${c.phone || '-'} | Status: ${c.status} | PPPoE: ${c.pppoe_username || '-'} | Pkt: ${c.package_name || '-'} | Tunggakan: ${unpaidStr}\n`;
}
contextText += `\n`;
// 3. Deteksi apakah admin menanyakan pelanggan tertentu
const qLower = String(userQuestion || '').toLowerCase();
const matchedCustomers = [];
for (const c of customers) {
let isMatch = false;
const name = String(c.name || '').toLowerCase().trim();
const pppoe = String(c.pppoe_username || '').toLowerCase().trim();
const phoneDigits = String(c.phone || '').replace(/\D/g, '');
const tag = String(c.genieacs_tag || '').toLowerCase().trim();
const idStr = String(c.id);
// Cek ID
if (new RegExp(`\\b(id|#)\\s*${idStr}\\b`, 'i').test(qLower)) {
isMatch = true;
}
// Cek No HP
else if (phoneDigits.length >= 8 && qLower.includes(phoneDigits.slice(-8))) {
isMatch = true;
}
// Cek PPPoE username
else if (pppoe.length >= 3 && qLower.includes(pppoe)) {
isMatch = true;
}
// Cek GenieACS Tag
else if (tag.length >= 3 && qLower.includes(tag)) {
isMatch = true;
}
// Cek Nama Pelanggan
else if (name.length >= 3) {
if (qLower.includes(name)) {
isMatch = true;
} else {
const words = name.split(/\s+/).filter(w => w.length >= 4);
for (const w of words) {
if (['bisa', 'tolong', 'admin', 'saya', 'yang', 'dari', 'untuk', 'paket', 'status', 'kabel', 'modem', 'wifi', 'cek'].includes(w)) continue;
if (qLower.includes(w)) {
isMatch = true;
break;
}
}
}
}
if (isMatch) {
matchedCustomers.push(c);
if (matchedCustomers.length >= 3) break;
}
}
// 4. Jika ada pelanggan yang cocok, lakukan Deep Dive (Invoices + GenieACS + MikroTik)
if (matchedCustomers.length > 0) {
contextText += `=== DETAIL MENDALAM PELANGGAN YANG DITANYAKAN ===\n`;
let activeSessionsMap = new Map();
try {
activeSessionsMap = await Promise.race([
mikrotikService.getActivePppoeSessionsMap(),
new Promise((_, reject) => setTimeout(() => reject(new Error('MikroTik timeout')), 4000))
]).catch(() => new Map());
} catch (_) {}
for (const mc of matchedCustomers) {
const invRows = db.prepare('SELECT id, period_month, period_year, amount, status, paid_at, created_at FROM invoices WHERE customer_id = ? ORDER BY id DESC LIMIT 5').all(mc.id);
const unpaidInvs = invRows.filter(i => i.status !== 'paid');
const paidInvs = invRows.filter(i => i.status === 'paid');
let onuData = null;
const lookupTag = mc.genieacs_tag || mc.pppoe_username || mc.phone;
if (lookupTag) {
try {
onuData = await Promise.race([
customerDevice.getCustomerDeviceData(lookupTag),
new Promise((_, reject) => setTimeout(() => reject(new Error('GenieACS timeout')), 4000))
]).catch(() => null);
} catch (_) {}
}
const pppoeUser = (mc.pppoe_username || '').toLowerCase();
const pppoeSession = pppoeUser ? activeSessionsMap.get(pppoeUser) : null;
contextText += `\n>> PELANGGAN: ${mc.name} (ID: ${mc.id})\n`;
contextText += ` - No HP: ${mc.phone || '-'}\n`;
contextText += ` - Status Billing: ${mc.status}\n`;
contextText += ` - Area: ${mc.area || '-'}\n`;
contextText += ` - Paket: ${mc.package_name || '-'} (Rp ${Number(mc.package_price || 0).toLocaleString('id-ID')}/bulan)\n`;
contextText += ` - Akun PPPoE: ${mc.pppoe_username || '-'}\n`;
contextText += ` - Status PPPoE MikroTik: ${pppoeSession ? `ONLINE (IP: ${pppoeSession.ip}, Uptime: ${pppoeSession.uptime}, Router: ${pppoeSession.routerName || '-'})` : 'OFFLINE / Belum dial'}\n`;
if (onuData) {
contextText += ` - Status Modem ONU: ${onuData.status || 'Unknown'}\n`;
contextText += ` - Redaman Optical (RX Power): ${onuData.rxPower ? onuData.rxPower + ' dBm' : '-'}\n`;
contextText += ` - Nama WiFi (SSID): ${onuData.ssid || '-'}\n`;
contextText += ` - ONU Uptime: ${onuData.uptime || '-'}\n`;
contextText += ` - Pengguna WiFi Terhubung: ${onuData.totalAssociations || 0} user\n`;
} else {
contextText += ` - Status Modem ONU: (Data modem belum terhubung/tag GenieACS belum diisi)\n`;
}
contextText += ` - Rincian Tunggakan Tagihan: ${unpaidInvs.length > 0 ? unpaidInvs.map(i => `#${i.id} (Periode ${i.period_month}/${i.period_year}: Rp ${Number(i.amount).toLocaleString('id-ID')})`).join(', ') : 'TIDAK ADA (Semua tagihan LUNAS)'}\n`;
if (paidInvs.length > 0) {
contextText += ` - Tagihan Terakhir Lunas: #${paidInvs[0].id} (Periode ${paidInvs[0].period_month}/${paidInvs[0].period_year} sebesar Rp ${Number(paidInvs[0].amount).toLocaleString('id-ID')} pada ${paidInvs[0].paid_at || '-'})\n`;
}
}
}
return contextText;
} catch (err) {
logger.error(`[TelegramAI] Gagal mengambil admin global context: ${err.message}`);
return '';
}
}
/**
* Buat System Instruction Terpadu untuk AI
*/
function buildSystemInstruction(isAdmin, customerContext, adminContext, senderName) {
const knowledge = loadKnowledgeBase();
if (isAdmin) {
return `Anda adalah asisten cerdas, serba tahu, dan handal yang mendampingi Pemilik / Administrator "RTRWNET Management & Billing System" (Botlokal).
PEDOMAN UTAMA & ATURAN JAWABAN:
1. TANPA BASA-BASI / TANPA DISCLAIMER KAKU:
- DILARANG KERAS menggunakan frasa robotik seperti: "Sebagai Asisten AI...", "Sebagai Asisten AI Eksekutif...", "Sebagai model bahasa...", "Mohon maaf sepertinya Anda bercanda/terpotong...", atau "Saya hanya bisa membantu seputar billing...".
- Langsung berikan jawaban yang cerdas, tepat sasaran, to-the-point, dan santun layaknya rekan kerja manusia yang pintar dan berwawasan luas.
2. JAWAB SEMUA PERTANYAAN (TERMASUK DI LUAR TOPIK BILLING):
- Jika admin menanyakan hal apa pun di luar topik billing (seperti pengetahuan umum, tokoh dunia/politik, sains, sejarah, teknologi, edukasi, coding, resep masakan, cuaca, tips harian, obrolan santai, dll.), JAWABLAH DENGAN LENGKAP, AKURAT, DAN CERDAS menggunakan seluruh kemampuan wawasan umum Anda sendiri!
- JANGAN PERNAH menolak pertanyaan atau menganggap pertanyaan bercanda/salah hanya karena bukan tentang internet/billing.
3. DATA OPERASIONAL LIVE & STATUS PELANGGAN:
- Jika admin menanyakan status pelanggan tertentu (misal: "apakah diva nunggak?", "cek redaman diva", "siapa yang terisolir?", "kenapa pelanggan X putus?"), gunakan DATA OPERASIONAL LIVE di bawah ini untuk menjawab secara presisi dan profesional.
- Jika admin menanyakan ringkasan (misal: "berapa yang isolir?", "siapa saja yang belum bayar?", "total uang tunggakan?"), rangkum dengan angka pasti dan sebutkan nama-nama pelanggan secara rapi.
4. SISTEM, TEKNIS, & TROUBLESHOOTING JARINGAN:
- Jika admin menanyakan kendala teknis (MikroTik API timeout, redaman jelek/LOS, isolir bypass, webhook payment gateway, dll.), berikan diagnosis penyebab dan solusi konkret mengacu pada DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH di bawah.
5. EKSEKUSI PERINTAH ADMINISTRATIF VIA TOOLS:
- Anda DILENGKAPI FUNCTION TOOLS untuk mengeksekusi aksi administratif. Jika pesan admin mengandung instruksi / perintah tindakan (tambah pelanggan, hapus pelanggan, reboot/restart modem ONU, ganti password/SSID WiFi, lunasi tagihan, isolir, buka isolir, kick user aktif, buat voucher hotspot, generate invoice bulanan, cek saldo digiflazz, atau top up agent), PANGGIL FUNCTION TOOL YANG SESUAI dengan parameter yang diekstrak dari percakapan.
- Untuk hapus pelanggan: SELALU panggil tool 'delete_customer_request' agar sistem meminta konfirmasi 2 langkah yang aman.
- Untuk tambah pelanggan: Panggil 'add_customer' dengan mengekstrak data dari pesan admin.
- Jika admin HANYA bertanya informasi tanpa memerintahkan perubahan data/eksekusi aksi, JANGAN panggil tool pengubah data, melainkan berikan jawaban teks langsung.
6. FORMAT LISAN NATURAL (VOICE & CHAT READY - TANPA SIMBOL & TANPA EMOJI):
- Jawaban Anda akan dibacakan langsung oleh mesin Text-to-Speech (TTS) dan ditampilkan pada aplikasi mobile.
- DILARANG KERAS MENGGUNAKAN SIMBOL ATAU EMOJI APAPUN (seperti lingkaran warna 🟢, 🔴, 🟡, ⚪, peringatan ⚠️, sinyal 📡, centang ✅, silang ❌, robot 🤖, dll.) karena akan dibaca salah oleh mesin suara menjadi "lingkaran hijau besar", "lingkaran merah", dll.
- DILARANG MENGGUNAKAN KARAKTER MARKDOWN KAKU seperti bullet points (•, -, *), tanda pagar (#), tanda bintang ganda, backticks, atau tabel.
- Jangan menyajikan data teknis mentah baris-demi-baris yang kaku. SEDERHANAKAN DATA dan rangkum menjadi kalimat percakapan yang mengalir enak didengar.
- Tambahkan logat bicara yang santun, luwes, hangat, dan ramah seperti asisten pribadi profesional, jangan kaku atau robotik.
- DILARANG KERAS menggunakan header formal di awal pesan atau template penutup klise di akhir pesan (seperti "Semoga membantu", "Ada yang bisa dibantu lagi").
- Langsung berikan penjelasan atau ringkasan data ke inti jawaban secara bersahabat.
7. PENYEDERHANAAN DAFTAR PANJANG (DATA DISAJIKAN RINGKAS & LISAN):
- Jika data yang ditanyakan berjumlah banyak (seperti daftar penunggak, pelanggan aktif, dll.), DILARANG KERAS menyebutkan puluhan nama satu per satu karena akan terlalu panjang dan melelahkan saat didengarkan.
- CUKUP SEBUTKAN total jumlahnya, total nilai nominalnya, dan sebutkan 3 sampai 5 nama teratas sebagai perwakilan.
- Akhiri dengan tawaran santun, misalnya: "Jika Anda ingin mengecek rincian pelanggan tertentu, Anda cukup sebutkan namanya."
DATA OPERASIONAL LIVE (ADMIN ACCESS):
${adminContext || '(Tidak ada data kontekstual khusus)'}
DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH:
${knowledge}
`;
}
let liveCustomerDataPrompt = '';
if (customerContext) {
liveCustomerDataPrompt = `
----------------------------------------
DATA STATUS LIVE PELANGGAN (Dari Database Server Billing):
- Nama Pelanggan: ${customerContext.customerName || senderName}
- Paket Layanan: ${customerContext.package || '-'}
- Akun PPPoE: ${customerContext.pppoeUsername || '-'}
- Status Langganan: ${customerContext.customerStatus || 'Active'}
- Status Tagihan Saat Ini: ${customerContext.hasUnpaid ? `MEMILIKI ${customerContext.unpaidCount} TAGIHAN BELUM LUNAS (Total: Rp ${customerContext.totalUnpaidAmount.toLocaleString('id-ID')})` : (customerContext.allPaid ? 'SEMUA TAGIHAN SUDAH LUNAS' : 'Belum ada data tagihan')}
${customerContext.unpaidInvoices ? `Rincian Tagihan Belum Lunas:\n${customerContext.unpaidInvoices}` : ''}
PETUNJUK KHUSUS JIKA MENANYAKAN TAGIHAN / TUNGGAKAN / PEMBAYARAN:
Jika pelanggan bertanya mengenai sisa tagihan, status pembayaran, jumlah uang yang harus dibayar, atau tunggakan miliknya, gunakan DATA STATUS LIVE di atas! Sebutkan nama pelanggan, total tagihan yang belum lunas beserta rincian periode bulannya secara sopan, jelas, dan ramah. Jika semua tagihan sudah lunas, beritahukan bahwa tagihan sudah lunas dan ucapkan terima kasih.
----------------------------------------
`;
}
return `Anda adalah asisten cerdas, ramah, dan solutif untuk komunitas dan layanan pelanggan "RTRWNET Management & Billing System" (Botlokal).
PEDOMAN UTAMA & ATURAN JAWABAN:
1. TANPA BASA-BASI KAKU:
- DILARANG menggunakan kata-kata klise seperti "Sebagai Asisten AI...", "Sebagai model bahasa...", atau menolak pertanyaan. Langsung jawab ke inti pertanyaan dengan ramah, santun, dan bersahabat.
2. JAWAB PERTANYAAN UMUM & DI LUAR APLIKASI:
- Jika anggota grup atau pelanggan bertanya tentang topik umum di luar aplikasi (pengetahuan umum, sains, teknologi, komputer, tips harian, edukasi, obrolan santai), JAWABLAH DENGAN CERDAS, INFORMATIF, DAN BERMANFAAT menggunakan seluruh wawasan Anda sendiri.
3. PERTANYAAN TERKAIT FITUR & TROUBLESHOOTING APLIKASI:
- Jika berkaitan dengan layanan internet, cara bayar tagihan, cara ganti sandi wifi, cek tagihan, atau troubleshooting jaringan rumah, jawablah dengan panduan praktis yang mengacu pada DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH di bawah.
4. PERTANYAAN DATA TAGIHAN PRIBADI PELANGGAN:
- Jika tersedia DATA STATUS LIVE PELANGGAN di bawah, gunakan informasi riil tersebut untuk menjawab status tunggakan atau pembayaran pelanggan secara sopan, jelas, dan ramah.
5. FORMAT LISAN NATURAL & BEBAS SIMBOL/EMOJI:
- Jawablah seperti percakapan personal yang wajar, luwes, ramah, dan santun layaknya manusia (tanpa robotik).
- DILARANG menggunakan simbol grafis atau emoji apapun (lingkaran 🟢, 🔴, 🟡, ⚠️, 💰, 📋, 🙏, dll.) dan hindari karakter formatting kaku (bullet point, bintang, pagar).
- DILARANG menggunakan header kaku atau kalimat footer penutup klise.
- Sampaikan penjelasan dengan bahasa yang santun, mudah dipahami, dan mengalir natural saat dibacakan suara.
${liveCustomerDataPrompt}
DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH:
${knowledge}
`;
}
/**
* Konversi skema function calling Gemini ke format standar OpenAI tools
*/
function convertGeminiToolsToOpenAI(declarations) {
if (!Array.isArray(declarations) || declarations.length === 0) return [];
function lowercaseTypes(schema) {
if (!schema || typeof schema !== 'object') return schema;
const copy = Array.isArray(schema) ? [] : {};
for (const [key, val] of Object.entries(schema)) {
if (key === 'type' && typeof val === 'string') {
copy[key] = val.toLowerCase();
} else if (typeof val === 'object' && val !== null) {
copy[key] = lowercaseTypes(val);
} else {
copy[key] = val;
}
}
return copy;
}
return declarations.map(decl => ({
type: 'function',
function: {
name: decl.name,
description: decl.description,
parameters: decl.parameters ? lowercaseTypes(decl.parameters) : { type: 'object', properties: {} }
}
}));
}
/**
* Panggil Google Gemini API
*/
async function callGeminiApi(apiKey, userQuestion, senderName = 'Member', customerContext = null, isAdmin = false, adminContext = null) {
const models = ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-flash'];
const preferredModel = getSetting('gemini_model', 'gemini-2.5-flash');
const modelList = [preferredModel, ...models.filter(m => m !== preferredModel)];
const systemInstruction = buildSystemInstruction(isAdmin, customerContext, adminContext, senderName);
let lastError = null;
for (const model of modelList) {
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const payload = {
contents: [
{
role: 'user',
parts: [
{
text: `${systemInstruction}\n\nPesan / Pertanyaan dari ${isAdmin ? `Admin (${senderName})` : senderName}:\n${userQuestion}`
}
]
}
],
generationConfig: {
temperature: 0.2,
maxOutputTokens: 1024
}
};
if (isAdmin && Array.isArray(adminToolDeclarations) && adminToolDeclarations.length > 0) {
payload.tools = [
{
functionDeclarations: adminToolDeclarations
}
];
}
const response = await axios.post(url, payload, {
headers: { 'Content-Type': 'application/json' },
timeout: 25000
});
const candidate = response.data?.candidates?.[0];
const parts = candidate?.content?.parts || [];
// Periksa apakah model memutuskan memanggil function tool
const funcPart = parts.find(p => p.functionCall);
if (funcPart && funcPart.functionCall) {
return {
type: 'function_call',
functionCall: {
name: funcPart.functionCall.name,
args: funcPart.functionCall.args || {}
}
};
}
// Jawaban teks biasa
const textPart = parts.find(p => p.text && p.text.trim());
if (textPart && textPart.text) {
return {
type: 'text',
text: textPart.text.trim()
};
}
} catch (err) {
lastError = err;
const status = err.response?.status;
if (status === 404) {
logger.warn(`[TelegramAI] Model ${model} tidak tersedia (404), mencoba model cadangan...`);
continue;
}
if (status === 429) {
logger.warn(`[TelegramAI] Model ${model} kena limit (429), mencoba model cadangan...`);
continue;
}
if (status === 400) {
throw new Error('API Key Gemini tidak valid atau belum diaktifkan.');
}
logger.warn(`[TelegramAI] Error calling ${model}: ${err.message}`);
}
}
throw lastError || new Error('Gagal mendapatkan respon dari AI.');
}
/**
* Panggil OpenAI-Compatible API (OpenAI, DeepSeek, Groq, OpenRouter, Custom / Local Ollama)
*/
async function callOpenAiCompatibleApi({
apiKey,
baseUrl,
model,
userQuestion,
senderName = 'Member',
customerContext = null,
isAdmin = false,
adminContext = null
}) {
const systemInstruction = buildSystemInstruction(isAdmin, customerContext, adminContext, senderName);
const cleanBaseUrl = String(baseUrl || 'https://api.openai.com/v1').trim().replace(/\/+$/, '');
const url = `${cleanBaseUrl}/chat/completions`;
const messages = [
{
role: 'system',
content: systemInstruction
},
{
role: 'user',
content: `Pesan / Pertanyaan dari ${isAdmin ? `Admin (${senderName})` : senderName}:\n${userQuestion}`
}
];
const payload = {
model: model,
messages: messages,
temperature: 0.2
};
if (isAdmin && Array.isArray(adminToolDeclarations) && adminToolDeclarations.length > 0) {
payload.tools = convertGeminiToolsToOpenAI(adminToolDeclarations);
payload.tool_choice = 'auto';
}
const headers = {
'Content-Type': 'application/json'
};
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
}
if (cleanBaseUrl.includes('openrouter.ai')) {
headers['HTTP-Referer'] = 'https://alijayanet.net';
headers['X-Title'] = 'Alijaya Billing System';
}
const response = await axios.post(url, payload, {
headers,
timeout: 35000
});
const choice = response.data?.choices?.[0];
const message = choice?.message;
// Periksa apakah model memutuskan memanggil function tool
if (message?.tool_calls && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
const firstCall = message.tool_calls[0];
let parsedArgs = {};
if (typeof firstCall.function?.arguments === 'string') {
try {
parsedArgs = JSON.parse(firstCall.function.arguments);
} catch (e) {
parsedArgs = {};
}
} else if (typeof firstCall.function?.arguments === 'object' && firstCall.function?.arguments !== null) {
parsedArgs = firstCall.function.arguments;
}
return {
type: 'function_call',
functionCall: {
name: firstCall.function?.name,
args: parsedArgs || {}
}
};
}
// Jawaban teks biasa
if (message?.content && typeof message.content === 'string' && message.content.trim()) {
return {
type: 'text',
text: message.content.trim()
};
}
throw new Error('Respon AI kosong dari provider.');
}
/**
* Fallback Jawaban Cerdas berbasis Kata Kunci jika belum ada Gemini API Key atau offline
*/
function getOfflineAnswer(question, customerContext = null, isAdmin = false, adminContext = null) {
const q = String(question || '').toLowerCase();
// Khusus Admin jika offline: Jawab ringkasan data riil dari database
if (isAdmin) {
try {
const db = require('../config/database');
// 1. Cek pertanyaan tunggakan / tagihan belum lunas
if (q.includes('tunggak') || q.includes('belum bayar') || q.includes('tagihan') || q.includes('piutang') || q.includes('nunggak')) {
const unpaidList = db.prepare(`
SELECT c.id, c.name, c.phone, c.status, COUNT(i.id) as count, SUM(i.amount) as total
FROM customers c
JOIN invoices i ON c.id = i.customer_id
WHERE i.status != 'paid'
GROUP BY c.id
ORDER BY total DESC
`).all();
if (unpaidList.length === 0) {
return 'Alhamdulillah, saat ini seluruh pelanggan sudah melunasi tagihannya dan tidak ada tunggakan.';
}
const totalNominal = unpaidList.reduce((sum, item) => sum + Number(item.total || 0), 0);
const topNames = unpaidList.slice(0, 3).map(c => c.name).join(', ');
return `Saat ini ada ${unpaidList.length} pelanggan yang memiliki tagihan belum lunas dengan total nilai sekitar ${totalNominal.toLocaleString('id-ID')} rupiah. Beberapa di antaranya yaitu ${topNames}. Apakah Anda ingin rincian tagihan tertentu?`;
}
// 2. Cek pertanyaan isolir / terisolir / putus
if (q.includes('isolir') || q.includes('suspend') || q.includes('putus')) {
const isolatedList = db.prepare(`
SELECT id, name, phone, pppoe_username, area
FROM customers
WHERE LOWER(status) IN ('isolated', 'suspended', 'isolir')
ORDER BY id ASC
`).all();
if (isolatedList.length === 0) {
return 'Saat ini tidak ada pelanggan yang sedang terisolir. Seluruh pelanggan terpantau dalam status aktif dan normal.';
}
const isoNames = isolatedList.map(c => c.name).join(', ');
return `Saat ini ada ${isolatedList.length} pelanggan yang sedang terisolir, yaitu atas nama ${isoNames}. Layanan mereka akan langsung aktif begitu tagihan dilunasi.`;
}
// 3. Cek total / ringkasan / rekap
if (q.includes('total') || q.includes('berapa') || q.includes('rekap') || q.includes('ringkasan') || q.includes('pelanggan')) {
const stats = db.prepare(`
SELECT
COUNT(*) as total,
COUNT(CASE WHEN LOWER(status) = 'active' THEN 1 END) as active,
COUNT(CASE WHEN LOWER(status) IN ('isolated', 'suspended', 'isolir') THEN 1 END) as isolated,
COUNT(CASE WHEN LOWER(status) IN ('inactive', 'terminated', 'nonaktif') THEN 1 END) as inactive
FROM customers
`).get();
return `Saat ini tercatat ada total ${stats.total} pelanggan di dalam sistem. Rinciannya terdiri dari ${stats.active} pelanggan aktif, ${stats.isolated} pelanggan terisolir, dan ${stats.inactive} pelanggan nonaktif. Secara umum seluruh jaringan terpantau lancar.`;
}
// 4. Cek modem / redaman / ont / onu / sinyal
if (q.includes('modem') || q.includes('onu') || q.includes('ont') || q.includes('redaman') || q.includes('sinyal') || q.includes('kabel optik')) {
const allCustomers = db.prepare('SELECT id, name, pppoe_username, phone, status, genieacs_tag FROM customers').all();
let matchedCust = null;
for (const c of allCustomers) {
const lowerName = (c.name || '').toLowerCase();
const firstName = lowerName.split(/\s+/)[0];
if (firstName && firstName.length > 2 && q.includes(firstName)) {
matchedCust = c;
break;
}
}
if (matchedCust) {
return `Kondisi modem milik ${matchedCust.name} saat ini terpantau normal dan aktif di sistem. Status layanannya ${matchedCust.status === 'active' ? 'aktif lancar' : matchedCust.status}, dan koneksi internetnya berjalan baik.`;
}
return 'Secara keseluruhan kondisi modem pelanggan dan router terpantau aktif normal. Kualitas redaman optik ideal berada di kisaran minus lima belas sampai minus dua puluh dua desibel. Jika Anda ingin mengecek modem pelanggan tertentu, cukup sebutkan nama pelanggannya.';
}
} catch (err) {
logger.error(`[TelegramAI] Offline admin answer error: ${err.message}`);
}
}
// Jika pelanggan menanyakan tagihan/tunggakan dan ada data live pelanggan
if (customerContext && (q.includes('tagihan') || q.includes('tunggak') || q.includes('bayar') || q.includes('lunas') || q.includes('tempo') || q.includes('invoice'))) {
if (customerContext.hasUnpaid) {
return `Halo Kak ${customerContext.customerName}, saat ini ada ${customerContext.unpaidCount} tagihan belum lunas dengan total sebesar ${customerContext.totalUnpaidAmount.toLocaleString('id-ID')} rupiah. Pembayaran dapat dilakukan via transfer bank atau QRIS di portal pelanggan.`;
} else if (customerContext.allPaid) {
return `Halo Kak ${customerContext.customerName}, terima kasih banyak. Seluruh tagihan internet Anda sudah tercatat lunas dan koneksi internet Anda aktif lancar.`;
}
}
// --- DIAGNOSTIK & PEMECAHAN MASALAH (TROUBLESHOOTING) DENGAN BAHASA LISAN NATURAL ---
// 1. Masalah Sudah Lunas tapi Masih Isolir
if (q.includes('masih isolir') || (q.includes('sudah bayar') && q.includes('isolir')) || (q.includes('lunas') && q.includes('isolir')) || q.includes('sudah bayar tapi')) {
return 'Jika pelanggan sudah lunas tetapi statusnya masih terisolir, biasanya alamat IP pelanggan masih tersimpan di daftar isolir MikroTik. Anda dapat membuka menu data pelanggan di web lalu klik tombol buka isolir, atau katakan buka isolir diikuti nama pelanggan.';
}
// 2. Masalah Terisolir tapi Masih Bisa Internetan
if (q.includes('masih bisa') && (q.includes('isolir') || q.includes('suspend') || q.includes('putus'))) {
return 'Jika pelanggan berstatus terisolir namun masih dapat mengakses internet, kemungkinan sesi aktif lamanya belum diputus oleh router. Anda bisa melakukan kick sesi aktif atau memastikan aturan firewall isolir berada di urutan teratas pada router MikroTik.';
}
// 3. Masalah Koneksi MikroTik API (Timeout / Gagal Konek)
if (q.includes('tidak bisa konek') || q.includes('gagal konek') || q.includes('api timeout') || q.includes('port 8728') || q.includes('port 8729') || q.includes('service api')) {
return 'Jika sistem gagal terhubung ke MikroTik API, pastikan layanan API pada port delapan ribu tujuh ratus dua puluh delapan sudah diaktifkan di menu IP Service router, dan pastikan user serta password di menu Routers sudah sesuai.';
}
// 4. Masalah Redaman Optik / LOS Lampu Merah
if ((q.includes('redaman') && (q.includes('jelek') || q.includes('tinggi') || q.includes('drop') || q.includes('dbm') || q.includes('solusi') || q.includes('masalah') || q.includes('kenapa') || q.includes('buruk'))) || q.includes('los') || q.includes('lampu merah') || q.includes('kabel putus') || q.includes('rx power')) {
return 'Nilai redaman optik yang ideal berada di kisaran minus lima belas sampai minus dua puluh dua desibel. Jika redaman lebih tinggi dari minus dua puluh delapan desibel atau muncul lampu merah sinyal putus, periksa kemungkinan kabel optik tertekuk atau konektor kotor yang perlu dibersihkan.';
}
// 5. Masalah Webhook / Payment Gateway / QRIS
if (q.includes('webhook') || (q.includes('qris') && (q.includes('gagal') || q.includes('belum lunas') || q.includes('unpaid') || q.includes('tidak otomatis')))) {
return 'Jika pembayaran QRIS belum otomatis lunas, pastikan URL callback webhook di dashboard payment gateway sudah terpasang dengan benar dan server menggunakan protokol HTTPS yang valid. Anda juga bisa melunasi tagihan secara manual melalui perintah suara asisten.';
}
// 6. Masalah WhatsApp "Menunggu Pesan Ini" / Logout
if (q.includes('menunggu pesan ini') || q.includes('waiting for this message') || q.includes('wa logout') || q.includes('wa putus')) {
return 'Jika sesi WhatsApp sering terputus, pastikan ponsel yang terhubung memiliki koneksi internet yang stabil dan mode hemat baterai telah dimatikan. Anda juga bisa melakukan reset sesi dan scan ulang kode QR di menu WhatsApp Gateway.';
}
// 7. Masalah Telegram Bot
if (q.includes('telegram') && (q.includes('akses ditolak') || q.includes('conflict') || q.includes('409') || q.includes('tidak respon'))) {
return 'Jika akses bot Telegram ditolak, pastikan ID Telegram Anda sudah dimasukkan ke pengaturan Telegram Admin ID di web admin.';
}
// 8. Masalah Database SQLite Locked
if (q.includes('database is locked') || q.includes('sqlite_busy') || q.includes('restore database')) {
return 'Jika terjadi kendala database terkunci, sistem biasanya sedang memproses transaksi. Anda dapat melakukan restart service melalui perintah pm2 restart billing.';
}
// 9. Masalah Digiflazz
if (q.includes('digiflazz') && (q.includes('gagal') || q.includes('rc 40') || q.includes('rc 41') || q.includes('rc 42') || q.includes('saldo habis'))) {
return 'Untuk transaksi Digiflazz, pastikan IP publik server sudah didaftarkan ke whitelist IP di member area Digiflazz dan saldo deposit Anda mencukupi.';
}
if (q.includes('isolir') || q.includes('putus') || q.includes('tunggak')) {
return 'Sistem isolir berjalan otomatis sesuai tanggal jatuh tempo. Begitu tagihan dibayarkan, isolir pelanggan akan langsung dicabut secara otomatis tanpa perlu tindakan manual.';
}
if (q.includes('genieacs') || q.includes('tr069') || q.includes('tr-069') || q.includes('onu') || q.includes('redaman') || q.includes('modem')) {
return 'Sistem terhubung langsung dengan GenieACS untuk auto provisioning modem baru, pengecekan redaman optik secara berkala, serta restart modem dan perubahan kata sandi wifi dari jarak jauh.';
}
if (q.includes('olt') || q.includes('snmp') || q.includes('gpon') || q.includes('epon')) {
return 'Sistem mendukung manajemen OLT GPON dan EPON untuk memantau port PON, mendeteksi modem baru yang belum terkonfigurasi, dan monitoring redaman optik massal.';
}
if (q.includes('payment') || q.includes('tripay') || q.includes('midtrans') || q.includes('xendit') || q.includes('duitku') || q.includes('qris')) {
return 'Sistem mendukung beragam gateway pembayaran otomatis seperti QRIS, transfer bank, dan e-wallet yang langsung mencabut isolir begitu pembayaran sukses.';
}
if (q.includes('whatsapp') || q.includes('wa') || q.includes('baileys') || q.includes('notif')) {
return 'Layanan WhatsApp Gateway digunakan untuk notifikasi tagihan otomatis, pengingat jatuh tempo, dan chatbot pelanggan.';
}
if (q.includes('mikrotik') || q.includes('router') || q.includes('pppoe') || q.includes('hotspot')) {
return 'Integrasi MikroTik mendukung sinkronisasi akun PPPoE dan Hotspot, monitoring pengguna aktif, serta pemutusan sesi secara otomatis.';
}
if (q.includes('radius') || q.includes('freeradius')) {
return 'Fitur FreeRADIUS Server menyediakan autentikasi terpusat untuk akun PPPoE dan Hotspot dengan alokasi IP dinamis.';
}
if (q.includes('portal') || q.includes('pelanggan') || q.includes('customer')) {
return 'Pelanggan dapat mengakses portal mandiri untuk mengecek tagihan tanpa login, membayar via QRIS, melihat riwayat pembayaran, serta mengubah nama dan sandi wifi rumah sendiri.';
}
// Jawaban standar jika belum ada keyword yang cocok
return 'Halo, ada yang bisa saya bantu hari ini? Anda bisa menanyakan status modem pelanggan, info tagihan, jumlah pelanggan aktif, atau kondisi jaringan, dan saya siap membantu.';
}
/**
* Membersihkan jawaban AI agar tampil bersih tanpa simbol, emoji, atau header/footer formal
*/
function cleanAiResponse(text) {
if (!text || typeof text !== 'string') return '';
let cleaned = text.trim();
// 1. Hapus pembungkus code block markdown
if (cleaned.startsWith('```') && cleaned.endsWith('```')) {
cleaned = cleaned.replace(/^```[a-zA-Z]*\n?/, '').replace(/\n?```$/, '').trim();
}
cleaned = cleaned.replace(/```[\s\S]*?```/g, ' ');
cleaned = cleaned.replace(/`([^`]+)`/g, '$1');
// 2. Hapus header garis / separator
cleaned = cleaned.replace(/^[─—\-_=]{2,}\s*/gm, '');
cleaned = cleaned.replace(/\s*[─—\-_=]{2,}$/gm, '');
// 3. Hapus header bot / perusahaan di awal
cleaned = cleaned.replace(/^(?:🏢|🤖|🌐|💬)?\s*\*?(?:ALIJAYA|AliJaya|RTRWNET|RTRW-?Net|Asisten AI|Asisten Virtual|Bot Billing|Portal Billing)[^\n*]*\*?\n+[─—\-_=]{2,}\n*/gi, '');
cleaned = cleaned.replace(/^(?:🏢|🤖|🌐|💬)?\s*\*?(?:ALIJAYA|AliJaya|RTRWNET|RTRW-?Net|Asisten AI|Asisten Virtual)[^\n*]*\*?\n+/gi, '');
// 4. Hapus footer template atau slogan
cleaned = cleaned.replace(/\n+[─—\-_=]{2,}\n+.*(?:Internet Tanpa Batas|AliJaya|ALIJAYA|Semoga membantu|Ada yang bisa dibantu lagi).*$/gi, '');
cleaned = cleaned.replace(/\n+(?:Semoga membantu!?|Semoga informasinya bermanfaat!?|Ada yang bisa dibantu lagi\??|Ada hal lain yang perlu ditanyakan\??|Salam hangat,?[^\n]*)$/gi, '');
// 5. HAPUS SEMUA EMOJI & SIMBOL GRAFIS (lingkaran 🟢 🔴 🟡 ⚪ ⚠️ 📡 📶 💡 🤖 🧾 💳 ❌ ✅ dll.)
cleaned = cleaned.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{200D}\u{20E3}]/gu, ' ');
cleaned = cleaned.replace(/[🟢🔴🟡⚪⚫🟠🔵🟣🟥🟩🟦🟧🟨⭕❌✅⚠️💡🤖🧾💳📡📶🔒🔓🔄🔑👉⏱️📱🔧🏷️💾💬⚙️🛡️💰📋🙏✔️✖️◀️▶️▲▼◆■]/gu, ' ');
// 6. Hapus format markdown asterisks, hashes, bullets, pipes, brackets
cleaned = cleaned.replace(/^[•·▪▫➢➔★☆✓✕✖\-\*]\s+/gm, '');
cleaned = cleaned.replace(/[•·▪▫➢➔★☆✓✕✖]/g, ' ');
cleaned = cleaned.replace(/[*_~#|\\^$<>\[\]{}()]/g, ' ');
// 7. Bersihkan spasi ganda dan baris kosong berlebih
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
cleaned = cleaned.replace(/[ \t]+/g, ' ');
cleaned = cleaned.replace(/\s*([,.\?!])\s*/g, '$1 ');
cleaned = cleaned.replace(/,\s*,+/g, ',');
cleaned = cleaned.replace(/\.\s*\.+/g, '.');
return cleaned.trim();
}
/**
* Handle tanya jawab AI utama
*/
async function askAi({ question, senderName, customerContext = null, isAdmin = false, extraContext = {} }) {
const cleanQ = String(question || '').trim();
if (!cleanQ) {
return 'Silakan ajukan pertanyaan seputar fitur aplikasi atau layanan internet Anda.';
}
let adminContext = null;
if (isAdmin) {
try {
adminContext = await getAdminGlobalContext(cleanQ);
} catch (err) {
logger.error(`[TelegramAI] Gagal build admin context: ${err.message}`);
}
}
const provider = (getSetting('ai_provider', 'deepseek') || 'gemini').toLowerCase().trim();
let aiReply = null;
if (provider === 'gemini') {
const apiKey = getSetting('gemini_api_key', '');
if (apiKey && apiKey.trim()) {
try {
aiReply = await callGeminiApi(apiKey.trim(), cleanQ, senderName, customerContext, isAdmin, adminContext);
} catch (err) {
logger.error(`[TelegramAI] Gagal call Gemini API: ${err.message}. Menggunakan fallback jawaban lokal.`);
}
}
} else {
// Multi-provider OpenAI compatible: openai, deepseek, groq, openrouter, custom
let apiKey = '';
let baseUrl = '';
let model = '';
if (provider === 'openai') {
apiKey = getSetting('openai_api_key', '');
baseUrl = 'https://api.openai.com/v1';
model = getSetting('openai_model', 'gpt-4o-mini') || 'gpt-4o-mini';
} else if (provider === 'deepseek') {
apiKey = getSetting('deepseek_api_key', '');
baseUrl = 'https://api.deepseek.com';
model = getSetting('deepseek_model', 'deepseek-chat') || 'deepseek-chat';
} else if (provider === 'groq') {
apiKey = getSetting('groq_api_key', '');
baseUrl = 'https://api.groq.com/openai/v1';
model = getSetting('groq_model', 'llama-3.3-70b-versatile') || 'llama-3.3-70b-versatile';
} else if (provider === 'openrouter') {
apiKey = getSetting('openrouter_api_key', '');
baseUrl = 'https://openrouter.ai/api/v1';
model = getSetting('openrouter_model', 'meta-llama/llama-3.3-70b-instruct') || 'meta-llama/llama-3.3-70b-instruct';
} else if (provider === 'custom') {
apiKey = getSetting('custom_ai_api_key', '');
baseUrl = getSetting('custom_ai_base_url', 'http://localhost:11434/v1');
model = getSetting('custom_ai_model', 'llama3') || 'llama3';
}
if ((apiKey && apiKey.trim()) || provider === 'custom') {
try {
aiReply = await callOpenAiCompatibleApi({
apiKey: apiKey ? apiKey.trim() : (provider === 'custom' ? 'ollama' : ''),
baseUrl,
model,
userQuestion: cleanQ,
senderName,
customerContext,
isAdmin,
adminContext
});
} catch (err) {
logger.error(`[TelegramAI] Gagal call AI (${provider}): ${err.message}`);
// Fallback ke Gemini jika gemini_api_key tersedia
const geminiKey = getSetting('gemini_api_key', '');
if (geminiKey && geminiKey.trim()) {
try {
logger.info('[TelegramAI] Mencoba fallback ke Gemini API...');
aiReply = await callGeminiApi(geminiKey.trim(), cleanQ, senderName, customerContext, isAdmin, adminContext);
} catch (geminiErr) {
logger.error(`[TelegramAI] Fallback ke Gemini juga gagal: ${geminiErr.message}`);
}
}
}
}
}
// Jika API berhasil memberikan respon (baik function tool maupun teks)
if (aiReply) {
if (typeof aiReply === 'object' && aiReply?.type === 'function_call') {
const toolResult = await executeAdminTool(aiReply.functionCall.name, aiReply.functionCall.args, extraContext);
return cleanAiResponse(toolResult);
}
if (typeof aiReply === 'object' && aiReply?.type === 'text') {
return cleanAiResponse(aiReply.text);
}
return cleanAiResponse(typeof aiReply === 'string' ? aiReply : (aiReply?.text || JSON.stringify(aiReply)));
}
// Jika API key belum diatur atau semua panggilan API gagal, gunakan offline knowledge base
return cleanAiResponse(getOfflineAnswer(cleanQ, customerContext, isAdmin, adminContext));
}
/**
* Utilitas Diagnostik: Cek DNS via DNS-over-HTTPS (DoH Cloudflare / Google)
*/
async function checkDns(domain, type = 'A') {
try {
const cleanDomain = String(domain || '').trim().replace(/^https?:\/\//i, '').split('/')[0];
const recType = String(type || 'A').toUpperCase();
// Gunakan Cloudflare DoH untuk kehandalan tanpa terkena port 53 hijack ISP
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(cleanDomain)}&type=${recType}`;
const res = await axios.get(url, {
headers: { 'Accept': 'application/dns-json' },
timeout: 7000
});
const data = res.data;
if (!data.Answer || data.Answer.length === 0) {
return `🔍 *HASIL CEK DNS*\n\nTarget: \`${cleanDomain}\`\nRecord: *${recType}*\nStatus: ⚠️ Tidak ditemukan record ${recType} untuk domain ini.`;
}
let out = `🌐 *HASIL CEK DNS (Cloudflare Resolver)*\n\n`;
out += `Domain: \`${cleanDomain}\`\nRecord: *${recType}*\n\n*Data Jawaban:*\n`;
data.Answer.forEach(ans => {
out += `• \`${ans.data}\` (TTL: ${ans.TTL}s)\n`;
});
return out;
} catch (err) {
return `❌ Gagal melakukan lookup DNS: ${err.message}`;
}
}
/**
* Utilitas Diagnostik: Cek Host / Latency (HTTP Head Ping)
*/
async function checkPing(host) {
try {
let cleanHost = String(host || '').trim();
if (!cleanHost.startsWith('http://') && !cleanHost.startsWith('https://')) {
cleanHost = 'http://' + cleanHost;
}
const startTime = Date.now();
const res = await axios.head(cleanHost, { timeout: 8000, validateStatus: () => true });
const latency = Date.now() - startTime;
return `🏓 *HASIL PING / KONEKSI*\n\n` +
`Target: \`${cleanHost}\`\n` +
`Status: ✅ Terhubung (HTTP ${res.status})\n` +
`Latensi: *${latency} ms*`;
} catch (err) {
return `❌ Gagal menghubungi \`${host}\`: ${err.message}`;
}
}
module.exports = {
askAi,
cleanAiResponse,
checkDns,
checkPing,
getOfflineAnswer,
getAdminGlobalContext
};

View File

@@ -312,10 +312,25 @@ function initTelegram() {
bot.on('message', async (msg) => { bot.on('message', async (msg) => {
if (!isAdmin(msg)) return; if (!isAdmin(msg)) return;
const text = msg.text; const text = String(msg.text || '').trim();
if (text === '/start' || text === '/menu') return; // Handled by onText if (!text || text.startsWith('/')) return; // command ditangani onText
if (text === '/start' || text === '/menu') return;
// Logika handle text manual jika diperlukan (misal untuk perintah kick/edit)
try {
bot.sendChatAction(msg.chat.id, 'typing').catch(() => {});
const telegramAi = require('./telegramAiService');
const answer = await telegramAi.askAi({
question: text,
senderName: (msg.from && msg.from.first_name) || 'Admin',
isAdmin: true
});
if (!answer) return;
const clean = typeof telegramAi.cleanAiResponse === 'function' ? telegramAi.cleanAiResponse(answer) : answer;
await bot.sendMessage(msg.chat.id, clean, { parse_mode: 'Markdown', reply_to_message_id: msg.message_id })
.catch(() => bot.sendMessage(msg.chat.id, clean, { reply_to_message_id: msg.message_id }));
} catch (e) {
logger.error('[TelegramBot] AI error: ' + e.message);
}
}); });
// Callback Query Handling // Callback Query Handling

190
services/voiceTtsService.js Normal file
View File

@@ -0,0 +1,190 @@
const https = require('https');
const { logger } = require('../config/logger');
// Cache audio sederhana dalam memori (maks 50 entri)
const ttsCache = new Map();
/**
* Membersihkan dan menerjemahkan format teks (markdown, emoji, simbol, istilah teknis)
* menjadi bahasa Indonesia lisan yang santun, luwes, dan natural saat dibacakan oleh mesin suara TTS.
*/
function cleanTextForSpeech(text) {
if (!text || typeof text !== 'string') return '';
let str = text;
// 1. Hapus blok kode, backtick, link markdown, html tags
str = str.replace(/```[\s\S]*?```/g, ' ');
str = str.replace(/`([^`]+)`/g, '$1');
str = str.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
str = str.replace(/<[^>]+>/g, ' ');
// 2. Terjemahkan simbol mata uang & nomor
str = str.replace(/\bRp\.?\s*([0-9.,]+)\b/gi, '$1 rupiah');
str = str.replace(/\bNo\.\s*/gi, 'nomor ');
str = str.replace(/\bNo\s+(\d+)/gi, 'nomor $1');
// 3. Terjemahkan nilai redaman optik & tanda minus angka
str = str.replace(/-(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, 'minus $1 desibel');
str = str.replace(/\b(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, '$1 desibel');
str = str.replace(/(^|\s)-(\d+(?:\.\d+)?)/g, '$1minus $2');
// 4. Terjemahkan singkatan & istilah teknis ke bahasa percakapan santun
str = str.replace(/\bRX\s*Power\b/gi, 'daya sinyal');
str = str.replace(/\bTX\s*Power\b/gi, 'daya kirim');
str = str.replace(/\bONU\b|\bONT\b/gi, 'modem');
str = str.replace(/\bPPPoE\b/gi, 'pe pe po e');
str = str.replace(/\bSSID\b/gi, 'nama wifi');
str = str.replace(/\bUptime\b/gi, 'waktu aktif');
str = str.replace(/\bIP\s*Address\b|\bIP\b/gi, 'alamat i pe');
str = str.replace(/\bDNS\b/gi, 'de en es');
str = str.replace(/\bAPI\b/gi, 'a pe i');
str = str.replace(/\bWA\b/gi, 'whatsapp');
str = str.replace(/\bQRIS\b/gi, 'kris');
str = str.replace(/\bLOS\b/gi, 'lampu merah sinyal putus');
str = str.replace(/\b(\d+)\s*Mbps\b/gi, '$1 mega bit per detik');
str = str.replace(/\b(\d+)\s*Kbps\b/gi, '$1 kilo bit per detik');
str = str.replace(/\b(\d+)\s*Gbps\b/gi, '$1 giga bit per detik');
str = str.replace(/\b(\d+)\s*ms\b/gi, '$1 mili detik');
// 5. Terjemahkan simbol umum
str = str.replace(/&/g, ' dan ');
str = str.replace(/%/g, ' persen ');
str = str.replace(/\+/g, ' plus ');
str = str.replace(/=/g, ' adalah ');
str = str.replace(/@/g, ' di ');
str = str.replace(/\//g, ' atau ');
// 6. HAPUS SEMUA EMOJI & SIMBOL GRAFIS (lingkaran warna hijau, merah, kuning, dll.)
str = str.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{200D}\u{20E3}]/gu, ' ');
str = str.replace(/[🟢🔴🟡⚪⚫🟠🔵🟣🟥🟩🟦🟧🟨⭕❌✅⚠️💡🤖🧾💳📡📶🔒🔓🔄🔑👉⏱️📱🔧🏷️💾💬⚙️🛡️💰📋🙏✔️✖️◀️▶️▲▼◆■]/gu, ' ');
// 7. Hapus markdown bullets, pembatas, dan format kaku
str = str.replace(/^[•·▪▫➢➔★☆✓✕✖\-\*]\s+/gm, '');
str = str.replace(/[•·▪▫➢➔★☆✓✕✖]/g, ' ');
str = str.replace(/^[─—\-_=]{2,}\s*$/gm, '');
str = str.replace(/[*_~#|\\^$<>\[\]{}()]/g, ' ');
// 8. Hapus semua karakter selain huruf, angka, spasi, dan tanda jeda suara (. , ? !)
str = str.replace(/[^\p{L}\p{N}\s,.\?!]/gu, ' ');
// 9. Normalisasi jeda tanda baca dan spasi
str = str.replace(/\s*([,.\?!])\s*/g, '$1 ');
str = str.replace(/,\s*,+/g, ',');
str = str.replace(/\.\s*\.+/g, '.');
str = str.replace(/\s+/g, ' ').trim();
return str;
}
/**
* Memecah teks panjang menjadi potongan kalimat (< 180 karakter)
* agar pas dengan batasan API TTS tanpa memotong kata di tengah.
*/
function splitTextIntoChunks(text, maxLen = 180) {
if (!text) return [];
const sentences = text.match(/[^.!?\n,]+[.!?\n,]*/g) || [text];
const chunks = [];
let currentChunk = '';
for (const sentence of sentences) {
const trimmed = sentence.trim();
if (!trimmed) continue;
if (trimmed.length > maxLen) {
// Jika satu kalimat saja sudah lebih dari maxLen, pecah berdasarkan kata
const words = trimmed.split(/\s+/);
for (const word of words) {
if ((currentChunk + ' ' + word).trim().length > maxLen) {
if (currentChunk.trim()) chunks.push(currentChunk.trim());
currentChunk = word;
} else {
currentChunk = (currentChunk + ' ' + word).trim();
}
}
} else if ((currentChunk + ' ' + trimmed).trim().length > maxLen) {
if (currentChunk.trim()) chunks.push(currentChunk.trim());
currentChunk = trimmed;
} else {
currentChunk = (currentChunk + ' ' + trimmed).trim();
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
}
/**
* Download satu potongan audio MP3 dari Google Translate TTS (Bahasa Indonesia)
*/
function fetchTtsChunk(chunkText, lang = 'id') {
return new Promise((resolve, reject) => {
const url = `https://translate.google.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(chunkText)}&tl=${lang}&client=tw-ob`;
const req = https.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 10000
}, (res) => {
if (res.statusCode !== 200) {
return reject(new Error(`TTS gagal dengan status HTTP ${res.statusCode}`));
}
const data = [];
res.on('data', d => data.push(d));
res.on('end', () => resolve(Buffer.concat(data)));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('TTS timeout setelah 10 detik'));
});
});
}
/**
* Generate audio MP3 utuh dari teks bahasa Indonesia
*/
async function generateIndonesianSpeech(text) {
const clean = cleanTextForSpeech(text);
if (!clean) return null;
// Cek cache
const cacheKey = clean.toLowerCase();
if (ttsCache.has(cacheKey)) {
return ttsCache.get(cacheKey);
}
const chunks = splitTextIntoChunks(clean, 175);
if (chunks.length === 0) return null;
try {
const bufferList = [];
for (const chunk of chunks) {
const buf = await fetchTtsChunk(chunk, 'id');
bufferList.push(buf);
}
const combinedBuffer = Buffer.concat(bufferList);
// Simpan di cache (maksimal 50 item)
if (ttsCache.size > 50) {
const firstKey = ttsCache.keys().next().value;
ttsCache.delete(firstKey);
}
ttsCache.set(cacheKey, combinedBuffer);
return combinedBuffer;
} catch (err) {
logger.error(`[VoiceTTS] Gagal generate suara: ${err.message}`);
throw err;
}
}
module.exports = {
cleanTextForSpeech,
splitTextIntoChunks,
generateIndonesianSpeech
};

View File

@@ -0,0 +1,72 @@
/**
* Self-check: pembatasan wilayah teknisi & kolektor.
* Jalankan: node tests/unit/area-access.test.js
* DB dan modul berat di-stub supaya bisa jalan tanpa node_modules.
*/
const assert = require('assert');
const path = require('path');
function stub(rel, exports) {
const p = require.resolve(path.join(__dirname, '..', '..', rel));
require.cache[p] = { id: p, filename: p, loaded: true, exports };
}
const techRows = { 1: { id: 1, area: 'Area A, Area B' }, 2: { id: 2, area: '' }, 3: { id: 3, area: 'semua' } };
const colRows = { 7: { id: 7, area: 'Area A' }, 8: { id: 8, area: '' } };
const db = {
prepare(sql) {
return {
get(id) {
if (/FROM technicians/.test(sql)) return techRows[Number(id)];
if (/FROM collectors/.test(sql)) return colRows[Number(id)];
return undefined;
},
all() { return []; },
run() { return { changes: 1 }; },
};
},
exec() {},
};
stub('config/database', db);
stub('config/logger', { logger: { info() {}, warn() {}, error() {}, debug() {} } });
stub('config/settingsManager', { getCurrentDateInTimezone: () => new Date(), getSetting: () => '' });
stub('services/customerIdService', { generateCustomerCode: () => 'C0001', backfillCustomerCodes: () => 0 });
const tech = require('../../services/techService');
const cust = require('../../services/customerService');
// Teknisi: area dipisah koma -> dibatasi ke dua area
const tf = tech.getTechAreaWhere(1, 'c');
assert.strictEqual(tf.isRestricted, true);
assert.deepStrictEqual(tf.areas, ['area a', 'area b']);
assert.ok(tf.where.includes('IN (?, ?)'));
// Teknisi tanpa area / "semua" -> tidak dibatasi
assert.strictEqual(tech.getTechAreaWhere(2).isRestricted, false);
assert.strictEqual(tech.getTechAreaWhere(3).isRestricted, false);
// canTechAccessCustomer
assert.strictEqual(tech.canTechAccessCustomer(1, { area: 'area b' }), true);
assert.strictEqual(tech.canTechAccessCustomer(1, { area: 'area z' }), false);
assert.strictEqual(tech.canTechAccessCustomer(1, { area: '' }), false);
assert.strictEqual(tech.canTechAccessCustomer(2, { area: 'apa saja' }), true);
assert.strictEqual(tech.canTechAccessCustomer(null, { area: 'area a' }), false);
// Kolektor: pelanggan yang ditugaskan ke kolektor lain selalu ditolak
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area a', collector_id: 0 }), true);
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area a', collector_id: 7 }), true);
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area a', collector_id: 8 }), false);
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area z', collector_id: 0 }), false);
assert.strictEqual(cust.canCollectorAccessCustomer(8, { area: 'area z', collector_id: 0 }), true);
// Where kolektor memuat batas area + kolektor_id
const cf = cust.getCollectorCustomerWhere(7, 'c');
assert.strictEqual(cf.isRestricted, true);
assert.deepStrictEqual(cf.params, ['area a', 7]);
const cf2 = cust.getCollectorCustomerWhere(8, 'c');
assert.strictEqual(cf2.isRestricted, false);
assert.deepStrictEqual(cf2.params, [8]);
console.log('OK: area-access (teknisi & kolektor)');

View File

@@ -93,6 +93,9 @@
<div class="act-menu"> <div class="act-menu">
<button type="button" class="act-item" <%- `onclick="viewStock(${item.id}, '${item.name}')"` %>><i class="bi bi-eye"></i> <%= t('admin.inventory.detail_stock', 'Detail Stok') %></button> <button type="button" class="act-item" <%- `onclick="viewStock(${item.id}, '${item.name}')"` %>><i class="bi bi-eye"></i> <%= t('admin.inventory.detail_stock', 'Detail Stok') %></button>
<button type="button" class="act-item success" <%- `onclick="openAddStock(${item.id}, '${item.name}')"` %>><i class="bi bi-plus-circle"></i> <%= t('admin.inventory.add_stock', 'Tambah Stok') %></button> <button type="button" class="act-item success" <%- `onclick="openAddStock(${item.id}, '${item.name}')"` %>><i class="bi bi-plus-circle"></i> <%= t('admin.inventory.add_stock', 'Tambah Stok') %></button>
<button type="button" class="act-item" <%- `onclick="openIssueStock(${item.id}, '${item.name}')"` %>><i class="bi bi-box-arrow-up"></i> Stok Keluar</button>
<button type="button" class="act-item" <%- `onclick="openReturnStock(${item.id}, '${item.name}')"` %>><i class="bi bi-arrow-return-left"></i> Retur</button>
<button type="button" class="act-item" <%- `onclick="openAdjustStock(${item.id}, '${item.name}')"` %>><i class="bi bi-clipboard-check"></i> Opname</button>
<button type="button" class="act-item" <%- `onclick='openEditItem(${JSON.stringify(item)})'` %>><i class="bi bi-pencil"></i> Edit Barang</button> <button type="button" class="act-item" <%- `onclick='openEditItem(${JSON.stringify(item)})'` %>><i class="bi bi-pencil"></i> Edit Barang</button>
</div> </div>
</div> </div>
@@ -250,6 +253,76 @@
</div> </div>
</div> </div>
<!-- MODAL: STOK KELUAR -->
<div class="mo" id="modal-issue">
<div class="mb">
<div class="mh"><div class="mt">Stok Keluar</div><button class="mc" onclick="closeModal('modal-issue')">&times;</button></div>
<form action="/admin/inventory/stock/out" method="POST">
<input type="hidden" name="item_id" id="o_item_id">
<div class="mbody">
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="o_item_name" readonly></div>
<div class="fg"><label class="fl">Serial Number (kosongkan untuk barang tanpa SN)</label><input class="fc" name="serial_number" list="o_serials" placeholder="Pilih / scan SN"><datalist id="o_serials"></datalist></div>
<div class="form-row">
<div class="fg"><label class="fl">Jumlah</label><input class="fc" type="number" name="quantity" value="1" min="1"></div>
<div class="fg"><label class="fl">Tipe</label>
<select class="fs" name="type"><option value="out">Keluar / Operasional</option><option value="assigned">Terpasang ke Pelanggan</option><option value="broken">Rusak</option><option value="loss">Hilang</option></select>
</div>
</div>
<div class="fg"><label class="fl">Penerima / Teknisi</label><input class="fc" name="recipient" placeholder="Nama teknisi / pelanggan"></div>
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-issue')">Batal</button><button class="btn btn-p">Simpan</button></div>
</form>
</div>
</div>
<!-- MODAL: RETUR -->
<div class="mo" id="modal-return">
<div class="mb">
<div class="mh"><div class="mt">Retur Barang</div><button class="mc" onclick="closeModal('modal-return')">&times;</button></div>
<form action="/admin/inventory/stock/return" method="POST">
<input type="hidden" name="item_id" id="r_item_id">
<div class="mbody">
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="r_item_name" readonly></div>
<div class="fg"><label class="fl">Serial Number (untuk unit ber-SN)</label><input class="fc" name="serial_number" placeholder="Kosongkan untuk barang tanpa SN"></div>
<div class="form-row">
<div class="fg"><label class="fl">Jumlah</label><input class="fc" type="number" name="quantity" value="1" min="1"></div>
<div class="fg"><label class="fl">Kondisi</label>
<select class="fs" name="condition"><option value="used">Bekas / Copotan</option><option value="new">Baru</option><option value="broken">Rusak</option></select>
</div>
</div>
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-return')">Batal</button><button class="btn btn-p">Simpan</button></div>
</form>
</div>
</div>
<!-- MODAL: OPNAME -->
<div class="mo" id="modal-adjust">
<div class="mb">
<div class="mh"><div class="mt">Stock Opname</div><button class="mc" onclick="closeModal('modal-adjust')">&times;</button></div>
<form action="/admin/inventory/stock/adjust" method="POST">
<input type="hidden" name="item_id" id="a_item_id">
<div class="mbody">
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="a_item_name" readonly></div>
<div class="fg"><label class="fl">Jumlah Fisik di Gudang</label><input class="fc" type="number" name="actual_quantity" min="0" required></div>
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-adjust')">Batal</button><button class="btn btn-p">Simpan</button></div>
</form>
</div>
</div>
<!-- MODAL: DETAIL STOK -->
<div class="mo" id="modal-detail">
<div class="mb">
<div class="mh"><div class="mt" id="d_title">Detail Stok</div><button class="mc" onclick="closeModal('modal-detail')">&times;</button></div>
<div class="mbody" id="d_body"></div>
</div>
</div>
<script> <script>
var I18N_INV = { var I18N_INV = {
addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>", addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>",
@@ -288,6 +361,48 @@
document.getElementById('modal-stock').classList.add('show'); document.getElementById('modal-stock').classList.add('show');
} }
function openIssueStock(itemId, itemName) {
document.getElementById('o_item_id').value = itemId;
document.getElementById('o_item_name').value = itemName;
var dl = document.getElementById('o_serials');
dl.innerHTML = '';
fetch('/admin/api/inventory/serials/' + itemId).then(r => r.json()).then(d => {
(d.serials || []).forEach(function (s) {
var opt = document.createElement('option');
opt.value = s.serial_number;
dl.appendChild(opt);
});
}).catch(function () {});
document.getElementById('modal-issue').classList.add('show');
}
function openReturnStock(itemId, itemName) {
document.getElementById('r_item_id').value = itemId;
document.getElementById('r_item_name').value = itemName;
document.getElementById('modal-return').classList.add('show');
}
function openAdjustStock(itemId, itemName) {
document.getElementById('a_item_id').value = itemId;
document.getElementById('a_item_name').value = itemName;
document.getElementById('modal-adjust').classList.add('show');
}
function viewStock(itemId, itemName) {
document.getElementById('d_title').innerText = 'Detail Stok — ' + itemName;
var body = document.getElementById('d_body');
body.innerHTML = 'Memuat…';
fetch('/admin/api/inventory/item/' + itemId).then(r => r.json()).then(function (d) {
if (!d.success) { body.innerText = d.message || 'Gagal memuat'; return; }
var rows = (d.stockList || []).map(function (s) {
return '<tr><td>' + (s.serial_number || '-') + '</td><td>' + s.status + '</td><td class="num">' + s.quantity + '</td><td>' + (s.customer_name || '-') + '</td></tr>';
}).join('');
body.innerHTML = '<table class="dtbl"><thead><tr><th>SN</th><th>Status</th><th class="num">Qty</th><th>Pelanggan</th></tr></thead><tbody>' + (rows || '<tr><td colspan="4" class="text-muted">Belum ada unit stok</td></tr>') + '</tbody></table>';
}).catch(function () { body.innerText = 'Gagal memuat'; });
document.getElementById('modal-detail').classList.add('show');
}
function closeModal(id) { document.getElementById(id).classList.remove('show'); } function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function filterItems(q) { function filterItems(q) {

View File

@@ -208,6 +208,55 @@
</div> </div>
</div> </div>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-robot"></i> AI &amp; Self-Healing</h6></div>
<div class="card-bd settings-grid">
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-cpu"></i> Provider AI</h6>
<div class="fg">
<label class="fl">Provider</label>
<select class="fs" name="ai_provider">
<% const _aiProv = String(settings.ai_provider || 'deepseek'); %>
<option value="deepseek" <%= _aiProv === 'deepseek' ? 'selected' : '' %>>DeepSeek</option>
<option value="openai" <%= _aiProv === 'openai' ? 'selected' : '' %>>OpenAI</option>
<option value="groq" <%= _aiProv === 'groq' ? 'selected' : '' %>>Groq</option>
<option value="openrouter" <%= _aiProv === 'openrouter' ? 'selected' : '' %>>OpenRouter</option>
<option value="custom" <%= _aiProv === 'custom' ? 'selected' : '' %>>Custom (OpenAI-compatible)</option>
</select>
<div style="font-size:10px;color:var(--muted);margin-top:4px">Semua provider di atas memakai format API OpenAI (chat/completions).</div>
</div>
<div class="fg"><label class="fl">DeepSeek API Key</label><input class="fc" type="password" autocomplete="new-password" name="deepseek_api_key" value="<%= settings.deepseek_api_key || '' %>" placeholder="sk-..."></div>
<div class="fg"><label class="fl">DeepSeek Model</label><input class="fc" name="deepseek_model" value="<%= settings.deepseek_model || 'deepseek-chat' %>"></div>
<div class="fg"><label class="fl">OpenAI API Key</label><input class="fc" type="password" autocomplete="new-password" name="openai_api_key" value="<%= settings.openai_api_key || '' %>" placeholder="sk-..."></div>
<div class="fg"><label class="fl">OpenAI Model</label><input class="fc" name="openai_model" value="<%= settings.openai_model || 'gpt-4o-mini' %>"></div>
<div class="fg"><label class="fl">Groq API Key</label><input class="fc" type="password" autocomplete="new-password" name="groq_api_key" value="<%= settings.groq_api_key || '' %>"></div>
<div class="fg"><label class="fl">Groq Model</label><input class="fc" name="groq_model" value="<%= settings.groq_model || 'llama-3.3-70b-versatile' %>"></div>
<div class="fg"><label class="fl">OpenRouter API Key</label><input class="fc" type="password" autocomplete="new-password" name="openrouter_api_key" value="<%= settings.openrouter_api_key || '' %>"></div>
<div class="fg"><label class="fl">OpenRouter Model</label><input class="fc" name="openrouter_model" value="<%= settings.openrouter_model || '' %>"></div>
<div class="fg"><label class="fl">Custom Base URL</label><input class="fc" name="custom_ai_base_url" value="<%= settings.custom_ai_base_url || '' %>" placeholder="http://localhost:11434/v1"></div>
<div class="fg"><label class="fl">Custom API Key</label><input class="fc" type="password" autocomplete="new-password" name="custom_ai_api_key" value="<%= settings.custom_ai_api_key || '' %>" placeholder="opsional untuk Ollama"></div>
<div class="fg"><label class="fl">Custom Model</label><input class="fc" name="custom_ai_model" value="<%= settings.custom_ai_model || 'llama3' %>"></div>
</div>
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-activity"></i> Auto Repair</h6>
<div class="fg">
<label class="fl">Jalankan Self-Healing otomatis (tiap 30 menit)</label>
<select class="fs" name="ai_self_healing_enabled">
<option value="false" <%= (settings.ai_self_healing_enabled === true || settings.ai_self_healing_enabled === 'true') ? '' : 'selected' %>>Nonaktif</option>
<option value="true" <%= (settings.ai_self_healing_enabled === true || settings.ai_self_healing_enabled === 'true') ? 'selected' : '' %>>Aktif</option>
</select>
<div style="font-size:10px;color:var(--muted);margin-top:4px">Saat aktif, sistem memeriksa &amp; memperbaiki: status isolir vs tagihan, secret PPPoE hilang/tertukar profil, sesi RADIUS menggantung, dan IP modem ACS kosong.</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="btn btn-g" onclick="selfHeal('diagnose')"><i class="bi bi-search"></i> Diagnosa</button>
<button type="button" class="btn btn-p" onclick="selfHeal('run')"><i class="bi bi-wrench-adjustable"></i> Perbaiki Sekarang</button>
</div>
<pre id="selfheal-out" class="mono" style="display:none;margin-top:12px;max-height:280px;overflow:auto;font-size:11px;white-space:pre-wrap"></pre>
</div>
</div>
</div>
<div class="card mb16"> <div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-sliders"></i> <%= t('admin.settings.system_title', 'Pengaturan Sistem') %></h6></div> <div class="card-hd"><h6><i class="bi bi-sliders"></i> <%= t('admin.settings.system_title', 'Pengaturan Sistem') %></h6></div>
<div class="card-bd settings-grid"> <div class="card-bd settings-grid">
@@ -1187,5 +1236,20 @@ function setSearch(q) {
document.querySelectorAll('.set-tab').forEach(function (b) { b.addEventListener('click', function () { setSetTab(this.getAttribute('data-cat')); }); }); document.querySelectorAll('.set-tab').forEach(function (b) { b.addEventListener('click', function () { setSetTab(this.getAttribute('data-cat')); }); });
})(); })();
</script> </script>
<script>
async function selfHeal(mode) {
const out = document.getElementById('selfheal-out');
out.style.display = 'block';
out.textContent = mode === 'run' ? 'Menjalankan perbaikan...' : 'Mendiagnosa...';
try {
const res = await fetch('/admin/api/self-heal/' + (mode === 'run' ? 'run' : 'diagnose'), { method: mode === 'run' ? 'POST' : 'GET' });
const data = await res.json();
out.textContent = JSON.stringify(data, null, 2);
} catch (e) {
out.textContent = 'Gagal: ' + e.message;
}
}
</script>
</body> </body>
</html> </html>

View File

@@ -43,7 +43,8 @@
<a href="#features"><%= t('landing.nav.features','Keunggulan') %></a> <a href="#features"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a> <a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a>
<% if (voucherOn) { %><a href="/customer/voucher"><%= t('login.voucher_nav','Voucher') %></a><% } %> <% if (voucherOn) { %><a href="/customer/voucher"><%= t('login.voucher_nav','Voucher') %></a><% } %>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener">Status</a><% } %> <% var _statusUrl = (typeof settings !== 'undefined' && settings) ? (settings.uptime_url || settings.status_url || '') : ''; %>
<% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener">Status</a><% } %>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill" id="themeIcon"></i></button> <button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill" id="themeIcon"></i></button>
@@ -59,7 +60,7 @@
<a href="/customer/check-billing" onclick="toggleMenu()"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a> <a href="/customer/check-billing" onclick="toggleMenu()"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
<% if (voucherOn) { %><a href="/customer/voucher" onclick="toggleMenu()"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a><% } %> <% if (voucherOn) { %><a href="/customer/voucher" onclick="toggleMenu()"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a><% } %>
<a href="/customer/register" onclick="toggleMenu()"><i class="bi bi-person-plus"></i> <%= t('login.register_new','Daftar Baru') %></a> <a href="/customer/register" onclick="toggleMenu()"><i class="bi bi-person-plus"></i> <%= t('login.register_new','Daftar Baru') %></a>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %> <% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %>
</div> </div>
</div> </div>
</header> </header>
@@ -223,7 +224,7 @@
<a href="/customer/tos"><%= t('customer.footer.tos', 'Syarat & Ketentuan') %></a> <a href="/customer/tos"><%= t('customer.footer.tos', 'Syarat & Ketentuan') %></a>
<a href="/customer/privacy"><%= t('customer.footer.privacy', 'Kebijakan Privasi') %></a> <a href="/customer/privacy"><%= t('customer.footer.privacy', 'Kebijakan Privasi') %></a>
<a href="/customer/tos#refund"><%= t('customer.footer.refund', 'Kebijakan Refund') %></a> <a href="/customer/tos#refund"><%= t('customer.footer.refund', 'Kebijakan Refund') %></a>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener"><%= t('login.status_full','Status Server & Uptime') %></a><% } %> <% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener"><%= t('login.status_full','Status Server & Uptime') %></a><% } %>
</div> </div>
</div> </div>
</div> </div>