23 Commits

Author SHA1 Message Date
oxpoe
6e2bf3fe93 chore(release): v0.5.3 — rapikan dokumentasi, CI, dan workflow rilis
Some checks are pending
CI / test (push) Waiting to run
Release / release (push) Successful in 35s
- bump versi 0.5.3 (version.txt, package.json)
- CHANGELOG: catatan 0.5.3 + arsip 0.5.2
- README: tautan Gitea/Forgejo baru, badge versi, bagian screenshot, fitur baru
- docs/RELEASE_NOTES_v0.5.3.md
- workflow CI (syntax + unit test) dan Release (buat rilis + asset zip) untuk Gitea & Forgejo
2026-11-05 03:32:03 +07:00
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
oxpoe
e2e3f40aa5 rebrand: rename key locale github_* -> gitea_* 2026-10-14 21:38:02 +07:00
oxpoe
a69b203b63 chore: bump versi 0.5.2 (rebrand + deploy ke Gitea) 2026-10-14 21:34:20 +07:00
oxpoe
11ccc31933 fix(update): fetch rilis dari Gitea, bukan github.com 2026-10-14 21:11:52 +07:00
oxpoe
19558b9b94 rebrand: ganti sisa teks 'GitHub' -> 'Gitea' (UI, komentar, dokumen) 2026-10-14 21:09:02 +07:00
oxpoe
6817404688 rebrand: arahkan tautan, badge, label UI & cek-update ke Gitea (bukan GitHub) 2026-10-14 21:08:02 +07:00
oxpoe
91b65fc769 docs: banner mirror (repo asli di Gitea) 2026-10-14 12:30:36 +07:00
oxpoe
ed557cf899 chore(tidy): version.txt=0.5.1 (cek update) + buang blok UI khusus Baileys 2026-10-13 04:07:15 +07:00
oxpoe
d02c9baf7b feat(update): mode container -> menu Update tampil dgn pesan rebuild image (bukan disembunyikan) 2026-10-13 04:00:25 +07:00
oxpoe
fed81ed5f9 chore: selaraskan default server_port=3001 (Docker/healthcheck/wiki) 2026-10-13 03:49:47 +07:00
oxpoe
a68121544d chore: rapikan istilah lama (RTRW/Baileys) + path relatif isolir-redirect 2026-10-13 03:49:25 +07:00
oxpoe
cdb5046147 docs: bersihkan dokumen basi (artefak fork/dev) + buang referensi digiflazz 2026-10-13 03:48:58 +07:00
oxpoe
cb60ceec10 chore: default wa_gateway_type=gowa (baileys dilepas) + README 2026-10-13 03:47:32 +07:00
oxpoe
0f612df70f fix(monitoring): diagnosa WhatsApp sadar-gateway (GoWA) + tambah cek Telegram 2026-10-13 03:36:53 +07:00
oxpoe
6f97430e41 feat: dukung SETTINGS_PATH (settings.json di direktori mount/persisten) 2026-10-13 03:29:36 +07:00
oxpoe
f27c02309e feat: badge kelengkapan data pelanggan; fix IP search GenieACS; mount speedtest backend; isolir-redirect embedded; sembunyikan menu Update tanpa .git 2026-10-13 03:01:16 +07:00
92 changed files with 4528 additions and 9600 deletions

33
.forgejo/workflows/ci.yml Normal file
View File

@@ -0,0 +1,33 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Check syntax (semua file JS)
shell: bash
run: |
fail=0
while IFS= read -r f; do
node --check "$f" || { echo "SYNTAX FAIL: $f"; fail=1; }
done < <(find . -name '*.js' -not -path './node_modules/*' -not -path './.git/*' -not -path './public/vendor/*')
exit $fail
- name: Unit tests
run: node tests/unit/area-access.test.js

View File

@@ -0,0 +1,45 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build archive & release
shell: bash
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
NOTES="docs/RELEASE_NOTES_v${VERSION}.md"
python3 - "$TAG" "$NOTES" > payload.json <<'PY'
import json, os, sys
tag, notes = sys.argv[1], sys.argv[2]
body = open(notes, encoding="utf-8").read() if os.path.exists(notes) else "Rilis " + tag
print(json.dumps({"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False}))
PY
git archive --format=zip --prefix="open-isp-${VERSION}/" -o "/tmp/open-isp-${VERSION}.zip" HEAD
ID=$(curl -fsS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d @payload.json | python3 -c 'import sys, json; print(json.load(sys.stdin)["id"])')
curl -fsS -X POST "$API/releases/$ID/assets?name=open-isp-${VERSION}.zip" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/zip" \
--data-binary "@/tmp/open-isp-${VERSION}.zip"
echo "Release $TAG dibuat dengan asset open-isp-${VERSION}.zip"

33
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,33 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Check syntax (semua file JS)
shell: bash
run: |
fail=0
while IFS= read -r f; do
node --check "$f" || { echo "SYNTAX FAIL: $f"; fail=1; }
done < <(find . -name '*.js' -not -path './node_modules/*' -not -path './.git/*' -not -path './public/vendor/*')
exit $fail
- name: Unit tests
run: node tests/unit/area-access.test.js

View File

@@ -0,0 +1,45 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build archive & release
shell: bash
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
NOTES="docs/RELEASE_NOTES_v${VERSION}.md"
python3 - "$TAG" "$NOTES" > payload.json <<'PY'
import json, os, sys
tag, notes = sys.argv[1], sys.argv[2]
body = open(notes, encoding="utf-8").read() if os.path.exists(notes) else "Rilis " + tag
print(json.dumps({"tag_name": tag, "name": tag, "body": body, "draft": False, "prerelease": False}))
PY
git archive --format=zip --prefix="open-isp-${VERSION}/" -o "/tmp/open-isp-${VERSION}.zip" HEAD
ID=$(curl -fsS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d @payload.json | python3 -c 'import sys, json; print(json.load(sys.stdin)["id"])')
curl -fsS -X POST "$API/releases/$ID/assets?name=open-isp-${VERSION}.zip" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/zip" \
--data-binary "@/tmp/open-isp-${VERSION}.zip"
echo "Release $TAG dibuat dengan asset open-isp-${VERSION}.zip"

View File

@@ -1,8 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: "\U0001F4DA Wiki & Dokumentasi"
url: https://github.com/oxpoe/open-isp/wiki
url: https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki
about: Panduan instalasi, konfigurasi, FAQ, dan troubleshooting
- name: "\U0001F4AC Tanya Jawab"
url: https://github.com/oxpoe/open-isp/issues
url: https://gitea.oxpoe.qzz.io/oxpoe/open-isp/issues
about: Ajukan pertanyaan umum seputar OPEN-ISP

View File

@@ -2,6 +2,34 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.5.3] — 2026-11-05 — "forge-lime"
### Ditambahkan
- **Pembatasan wilayah kerja**: teknisi & kolektor hanya melihat/menangani pelanggan, tiket, perangkat, dan tagihan di area tugasnya (dukung multi-area). Admin/root tetap penuh.
- **Inventaris gudang**: alur stok keluar (FIFO / serial number), retur barang bekas, stock opname dengan selisih otomatis, detail unit per barang, statistik stok, serta harga & supplier.
- **Edit slip payroll**: rincian gaji (tunjangan, potongan, bonus, lembur) dapat diubah selama status masih draft; total dihitung ulang otomatis.
- **AI & Self-Healing**: provider AI OpenAI-compatible (DeepSeek default, OpenAI, Groq, OpenRouter, custom/Ollama), bot Telegram menjawab pertanyaan admin, dan auto-repair jaringan (status isolir vs tagihan, secret PPPoE hilang/tertukar profil, sesi RADIUS menggantung, IP ACS kosong) dengan **toggle on/off** di Pengaturan.
- **Security Watchdog**: deteksi brute-force login admin/kasir/teknisi/kolektor dan MikroTik, blokir IP sementara, alert suara (TTS soundbox) + notifikasi admin.
- **Batas sesi login**: admin/kasir/teknisi/kolektor maksimal 2 sesi per akun, pelanggan 3 sesi; sesi lama diputus otomatis dan session ID dirotasi saat login.
### Diperbaiki
- Tab **Status** di halaman login pelanggan kini mengikuti setting `uptime_url` (sebelumnya selalu menuju URL lama `status.skyfiber.qzz.io`).
- Kolom API key AI disamarkan (`type=password`) agar tidak tampil di layar/screenshot.
### Dokumentasi
- 10 screenshot aplikasi (desktop + mobile) di `docs/screenshots/`.
## [0.5.2] — 2026-10-14 — "mirror-gitea"
### Diperbaiki
- Kolom **IP Address** di **GenieACS Pro** kini tampil saat **mode pencarian** (sebelumnya kosong).
- Menu **Update Gitea** otomatis disembunyikan pada instalasi **tanpa `.git`** (container/Docker), tetap tampil pada instalasi git.
### Ditambahkan
- **Badge kelengkapan data** di daftar pelanggan: centang hijau = **nomor HP + koordinat + ACS** lengkap; silang merah = ada yang kurang (tooltip menyebut yang belum).
- **Backend speedtest bawaan** (`/speedtest/*`) kini aktif (LibreSpeed self-hosted, same-origin).
- `isolir-redirect-server` dapat dijalankan **embedded** dalam proses app (opt-out via `ISOLIR_REDIRECT_EMBEDDED=0`).
## [0.5.1] — 2026-10-13 — "pickle-lime"
### Diubah
@@ -51,4 +79,4 @@ Rilis open-source perdana.
### Kredit
- Base kode & repo: [alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw) oleh **Alijaya Net**.
- Dikembangkan menjadi OPEN-ISP oleh [oxpoe](https://github.com/oxpoe).
- Dikembangkan menjadi OPEN-ISP oleh [oxpoe](https://gitea.oxpoe.qzz.io/oxpoe).

View File

@@ -30,6 +30,6 @@ Kode Etik ini berlaku di seluruh ruang proyek (repositori, issues, PR) dan saat
## Penegakan
Perilaku yang tidak dapat diterima dapat dilaporkan kepada pengelola melalui **GitHub** (buat issue privat/mention `@oxpoe`). Semua laporan akan ditinjau dan ditangani secara adil dan rahasia.
Perilaku yang tidak dapat diterima dapat dilaporkan kepada pengelola melalui **Gitea** (buat issue privat/mention `@oxpoe`). Semua laporan akan ditinjau dan ditangani secara adil dan rahasia.
Teks ini diadaptasi dari [Contributor Covenant](https://www.contributor-covenant.org), versi 2.1.

View File

@@ -1,3 +1,5 @@
> 🪞 **Repositori utama:** [Gitea](https://gitea.oxpoe.xyz/oxpoe/open-isp) · [Forgejo](https://forgejo.oxpoe.xyz/oxpoe/open-isp) — rilis & unduhan ada di sana. GitHub hanya cermin kode.
<p align="center">
<img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%">
</p>
@@ -9,18 +11,34 @@
<p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.5.1_pickle--lime-ADFF2F" alt="Versi 0.5.1 (pickle-lime)">
<img src="https://img.shields.io/badge/versi-0.5.3_forge--lime-ADFF2F" alt="Versi 0.5.3 (forge-lime)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<img src="https://img.shields.io/github/stars/oxpoe/open-isp?style=flat" alt="Stars">
<img src="https://img.shields.io/github/forks/oxpoe/open-isp?style=flat" alt="Forks">
<img src="https://img.shields.io/github/issues/oxpoe/open-isp?style=flat" alt="Issues">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.stars_count&label=stars&style=flat&color=blue&logo=gitea" alt="Stars">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.forks_count&label=forks&style=flat&color=lightgrey&logo=gitea" alt="Forks">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.open_issues_count&label=issues&style=flat&color=orange&logo=gitea" alt="Issues">
</p>
---
**OPEN-ISP** adalah aplikasi billing dan manajemen ISP yang lengkap: tagihan otomatis, isolir otomatis, pembayaran (QRIS/webhook), perangkat MikroTik (PPPoE & hotspot), OLT PON multi-vendor, TR-069 (GenieACS/ACS), RADIUS, peta jaringan, notifikasi WhatsApp & Telegram, hingga portal self-service pelanggan dalam satu platform — semuanya berjalan mandiri (self-hosted) dengan SQLite sebagai basis data. **NOTE:** dibuat dan ditulis oleh AI ([Deepseek](https://github.com/deepseek-ai)), on [opencode](https://github.com/anomalyco/opencode).
## Tangkapan Layar
| Dashboard Admin | Daftar Pelanggan |
|---|---|
| ![Dashboard](docs/screenshots/03-admin-dashboard.png) | ![Pelanggan](docs/screenshots/04-admin-customers.png) |
| Inventaris & Stok Keluar | Pengaturan AI & Self-Healing |
|---|---|
| ![Inventaris](docs/screenshots/05-admin-inventory.png) | ![AI](docs/screenshots/07-admin-settings-ai.png) |
| Login Pelanggan | Dashboard Pelanggan |
|---|---|
| ![Login](docs/screenshots/08-customer-login.png) | ![Dashboard Pelanggan](docs/screenshots/09-customer-dashboard.png) |
Selengkapnya (10 gambar, desktop + mobile): [`docs/screenshots/`](docs/screenshots/).
## Fitur
- **Billing & pembayaran** — tagihan bulanan otomatis (cron tanggal 1), promo & prorata per paket, kode unik/QRIS, konfirmasi bukti transfer, webhook notifikasi pembayaran, cetak invoice, bayar tunggal/massal, void/unpay.
@@ -35,7 +53,11 @@
- **Speed test** — server internal maupun eksternal (preset provider diatur admin), mode embed/tab, tombol Muat Ulang & Bersihkan Cache.
- **PWA** — dapat dipasang di HP, offline, pembaruan otomatis (bar "versi baru tersedia").
- **Bilingual** — antarmuka Indonesia / English (bisa diganti dari UI).
- **Lain-lain** — inventaris gudang, tiket dukungan, laporan, monitoring sistem (CPU/RAM/disk), ODP, backup/restore database, audit log.
- **Inventaris gudang** — stok masuk, keluar (FIFO / serial number), retur, stock opname, detail unit, harga & supplier, statistik stok.
- **AI & otomatisasi** — provider AI OpenAI-compatible (DeepSeek/OpenAI/Groq/OpenRouter/custom), bot Telegram AI, dan **self-healing** jaringan dengan toggle on/off.
- **Keamanan** — security watchdog brute-force (app + MikroTik), batas sesi login per akun (staff 2, pelanggan 3), rotasi session ID, alert suara/Telegram.
- **Pembatasan wilayah** — teknisi & kolektor hanya mengakses pelanggan/tiket/perangkat di area tugasnya.
- **Lain-lain** — tiket dukungan, laporan, monitoring sistem (CPU/RAM/disk), ODP, backup/restore database, audit log.
## Tangkapan Layar
@@ -54,7 +76,7 @@
</tr>
</table>
Selengkapnya: **[Wiki OPEN-ISP](https://github.com/oxpoe/open-isp/wiki)**.
Selengkapnya: **[Wiki OPEN-ISP](https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki)**.
## Mulai Cepat
@@ -67,7 +89,7 @@ Klik badge di atas untuk membuka **Codespaces** — dependensi & data demo disia
### Docker (disarankan)
```bash
git clone https://github.com/oxpoe/open-isp.git
git clone https://gitea.oxpoe.xyz/oxpoe/open-isp.git
cd open-isp
docker compose up -d
```
@@ -103,12 +125,12 @@ Dukungan sukarela tetap diterima lewat menu **Dukung OPEN-ISP** di aplikasi.
## Dokumentasi
- [`docs/`](docs/) — referensi teknis (skema database, OID OLT, integrasi RADIUS/MikroTik, checklist backup, dll.).
- [Wiki](https://github.com/oxpoe/open-isp/wiki) — panduan instalasi, konfigurasi, tutorial, FAQ (sedang disusun).
- [Issues](https://github.com/oxpoe/open-isp/issues) — laporan bug & permintaan fitur.
- [Wiki](https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki) — panduan instalasi, konfigurasi, tutorial, FAQ (sedang disusun).
- [Issues](https://gitea.oxpoe.xyz/oxpoe/open-isp/issues) — laporan bug & permintaan fitur.
## Teknologi
Node.js · Express · EJS · better-sqlite3 (SQLite) · Bootstrap 5 + Bootstrap Icons (vendor lokal) · Leaflet · PWA/Service Worker · sharp · net-snmp · MikroTik RouterOS API · Baileys / GoWA · Telegram Bot API.
Node.js · Express · EJS · better-sqlite3 (SQLite) · Bootstrap 5 + Bootstrap Icons (vendor lokal) · Leaflet · PWA/Service Worker · sharp · net-snmp · MikroTik RouterOS API · GoWA (WhatsApp gateway) · Telegram Bot API.
## Lisensi &amp; Kredit
@@ -116,7 +138,7 @@ Dirilis dengan lisensi **ISC** — bebas digunakan, dimodifikasi, dan didistribu
- **Base kode &amp; repo**: [alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw) — dibuat oleh **Alijaya Net**. Terima kasih banyak atas base kode dan repo yang bermanfaat.
- **Add-on**: GoWA (WhatsApp gateway), LibreSpeed & OpenSpeedTest (speed test), Uptime Kuma (monitoring, opsional), notification-forwarder.
- **Dikembangkan oleh** [oxpoe](https://github.com/oxpoe) — **code by DeepSeek V4.1 Flash — max, on opencode**.
- **Dikembangkan oleh** [oxpoe](https://gitea.oxpoe.xyz/oxpoe) — **code by DeepSeek V4.1 Flash — max, on opencode**.
- Terima kasih untuk para kontributor dan komunitas ISP Indonesia.
## Dukungan &amp; Donasi

View File

@@ -10,7 +10,7 @@
**Jangan** membuka Issue publik untuk masalah keamanan.
Gunakan tab **Security → Report a vulnerability** (GitHub Security Advisories) pada repositori ini, atau hubungi pengelola secara privat melalui GitHub.
Gunakan tab **Security → Report a vulnerability** (Gitea Security Advisories) pada repositori ini, atau hubungi pengelola secara privat melalui Gitea.
Sertakan: deskripsi, langkah reproduksi, dampak, dan (bila ada) saran perbaikan. Kami akan berusaha merespons secepatnya.
@@ -20,4 +20,4 @@ Sertakan: deskripsi, langkah reproduksi, dampak, dan (bila ada) saran perbaikan.
- Jangan pernah meng-commit `settings.json`, `.env`, `database/`, `uploads/`.
- Jalankan di belakang HTTPS/reverse proxy untuk produksi.
Selengkapnya: [Wiki — Keamanan](https://github.com/oxpoe/open-isp/wiki/Keamanan).
Selengkapnya: [Wiki — Keamanan](https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki/Keamanan).

View File

@@ -2,7 +2,7 @@
## 1. Uji manual cepat
1. **Landing** `/` — hero, paket, switcher **ID/EN**, link GitHub/Wiki/Lisensi.
1. **Landing** `/` — hero, paket, switcher **ID/EN**, link Gitea/Wiki/Lisensi.
2. **Login admin** `/admin/login` → `admin` / `demo-isp123` → dashboard.
3. **Pelanggan** `/customer/login` → masukkan nomor HP demo **`081200000001`** (login pelanggan hanya butuh nomor HP) → dashboard & tagihan.
4. **Health** `/health` · versi `/version.txt`.

View File

@@ -57,7 +57,7 @@ const createSqliteSessionStore = require('./config/sqliteSessionStore');
const { getSetting, getSettingsWithCache, ensureDefaultSettings, getNowLocal, formatDateLocal, formatTimeLocal, parseDateInTimezone } = require('./config/settingsManager');
const { SUPPORTED_LANGS, FALLBACK_LANG, normalizeLang, t } = require('./config/i18n');
// Pastikan semua default settings ada (untuk migrasi/update dari GitHub)
// Pastikan semua default settings ada (untuk migrasi/update dari Gitea)
if (typeof ensureDefaultSettings === 'function') {
ensureDefaultSettings();
}
@@ -67,6 +67,7 @@ const app = express();
// Kompresi gzip (performa: respons HTML/CSS/JS lebih kecil)
app.use(compression());
app.use('/speedtest', require('./routes/speedtest'));
const isProduction = process.env.NODE_ENV === 'production';
const cookieSecure = getSetting('cookie_secure', isProduction);
@@ -176,7 +177,7 @@ app.use((req, res, next) => {
res.locals.serviceNotice = noticeSvc.getServiceNotice();
res.locals.uptimeUrl = getSetting('uptime_url', '');
res.locals.heroVer = getSetting('landing_hero_version', '');
res.locals.projectUrl = String(getSetting('project_url', 'https://github.com/oxpoe/open-isp') || '').replace(/\/+$/, '');
res.locals.projectUrl = String(getSetting('project_url', 'https://gitea.oxpoe.qzz.io/oxpoe/open-isp') || '').replace(/\/+$/, '');
res.locals.appVersion = String(getSetting('app_version', '0.5.1') || '0.5.1');
res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0');
@@ -1106,7 +1107,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
// Variabel global untuk modul lain yang masih membaca konfigurasi (mis. skrip utilitas)
global.appSettings = {
port: getSetting('server_port', 4555),
port: getSetting('server_port', 3001),
host: getSetting('server_host', 'localhost'),
genieacsUrl: getSetting('genieacs_url', 'http://localhost:7557'),
genieacsUsername: getSetting('genieacs_username', ''),
@@ -2210,7 +2211,7 @@ logger.info(`Attempting to start server on configured port: ${port}`);
startServer(port);
if (getSetting('whatsapp_enabled', false)) {
const gwType = getSetting('wa_gateway_type', 'baileys');
const gwType = getSetting('wa_gateway_type', 'gowa');
if (gwType === 'baileys') {
import('./services/whatsappBot.mjs')
.then((mod) => mod.startWhatsAppBot())
@@ -2242,3 +2243,8 @@ app.use(errorHandler);
// Export app untuk testing
module.exports = app;
// isolir redirect server embedded (port 8099) — untuk container/reverse-proxy; opt-out: ISOLIR_REDIRECT_EMBEDDED=0
if (String(process.env.ISOLIR_REDIRECT_EMBEDDED || '1') !== '0') {
try { require('./isolir-redirect-server.js'); } catch (e) { console.error('[isolir-redirect]', e.message); }
}

View File

@@ -464,7 +464,7 @@ db.exec(`
CREATE TABLE IF NOT EXISTS wa_chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT NOT NULL DEFAULT 'outbound',
gateway TEXT NOT NULL DEFAULT 'baileys',
gateway TEXT NOT NULL DEFAULT 'gowa',
sender_phone TEXT,
recipient_phone TEXT,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
@@ -532,7 +532,7 @@ db.exec(`
/**
* Memastikan menu-menu utama (WA, Settings, dll) selalu terbuka (Visible)
* meskipun setelah update dari GitHub yang mungkin mengunci menu tersebut secara default.
* meskipun setelah update dari Gitea yang mungkin mengunci menu tersebut secara default.
*/
function forceUnlockCoreMenus() {
try {
@@ -1107,6 +1107,18 @@ try {
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); }
// 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.getAppSetting = getAppSetting;
module.exports.saveAppSetting = saveAppSetting;

7
config/paths.js Normal file
View File

@@ -0,0 +1,7 @@
// Lokasi file settings.json — dapat diarahkan via env SETTINGS_PATH
// (dipakai agar settings bisa disimpan di direktori yang di-mount / persisten).
const path = require('path');
const settingsPath = process.env.SETTINGS_PATH
? path.resolve(process.env.SETTINGS_PATH)
: path.join(__dirname, '..', 'settings.json');
module.exports = { settingsPath };

View File

@@ -17,7 +17,7 @@ let settingsCacheTime = 0;
const CACHE_DURATION = 2000; // 2 detik
// File system watcher untuk auto-reload settings
const settingsPath = path.join(__dirname, '../settings.json');
const { settingsPath } = require('./paths');
let watcher = null;
// Helper untuk baca settings.json secara dinamis
@@ -99,7 +99,7 @@ function startSettingsWatcher() {
try {
const s = getSettingsWithCache();
const port = s.server_port ?? 4555;
const port = s.server_port ?? 3001;
const host = s.server_host || 'localhost';
const gurl = s.genieacs_url || '(tidak diatur)';
const company = s.company_header || '(default)';
@@ -285,7 +285,15 @@ function ensureDefaultSettings() {
// Default settings yang wajib ada
const defaultSettings = {
// RADIUS Server settings (ditambahkan untuk update dari GitHub)
// 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_enabled: '0',
radius_secret: 'secret123',
radius_auth_port: '1812',

View File

@@ -1,274 +0,0 @@
# Analisis Komprehensif Sistem, Logika Backend, dan UI/UX — Billing RT/RW Net
> **Target Project**: `/opt/billing-rtrw` (VPS `deneva` — `116.212.74.115`)
> **Layanan**: `billing-rtrw.service` (Node.js v20+, port `3001`)
> **Repository Asal**: `https://github.com/alijayanet/billing-rtrw`
> **Repository Fork/Deploy**: `https://github.com/alijayanet/billing-rtrw`
---
## 1. Ringkasan Eksekutif & Gambaran Sistem
Billing RT/RW Net ini adalah sistem manajemen operasional ISP (Internet Service Provider) berbasis komunitas yang memadukan:
1. **Billing & Invoicing**: Siklus tagihan bulanan, isolir otomatis, dan pembayaran QRIS dinamis/manual.
2. **Network Automation**: Integrasi RouterOS MikroTik (PPPoE, Hotspot Voucher, Simple Queue, Address List).
3. **Hardware Management (FTTH)**: Embedded ACS TR-069 Server (CWMP) untuk manajemen ONT klien dan SNMP OLT (EPON/GPON).
4. **Customer Self-Service (PWA)**: Portal pelanggan mobile-first dengan auto-login 90 hari, ganti WiFi SSID/password mandiri, grafik pemakaian kuota/bandwidth, dan reboot modem.
5. **Multi-Channel Bot**: Bot WhatsApp (Baileys socket) dan Bot Telegram untuk notifikasi, penagihan, dan tiket operasional.
```mermaid
flowchart TD
subgraph Klien_Pelanggan["Portal Klien & Interaksi"]
CustPWA["Customer PWA (Tailwind + Lucide)"]
PublicVoucher["Portal Voucher & QRIS"]
WABot["WhatsApp Bot (Baileys)"]
TeleBot["Telegram Bot Notifikasi"]
end
subgraph Core_Backend["Express 4 Engine (Port 3001)"]
App["app-customer.js (Main Entry)"]
ACSServer["Embedded TR-069 Server (/acs)"]
AuthSvc["Auth & Token Service (90 Hari)"]
BillSvc["Billing & Invoice Engine"]
MikroSvc["MikroTik RouterOS API"]
OLTSvc["OLT Service (SNMP / Web)"]
UsageSvc["Bandwidth Usage Tracker"]
end
subgraph Data_Network["Infrastruktur Jaringan & Database"]
DB[("SQLite WAL (better-sqlite3)")]
MikroTik[("Router MikroTik (PPPoE / Hotspot)")]
OLT[("OLT Fiber (Hioso/HSGQ/VSOL)")]
CPE[("ONT / Modem Pelanggan (TR-069)")]
end
CustPWA -->|HTTP/REST| App
PublicVoucher -->|HTTP/REST| App
WABot <--> App
TeleBot <--> App
App --> AuthSvc
App --> BillSvc
App --> MikroSvc
App --> OLTSvc
App --> UsageSvc
CPE <-->|CWMP SOAP| ACSServer
ACSServer <--> DB
BillSvc <--> DB
MikroSvc <--> MikroTik
OLTSvc <--> OLT
UsageSvc <--> MikroTik
```
---
## 2. Analisis Backend & Logika Alur Fungsi
### 2.1 Arsitektur Server & Runtime
* **Runtime**: Node.js `>=20.0.0` (Native ES Module + CommonJS hybrid).
* **Database Engine**: SQLite 3 melalui `better-sqlite3` dengan mode **WAL (Write-Ahead Logging)**, foreign keys aktif, sinkronisasi file cepat dan hemat resource.
* **Proses Lifecycle**: Dikelola oleh Systemd (`billing-rtrw.service`) dengan auto-restart `on-failure` (delay 5 detik), batas memori RAM `--max-old-space-size=512`.
* **Proteksi Server**:
* Penanganan `unhandledRejection` dan `uncaughtException` terpusat agar error koneksi (misal timeout MikroTik) tidak mematikan server Express.
* Fallback port otomatis (`startServer` mencoba port +1000 jika port 3001 bentrok).
* Proteksi CSRF berbasis validasi header `Origin` dan `Referer` (mengecualikan webhook dan endpoint SOAP ACS).
---
### 2.2 Modul-Modul Inti & Alur Logika
#### A. Layanan Billing, Invoice & Isolir (`services/billingService.js` & `services/cronService.js`)
* **Logika Pembuatan Tagihan**:
1. Cron berjalan setiap hari (`cronService.js`) memeriksa seluruh pelanggan aktif.
2. Tagihan dibuat otomatis setiap siklus tanggal tagihan (`billing_date`) untuk periode bulan berjalan.
3. Kode unik nominal QRIS (`qris_amount_unique`) diinjeksi 3 digit acak (1–999) agar mutasi bank/QRIS dapat dicocokkan otomatis tanpa payment gateway berbayar.
* **Logika Isolir Otomatis**:
1. Bila tanggal saat ini mencapai atau melewati `isolir_day` (default tanggal 20 tiap bulan) dan tagihan pelanggan masih `unpaid`:
2. Status pelanggan di database diubah menjadi `isolated`.
3. `mikrotikService` dipanggil untuk memindahkan secret PPPoE ke profile isolir atau memasukkan IP klien ke Address List `ISOLIR` di MikroTik Firewall.
4. Sesi aktif diputus (`disconnectPPPoE`) sehingga pelanggan ter-redirect ke halaman Walled Garden (`/isolated`).
#### B. Layanan TR-069 ACS Server (`services/acsServerService.js` & `/acs`)
* **Protokol**: CWMP (CPE WAN Management Protocol) via SOAP XML over HTTP POST.
* **Alur Inform & Handshake**:
1. Modem (ONT) mengirim HTTP POST `Inform` ke endpoint `/acs`.
2. Server memeriksa header `Authorization`. Jika belum diautentikasi, server membalas dengan **HTTP 401 Unauthorized** (Digest Challenge).
3. Modem mengirim kembali request dengan Digest Hash yang dihitung berdasarkan username (`skyfiber`) dan password acak perangkat.
4. Server membaca parameter XML (`Device.DeviceInfo.*`, `Device.WANDevice.*`, `Device.Ethernet.*`, `Device.LANDevice.*`).
5. Data kekuatan redaman optik (`RXPower`, `TXPower`), serial number, model, dan versi software disimpan ke tabel `acsdevices`.
6. Server memeriksa antrean tugas (task queue) per perangkat: jika ada perintah ganti password WiFi atau reboot, server merespons dengan `SetParameterValues` atau `Reboot`.
#### C. Pengelolaan WiFi Multi-Vendor (`services/customerDeviceService.js`)
* **Masalah Klasik**: Tiap vendor modem (Huawei, ZTE, FiberHome, Nokia) memiliki skema OID/TR-098 yang berbeda-beda. Mengirim parameter yang salah menyebabkan SOAP Fault 9007.
* **Logika Adaptif Per-Vendor**:
* **Huawei / FiberHome / Nokia**: Memprioritaskan `PreSharedKey.1.KeyPassphrase` dan menghindari pengiriman hex 64-digit bersamaan dengan plain string.
* **ZTE / ZICG / CIOT**: Memprioritaskan `KeyPassphrase` top-level.
* **Dual-Band SSID**: Mengatur `WLANConfiguration.1.SSID` (2.4 GHz) dan `WLANConfiguration.5.SSID` (5 GHz) secara independen tanpa merusak konfigurasi guest SSID.
#### D. Integrasi Jaringan MikroTik (`services/mikrotikService.js`)
* **Koneksi**: RouterOS API (port default `25722`/`8728`) menggunakan library `routeros-client`.
* **Fitur Utama**:
* Sinkronisasi PPPoE: Read active connections, update secret passwords, kick active sessions.
* Monitoring Traffic: Mengambil data byte RX/TX interface pelanggan untuk kalkulasi kuota.
* Voucher Hotspot: Direct injection user hotspot, profile rate-limit, dan validitas voucher.
#### E. Autentikasi Pelanggan & Persistent Token (`services/customerAuthTokenService.js`)
* **Alur Sesi PWA**:
1. Pelanggan memasukkan nomor HP / ID Pelanggan.
2. Saat login berhasil, backend men-generate token kriptografi acak `crypto.randomBytes(32).toString('hex')`.
3. Token di-hash dengan SHA-256 dan disimpan di tabel SQLite `customer_auth_tokens` dengan masa berlaku 90 hari.
4. Plain token dikirimkan ke browser sebagai cookie `customer_remember` (`httpOnly`, `secure`, `sameSite: lax`).
5. Ketika pengguna membuka PWA (bahkan setelah browser/HP di-restart), middleware auto-login memverifikasi cookie ini dan merekonstruksi session secara transparan.
#### F. Pelacakan Kuota & Pemakaian Bandwidth (`services/usageService.js`)
* **Logika Agregasi**:
1. Mengambil byte delta dari MikroTik queue / interface.
2. Menyimpan sampel agregat harian dan bulanan ke database.
3. Menyediakan API untuk konsumsi frontend (Chart.js di dashboard pelanggan).
---
## 3. Analisis UI / UX, Styling, Typografi & Layout
Pemeriksaan mendalam terhadap template EJS dan file CSS/JS di `/opt/billing-rtrw/views/` dan `/opt/billing-rtrw/public/` mengungkap sejumlah temuan penting mengenai styling, engine, dan konsistensi desain:
### 3.1 Peta Engine & Library UI per Portal
| Portal / Halaman | Engine CSS Utama | Library Icon | Font Family Utama | Style Pendekatan |
|---|---|---|---|---|
| **Customer Dashboard** (`views/dashboard.ejs`) | **Tailwind CSS 3.4.17** (via CDN) | **Lucide Icons** (v1.48.0) + FontAwesome 6.4.0 | **Plus Jakarta Sans** (Google Fonts) | Modern App Shell / Glassmorphism Mobile-first |
| **Customer Login** (`views/login.ejs`) | **Bootstrap 5.3.2** (CDN) | **Bootstrap Icons 1.11.3** | **System Font Stack** (`-apple-system`, Segoe UI, Roboto) | Centered Glass Card / Radial Background |
| **Admin Portal** (`views/admin/*`) | **Custom CSS** (`/css/admin.css`) | **Bootstrap Icons 1.11.3** + FontAwesome | **Inter** (Google Fonts) | Desktop Enterprise Sidebar & Table Layout |
| **Teknisi Portal** (`views/tech/*`) | **Bootstrap 5** + `/css/theme.css` | **Bootstrap Icons** | System Font / Inter | Mobile Bottom Navigation |
| **Halaman Peta Jaringan** (`views/admin/map.ejs`) | **Leaflet 1.9.4** + `/css/admin.css` | FontAwesome + SVG Animasi | Inter | Fullscreen Interactive Map & Vector Layer |
---
### 3.2 Analisis Typografi & Hierarki Font
```
[Customer Dashboard] Plus Jakarta Sans (300, 400, 500, 600, 700, 800)
├── Title / Header : text-base sm:text-lg font-bold (Tracking tight)
├── Section Labels : text-xs font-semibold text-slate-400 uppercase (Tracking wider)
├── Body / Konten : text-sm font-normal text-slate-200 (Leading relaxed)
└── Badges & Metadata : text-[10px] sm:text-xs font-medium
[Customer Login] System Font Stack (BlinkMacSystemFont, Segoe UI, Roboto)
└── Ketidaksesuaian : Font berbeda dengan Dashboard; saat user login terjadi visual jump dari font sistem ke Plus Jakarta Sans.
[Admin Portal] Inter (300, 400, 500, 600, 700, 800)
├── Sidebar Items : font-size 13px, font-weight 500
├── Data Table Header : font-size 11px, text-transform uppercase, letter-spacing 0.5px
└── Table Data Cells : font-size 13px, color var(--text: #e6edf3)
```
---
### 3.3 Analisis Styling, Palette Warna & Tema Gelap/Terang
#### Palette Warna Dominan
1. **Mode Gelap (Customer Dashboard)**:
* Background Utama: `#0b0f19` (Deep Blue-Black)
* Card & Panel: `#1e293b` (Slate 800) dengan border `#334155` (Slate 700)
* Aksen Primer (Brand): Gradasi Teal `#14b8a6` ke Cyan `#06b6d4`
* Aksen Sekunder: Indigo `#6366f1`
* Status Indikator: Hijau Sukses `#10b981`, Merah Warning/Error `#f43f5e`, Amber `#f59e0b`
2. **Mode Terang (Customer Dashboard & Global Theme)**:
* Background Utama: `#f1f5f9` (Slate 100)
* Card & Panel: `#ffffff` murni dengan border `rgba(15, 23, 42, 0.08)`
* Teks Utama: `#0f172a` (Slate 900), Teks Muted: `#64748b` (Slate 500)
3. **Mekanisme Tema (Theming Engine)**:
* Terdapat dua sistem tema yang berjalan berdampingan:
* `views/dashboard.ejs` menggunakan script lokal inline (`localStorage: skyfiber-customer-theme`) mengendalikan class `dark` pada `<html>` untuk Tailwind.
* Portal lainnya menggunakan `/js/theme.js` dan `/css/theme.css` (`localStorage: app-theme`) mengendalikan atribut `data-theme="light|dark"`.
* Pada rilis `v1.9.4`, kedua key ini sudah disinkronkan, namun styling mode terang di portal admin masih membutuhkan penyesuaian kontras pada beberapa tabel dan modal.
---
### 3.4 Analisis Layout, Spacing, Alignment & UX
#### 1. Customer Dashboard (Mobile App Shell)
* **Alignment & Container**:
* Lebar maksimum dibatasi ke `max-w-md` atau `max-w-lg` di tengah layar (`mx-auto`), memberikan rasa native mobile application saat dibuka di smartphone maupun desktop browser.
* Padding konten konsisten (`px-4 py-3`), dengan padding bawah ekstra (`pb-28`) untuk memberi ruang bagi floating navigation dock.
* **Floating Glass Dock (5 Tab Navigasi)**:
* Terpasang fixed di bagian bawah layar: `fixed bottom-4 left-1/2 -translate-x-1/2`.
* Menggunakan efek glassmorphism (`backdrop-blur-md`, background semi-transparan, border tipis halus).
* Indikator aktif berupa **sliding pill** yang bergerak halus mengikuti tab yang diklik (`updatePillPosition`).
* Tab navigasi:
1. `tab-beranda` (Home, status paket, kuota, shortcut aksi)
2. `tab-router` (Status ONT, redaman fiber optic, ganti WiFi, reboot)
3. `tab-ppob` (Layanan isi pulsa & token listrik - status Coming Soon)
4. `tab-tagihan` (Riwayat invoice, tombol bayar QRIS)
5. `tab-profile` (Data akun, riwayat tiket kendala, tombol CS WhatsApp, logout)
* **Kerapian & Touch Target**:
* Seluruh tombol interaktif memiliki ukuran touch-target minimal `40x40px` (ramah sentuhan jari di layar HP).
* Safe-area-insets untuk iPhone (`env(safe-area-inset-bottom)`) sudah ditambahkan pada `.dock-glass`.
#### 2. Admin & Staff Portal
* **Layout**:
* Sidebar navigasi di sisi kiri (`width: 240px`, fixed).
* Konten utama di sebelah kanan dengan header fixed dan breadcrumb.
* Responsive breakpoint: Pada layar kecil (mobile), sidebar disembunyikan dan diakses lewat toggle menu hamburger.
---
### 3.5 Temuan Evaluasi & Celah UI/UX (Bottlenecks)
1. **Fragmentasi Framework CSS**:
* Halaman login menggunakan **Bootstrap 5**, sedangkan dashboard pelanggan menggunakan **Tailwind CSS**. Ini membebani browser pengguna karena harus mengunduh dua framework CSS besar saat pertama kali membuka aplikasi.
2. **Ketergantungan CDN Runtime (Client-Side Tailwind)**:
* Dashboard pelanggan menggunakan `<script src="https://cdn.tailwindcss.com/3.4.17"></script>`. CDN ini mem-parse class Tailwind saat runtime di perangkat klien (JIT compiler di browser), yang dapat memperlambat rendering awal (First Contentful Paint) pada ponsel berspesifikasi rendah.
3. **Dual Icon Libraries**:
* Halaman dashboard memuat **Lucide Icons** dan **FontAwesome 6.4.0** secara bersamaan. FontAwesome (file CSS + font ~400KB) hanya digunakan untuk segelintir ikon kecil, sementara sebagian besar UI sudah menggunakan SVG Lucide.
4. **Log Error Berulang di Backend**:
* Terdapat log `Telegram Polling Error` berkala pada journal server deneva karena token bot belum dikonfigurasi / timeout koneksi ke server Telegram API.
---
## 4. Rencana Kerja Terperinci (Execution Plan)
Rencana perubahan ini dibagi menjadi 4 fase berurutan dengan prinsip **zero-downtime** dan keamanan data:
```mermaid
timeline
title Timeline Rencana Peningkatan Billing RTRW
Fase 1 : Konsolidasi Backend & Error Fix : Fix Polling Telegram : Optimalisasi Error Handling
Fase 2 : Unifikasi Design System : Standarisasi Font (Plus Jakarta Sans) : Harmonisasi Halaman Login & Dashboard
Fase 3 : Optimasi Asset & Performa : Eliminasi Redundansi Icon : Cache Profiling & PWA Hardening
Fase 4 : Testing & Validasi : Uji Responsivitas Semua Layar : Uji Fungsi WiFi & Pembayaran Live
```
### Fase 1: Perbaikan Stabilitas & Logika Backend
1. **Fix Polling Telegram (`services/telegramBot.js`)**:
* Tambahkan pengecekan kondisi: jika token Telegram kosong, nonaktifkan `polling: true` agar tidak membanjiri log server dengan polling error setiap 5 detik.
2. **Peningkatan Ketahanan Koneksi MikroTik (`services/mikrotikService.js`)**:
* Implementasikan retry backoff otomatis saat query queue/PPPoE timeout, menghindari uncaught exception yang tidak perlu.
3. **Audit Query Database (`config/database.js`)**:
* Periksa indeks pada tabel `pppoe_traffic_samples`, `invoices`, dan `customer_auth_tokens` untuk mempercepat query bulanan.
### Fase 2: Harmonisasi UI/UX & Standarisasi Tipografi
1. **Standarisasi Tipografi**:
* Terapkan font **Plus Jakarta Sans** ke seluruh halaman pelanggan, termasuk halaman Login (`views/login.ejs`), Register, dan Isolir (`views/isolated.ejs`), menggantikan system font default agar pengalaman visual konsisten sejak awal.
2. **Harmonisasi Desain Halaman Login (`views/login.ejs`)**:
* Rombak halaman login agar memiliki nuansa tema, sudut rounded, dan aksen warna Teal/Cyan yang identik dengan Dashboard Pelanggan.
* Ganti elemen Bootstrap pada halaman login menjadi layout yang selaras dengan tema dashboard.
3. **Penyempurnaan Form Ganti WiFi di Tab Router**:
* Perjelas instruksi frekuensi 2.4 GHz vs 5 GHz.
* Tambahkan toggle visibility (mata) untuk password WiFi dan validasi panjang password (minimal 8 karakter) di sisi klien sebelum submit.
### Fase 3: Optimasi Frontend & Efisiensi Aset
1. **Rasionalisasi Icon**:
* Migrasikan sisa ikon FontAwesome di Dashboard pelanggan ke **Lucide Icons** bawaan.
* Hapus pemanggilan CSS FontAwesome di dashboard pelanggan untuk menghemat ukuran transfer data hingga ~400 KB.
2. **Optimasi Mode Terang (Light Mode)**:
* Periksa dan rapikan kontras teks di seluruh tab dashboard agar tidak ada teks putih yang tidak terbaca saat berganti ke background terang.
* Pastikan border card dan form input terlihat tegas pada mode terang.
### Fase 4: Pengujian, Validasi & Deployment
1. **Uji Coba Lintas Perangkat**:
* Uji tampilan di mobile (Android Chrome, iOS Safari), tablet, dan desktop.
* Uji fungsi PWA Add to Home Screen dan verifikasi auto-login setelah kill aplikasi.
2. **Uji Fungsional End-to-End**:
* Uji ganti SSID/password pada modem online.
* Uji alur pembuatan invoice dan QRIS.
* Restart service `billing-rtrw.service` dan verifikasi log bersih tanpa error.

View File

@@ -1,317 +0,0 @@
# AUDIT FINDINGS SUMMARY - QUICK REFERENCE
**Status:** Complete Audit
**Date:** 14 August 2026
**Format:** Executive Summary for Management
---
## 🎯 AUDIT OVERVIEW
| Metric | Status | Details |
|--------|--------|---------|
| **Security Score** | 5.5/10 | ⚠️ Critical issues found |
| **Code Quality** | 7/10 | Good structure, needs hardening |
| **Database Integrity** | 8/10 | Well-designed schema |
| **Production Ready** | ❌ NO | Must fix critical issues first |
| **Audit Completed** | ✅ YES | Full report available |
---
## 🔴 CRITICAL FINDINGS (MUST FIX IMMEDIATELY)
### 1. PASSWORD STORAGE (CRITICAL)
- **Issue:** Passwords stored in plaintext in database
- **Risk:** Full account compromise if database breached
- **Files:** `services/techService.js`, `settings.json`
- **Fix Time:** 4-6 hours
- **Severity:** CRITICAL
- **Status:** Not Fixed ❌
### 2. DEFAULT CREDENTIALS (CRITICAL)
- **Issue:** Default admin password `admin123` in settings.json
- **Risk:** Anyone can login to admin panel
- **Fix Time:** 1 hour
- **Severity:** CRITICAL
- **Status:** Not Fixed ❌
### 3. UNENCRYPTED CREDENTIALS (CRITICAL)
- **Issue:** API keys and passwords in plaintext in settings.json
- **Risk:** Exposure in version control, server breach
- **Files:** `settings.json`
- **Fix Time:** 2-3 hours
- **Severity:** CRITICAL
- **Status:** Not Fixed ❌
### 4. WEAK PASSWORD HASHING (HIGH)
- **Issue:** SHA256 and MD5 used instead of bcrypt/argon2
- **Risk:** Password cracking in hours/minutes
- **Files:** Multiple service files
- **Fix Time:** 6-8 hours
- **Severity:** HIGH
- **Status:** Not Fixed ❌
---
## 🟠 HIGH PRIORITY FINDINGS
### 5. CSRF PROTECTION (LIMITED)
- **Issue:** Only Referer/Origin validation, no explicit CSRF tokens
- **Risk:** Cross-site request forgery attacks possible
- **Fix Time:** 4-6 hours
- **Severity:** HIGH
### 6. SESSION MANAGEMENT
- **Issue:** In-memory session store, no regeneration on login
- **Risk:** Session hijacking possible
- **Fix Time:** 3-4 hours
- **Severity:** HIGH
### 7. INPUT VALIDATION
- **Issue:** Incomplete input validation and sanitization
- **Risk:** XSS and injection attacks
- **Fix Time:** 8-10 hours
- **Severity:** HIGH
---
## 🟡 MEDIUM PRIORITY FINDINGS
### 8. FILE UPLOAD SECURITY
- **Issue:** Only MIME type validation, no deep file scanning
- **Risk:** Malicious file upload
- **Fix Time:** 2-4 hours
- **Severity:** MEDIUM
### 9. BACKUP & DISASTER RECOVERY
- **Issue:** Auto-backup disabled in settings
- **Risk:** Data loss in disaster scenario
- **Fix Time:** 2-3 hours
- **Severity:** MEDIUM
### 10. ERROR LOGGING
- **Issue:** Some errors may expose sensitive info
- **Risk:** Information disclosure
- **Fix Time:** 2-3 hours
- **Severity:** MEDIUM
---
## ✅ POSITIVE FINDINGS
### What's Working Well:
- ✅ Database design is solid with proper constraints
- ✅ Error handling middleware implemented
- ✅ Winston logger properly configured
- ✅ SQL queries use parameterized statements (SQL injection safe)
- ✅ File upload has MIME type validation
- ✅ Session cookies have httpOnly and sameSite flags
- ✅ CORS/Origin checking implemented
- ✅ Comprehensive feature set well-structured
---
## 📊 TIMELINE TO PRODUCTION READINESS
```
Week 1 (CRITICAL):
├─ Fix password hashing → 8 hours
├─ Setup .env & remove hardcoded secrets → 4 hours
├─ Implement CSRF tokens → 4 hours
├─ Setup session store → 3 hours
└─ Testing → 4 hours
TOTAL: ~23 hours (3 days)
Week 2 (HIGH):
├─ Input validation framework → 8 hours
├─ Rate limiting → 4 hours
├─ Backup automation → 3 hours
├─ Security headers (helmet) → 2 hours
└─ Testing & QA → 8 hours
TOTAL: ~25 hours (3 days)
Week 3 (MEDIUM):
├─ File upload scanning → 6 hours
├─ Log review & cleanup → 4 hours
├─ Documentation → 4 hours
└─ Staging deployment → 4 hours
TOTAL: ~18 hours (2 days)
GRAND TOTAL: ~66 hours (~2 weeks full-time)
or ~3-4 weeks part-time (20 hrs/week)
```
---
## 💼 BUSINESS IMPACT
### Current State (If Deployed Now):
- 🔴 **NOT RECOMMENDED** for production
- Risk of account takeover is HIGH
- Risk of data breach is CRITICAL
- Compliance risk with regulations (OJK, PCI-DSS if payment processing)
### After Critical Fixes (1-2 weeks):
- 🟢 **PRODUCTION READY** for initial deployment
- Password security improved significantly
- Credentials properly managed
- Basic CSRF protection in place
### After All Fixes (3-4 weeks):
- 🟢 **PRODUCTION HARDENED**
- Enterprise-grade security
- Compliance-ready
- Audit-ready for external review
---
## 👥 RESOURCE REQUIREMENTS
### Team Needed:
- **Backend Developer(s):** 1-2 (main focus)
- **Security Reviewer:** 1 (part-time, 4-6 hours/week)
- **QA/Tester:** 1 (part-time, 2-4 hours/week)
- **DevOps/Infrastructure:** 0.5 (env setup, deployment)
### Skills Required:
- Node.js/JavaScript (Backend)
- Database migration experience
- Security best practices knowledge
- Testing & QA methodologies
---
## 🎯 RECOMMENDED ACTION PLAN
### Phase 1: IMMEDIATE (Days 1-2)
1. ✅ Review this audit with team
2. ✅ Create .env file with sensitive data
3. ✅ Install required packages (bcrypt, helmet, etc)
4. ✅ Backup current database
### Phase 2: CRITICAL FIXES (Days 3-5)
1. ✅ Implement password hashing with bcrypt
2. ✅ Migrate existing passwords
3. ✅ Setup environment variables
4. ✅ Remove hardcoded credentials
### Phase 3: TESTING & VALIDATION (Days 6-7)
1. ✅ Test all authentication flows
2. ✅ Verify password migration
3. ✅ Test environment variable loading
4. ✅ Deploy to staging
### Phase 4: HIGH PRIORITY FIXES (Week 2)
1. ✅ Implement CSRF protection
2. ✅ Setup session store
3. ✅ Add rate limiting
4. ✅ Input validation
### Phase 5: FINAL HARDENING (Week 3)
1. ✅ Security headers (Helmet)
2. ✅ File upload scanning
3. ✅ Backup automation
4. ✅ Final testing
### Phase 6: PRODUCTION DEPLOYMENT (End of Week 3)
1. ✅ Security review checklist
2. ✅ Final audit verification
3. ✅ Deploy to production
4. ✅ Monitoring setup
---
## 📋 DEPLOYMENT CHECKLIST
Before production deployment, verify:
- [ ] **Password Security**
- [ ] Bcrypt implemented and tested
- [ ] All passwords migrated to hashes
- [ ] No plaintext passwords in code/config
- [ ] Login flows working with hashed passwords
- [ ] **Credential Management**
- [ ] .env file created and populated
- [ ] Settings.json reviewed for sensitive data
- [ ] .env added to .gitignore
- [ ] Environment variables working
- [ ] **Access Control**
- [ ] CSRF protection implemented
- [ ] Rate limiting configured
- [ ] Session management hardened
- [ ] Role-based access control verified
- [ ] **Security Headers**
- [ ] Helmet.js installed and configured
- [ ] HTTPS enforced (if applicable)
- [ ] Security headers present in responses
- [ ] **Data Protection**
- [ ] Input validation implemented
- [ ] Output sanitization verified
- [ ] File uploads scanning configured
- [ ] Database encryption (at rest if needed)
- [ ] **Logging & Monitoring**
- [ ] Audit trail logged for sensitive operations
- [ ] Error logging configured
- [ ] Monitoring alerts setup
- [ ] Log aggregation configured
- [ ] **Backup & Recovery**
- [ ] Automated backup enabled
- [ ] Backup encryption enabled
- [ ] Recovery procedure tested
- [ ] Backup retention policy set
- [ ] **Testing**
- [ ] Unit tests passing
- [ ] Integration tests passing
- [ ] Security tests completed
- [ ] Load testing completed
- [ ] Penetration testing (optional but recommended)
- [ ] **Documentation**
- [ ] Security practices documented
- [ ] Incident response plan ready
- [ ] Setup procedures documented
- [ ] Troubleshooting guide prepared
---
## 🚨 KNOWN VULNERABILITIES
| Vulnerability | CVSS Score | Status | Action |
|---|---|---|---|
| Plaintext password storage | 9.8 | Open | CRITICAL - Fix immediately |
| Default credentials | 8.7 | Open | CRITICAL - Fix immediately |
| Unencrypted API keys | 8.9 | Open | CRITICAL - Fix immediately |
| Weak password hashing | 7.5 | Open | HIGH - Fix this week |
| Limited CSRF protection | 6.5 | Open | HIGH - Fix this week |
| In-memory session store | 6.1 | Open | HIGH - Fix this week |
| Incomplete input validation | 7.1 | Open | HIGH - Fix this week |
---
## 📞 CONTACT & SUPPORT
For questions or clarifications:
1. Review detailed findings in `AUDIT_SECURITY_2026.md`
2. Check implementation guide in `IMPLEMENTATION_GUIDE_SECURITY.md`
3. Consult code samples in this document
---
## 📝 SIGN-OFF
**Audit Completed By:** Security & Code Quality Review Team
**Date Completed:** 14 August 2026
**Next Review:** 30 days after fixes implementation
**Status:** READY FOR ACTION ✅
---
**RECOMMENDATION: DO NOT DEPLOY TO PRODUCTION UNTIL CRITICAL FIXES ARE COMPLETED**
For production deployment, all findings must be reviewed, and critical issues must be resolved.

File diff suppressed because it is too large Load Diff

View File

@@ -1,833 +0,0 @@
# AUDIT KEAMANAN & KUALITAS APLIKASI BILLING RTRWNET
**Status:** ✅ AUDIT LENGKAP
**Tanggal Audit:** 14 Agustus 2026
**Auditor:** Security & Code Quality Review
**Aplikasi Status:** 🟠 PRODUCTION WITH CRITICAL ISSUES
---
## 📋 RINGKASAN EKSEKUTIF
Aplikasi RTRWNET Billing System adalah sistem yang **comprehensive dan feature-rich** untuk manajemen ISP. Namun, audit menemukan **beberapa kerentanan keamanan KRITIS** yang harus segera diperbaiki sebelum production deployment atau jika sudah di production harus segera ditindaklanjuti.
### Skor Keamanan: 5.5/10 🔴
- ✅ Aspek Positif: Struktur kode baik, error handling solid, logging terimplementasi
- ⚠️ Aspek Kritis: Keamanan password, configuration management, input validation
- ❌ Aspek Negatif: Plaintext credentials, default passwords, weak hashing
---
## 🔐 KEAMANAN KRITIS - SEGERA PERBAIKI
### 1. PASSWORD DISIMPAN DALAM PLAINTEXT ❌ CRITICAL
**Lokasi:**
- [services/techService.js](services/techService.js#L4) - Password technician tersimpan plaintext di database
- [settings.json](settings.json#L20-L21) - Admin password `admin123` dalam plaintext
**Masalah:**
```javascript
// ❌ TIDAK AMAN - Line 4 di techService.js
const tech = db.prepare('SELECT * FROM technicians WHERE username = ? AND password = ? AND is_active = 1').get(username, password);
// Password dibandingkan langsung tanpa hashing
// ❌ TIDAK AMAN - settings.json
"admin_username": "admin",
"admin_password": "admin123", // Password plaintext!
```
**Risiko:**
- Jika database bocor, semua password terekspos
- Jika settings.json terlihat, credentials admin langsung ketahuan
- Tidak compliance dengan standar keamanan (OWASP, ISO27001)
- Database breach = total kompromis sistem
**Rekomendasi - HARUS DILAKUKAN:**
1. **Implementasi bcrypt untuk hashing password:**
```javascript
// Install bcrypt terlebih dahulu
// npm install bcrypt
// Saat register/create user:
const bcrypt = require('bcrypt');
const hashedPassword = await bcrypt.hash(plainPassword, 10);
// Simpan hashedPassword ke database
// Saat authenticate:
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
if (isValid) {
// Login berhasil
}
```
2. **Update database schema:**
```sql
-- Tambahkan kolom password_hash
ALTER TABLE technicians ADD COLUMN password_hash TEXT;
ALTER TABLE administrators ADD COLUMN password_hash TEXT;
-- Update existing passwords dengan hash
-- (Lakukan migration script terpisah)
```
3. **Jangan simpan password di settings.json:**
```json
// ❌ SEBELUM
{
"admin_password": "admin123"
}
// ✅ SESUDAH - Gunakan database atau environment variable
{
"admin_user_id": 1 // Reference ke database saja
}
// Atau gunakan .env file:
ADMIN_PASSWORD_HASH="$2b$10$..."
```
**Timeline:** IMMEDIATE - Sebelum production
---
### 2. DEFAULT CREDENTIALS (admin/admin123) ❌ CRITICAL
**Lokasi:** [settings.json](settings.json#L19-L21)
**Masalah:**
- Default username/password yang sangat lemah
- Siapapun yang tahu dokumentasi aplikasi bisa login
- OWASP Top 10: A01:2021 - Broken Access Control
**Rekomendasi:**
```bash
# Script untuk force password change saat first-time setup
node scripts/setup-admin-password.js
# Atau implementasikan setup wizard:
- Saat pertama kali aplikasi dijalankan
- Wajib setup admin username & password baru
- Simpan di database dengan hash bcrypt
- Tandai setup selesai di flag di database
```
**Timeline:** IMMEDIATE
---
### 3. API KEY & CREDENTIALS DALAM PLAINTEXT ❌ CRITICAL
**Lokasi:** [settings.json](settings.json)
```json
"genieacs_password": "admin",
"midtrans_server_key": "",
"xendit_api_key": "",
"tripay_private_key": "",
"digiflazz_api_key": "",
"radius_secret": "secret123"
```
**Masalah:**
- Semua credentials eksternal dalam plaintext di file JSON
- File bisa terlihat di source code management
- Risiko exposure jika server dikompromis
- Tidak praktis untuk berbagai environment (dev/staging/production)
**Rekomendasi - HARUS DILAKUKAN:**
1. **Gunakan environment variables (.env):**
```bash
# File: .env (JANGAN commit ke git!)
GENIEACS_URL=http://192.168.8.189:7557
GENIEACS_USERNAME=admin
GENIEACS_PASSWORD=SecurePassword123!
MIDTRANS_SERVER_KEY=Mid-xxxxx-xxx
XENDIT_API_KEY=xnd_development_xxxxx
TRIPAY_API_KEY=xxxxx
DIGIFLAZZ_API_KEY=xxxxx
RADIUS_SECRET=RadiusSecret123!
SESSION_SECRET=RandomSecure123!SecretHere
ADMIN_API_KEY=AdminApiKey123!Secure
```
2. **Implementasi di code:**
```javascript
// ✅ AMAN
const settings = {
genieacs_password: process.env.GENIEACS_PASSWORD || getSetting('genieacs_password'),
midtrans_server_key: process.env.MIDTRANS_SERVER_KEY,
xendit_api_key: process.env.XENDIT_API_KEY,
// ... dll
};
```
3. **Update .gitignore:**
```bash
# Jangan commit file ini!
.env
.env.local
.env.*.local
settings.json # Alternatif: commit template saja
```
4. **Implementasi encryption untuk settings.json (jika diperlukan):**
```javascript
// Gunakan settingsEncryption.js yang sudah ada
const { encryptValue, decryptValue } = require('./config/settingsEncryption');
// Encrypt credentials:
const encrypted = encryptValue(credentialValue);
// Decrypt saat digunakan:
const plainValue = decryptValue(encryptedValue);
```
**Timeline:** IMMEDIATE
---
### 4. WEAK PASSWORD HASHING ⚠️ HIGH
**Lokasi Lainnya:**
- [config/database.js](config/database.js#L627) - SHA256 untuk password
- [services/agentService.js](services/agentService.js#L27) - MD5 hash
- [routes/adminPortal.js](routes/adminPortal.js#L98) - MD5 hash
**Masalah:**
```javascript
// ❌ TIDAK AMAN - SHA256 bukan untuk password hashing
const hash = crypto.createHash('sha256').update(input).digest('hex');
// ❌ TIDAK AMAN - MD5 sudah deprecated
const sign = crypto.createHash('md5').update(username + apiKey).digest('hex');
```
- SHA256/MD5 = algoritma hashing cepat (bisa di-brute force)
- Tidak ada salt random
- Tidak ada rate limiting pada hashing
- Bisa di-crack dengan GPU dalam hitungan jam
**Rekomendasi:**
```javascript
// ✅ AMAN - Gunakan bcrypt atau Argon2
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 10); // 10 = cost factor
const isValid = await bcrypt.compare(plainPassword, hash);
// Atau Argon2 (lebih kuat):
const argon2 = require('argon2');
const hash = await argon2.hash(password);
const isValid = await argon2.verify(hash, password);
```
**Timeline:** HIGH - Dalam 1 minggu
---
## 🔒 KEAMANAN SEDANG - TINGKATKAN
### 5. SESSION MANAGEMENT ⚠️ MEDIUM
**Lokasi:** [app-customer.js](app-customer.js#L79-L90)
**Status Saat Ini:** ✅ Baik
```javascript
app.use(session({
secret: getSetting('session_secret', 'default-secret'),
resave: false,
saveUninitialized: false,
cookie: {
secure: Boolean(cookieSecure), // ✅ HTTPS only (jika production)
httpOnly: true, // ✅ JavaScript tidak bisa akses
sameSite: 'lax', // ✅ CSRF protection
maxAge: 24 * 60 * 60 * 1000 // ✅ Timeout 24 jam
},
name: 'customer.sid'
}));
```
**Rekomendasi Improvement:**
1. **Gunakan session store eksternal:**
```javascript
// Jangan gunakan default memory store di production
const SessionStore = require('better-sqlite3-session-store')(session);
const store = new SessionStore({
client: db,
expired: {
clear: true,
intervalMs: 15 * 60 * 1000 // Clear expired sessions tiap 15 menit
}
});
app.use(session({
store: store,
// ... config lainnya
}));
```
2. **Regenerate session ID saat login:**
```javascript
// Saat login berhasil
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.userId = user.id;
req.session.userRole = user.role;
// ... simpan data user lainnya
res.redirect('/dashboard');
});
```
3. **Validasi session pada setiap request:**
```javascript
app.use((req, res, next) => {
// Cek timeout session
const lastActivity = req.session.lastActivity || Date.now();
const inactivityTimeout = 30 * 60 * 1000; // 30 menit
if (Date.now() - lastActivity > inactivityTimeout) {
req.session.destroy();
return res.status(401).json({ error: 'Session expired' });
}
req.session.lastActivity = Date.now();
next();
});
```
**Timeline:** MEDIUM - 2 minggu
---
### 6. CSRF PROTECTION ⚠️ MEDIUM
**Status Saat Ini:** ⚠️ Terbatas
```javascript
// Hanya check Referer/Origin, tidak ada CSRF token explicit
if (origin) {
const originHost = new URL(origin).host;
if (originHost !== host) {
return res.status(403).json({ error: 'Forbidden - Invalid Origin' });
}
}
```
**Masalah:**
- Referer bisa di-spoof dalam beberapa kasus
- Browser lama atau proxy bisa hilangkan Referer header
- Tidak ada explicit CSRF token di form
**Rekomendasi:**
1. **Implementasi CSRF token library:**
```javascript
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: false }); // Gunakan session, bukan cookie
app.post('/admin/action', csrfProtection, (req, res) => {
// CSRF token sudah tervalidasi otomatis
// Proses request
});
```
2. **Di EJS templates:**
```ejs
<form method="POST" action="/admin/billing/generate">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit">Generate Invoice</button>
</form>
```
**Timeline:** MEDIUM - 2 minggu
---
### 7. INPUT VALIDATION & SANITIZATION ⚠️ MEDIUM
**Issue:**
Beberapa endpoint tidak melakukan validasi input yang ketat sebelum:
- Memasukkan ke database
- Menampilkan ke user (XSS risk)
- Digunakan dalam query
**Contoh lokasi yang perlu improvement:**
- [routes/adminPortal.js](routes/adminPortal.js) - Bulk operations
- [routes/customerPortal.js](routes/customerPortal.js) - Customer data update
- File upload handling
**Rekomendasi:**
1. **Gunakan input validation library:**
```bash
npm install joi express-validator
```
2. **Implementasi validation:**
```javascript
const { body, validationResult } = require('express-validator');
router.post('/admin/customers/add', [
body('name').trim().notEmpty().isLength({ max: 100 }),
body('phone').trim().isMobilePhone('id-ID'),
body('email').isEmail().normalizeEmail(),
body('address').trim().isLength({ max: 255 }),
body('package_id').isInt({ gt: 0 }),
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Lanjutkan proses
});
```
3. **Sanitize output di EJS:**
```ejs
<!-- ❌ TIDAK AMAN - Bisa XSS -->
<%= customer.name %>
<!-- ✅ AMAN - Sanitized -->
<%- cleanHtml(customer.name) %>
<!-- atau gunakan escape by default di EJS config -->
```
**Timeline:** MEDIUM - 2 minggu
---
## ⚠️ KEAMANAN RENDAH - PERHATIAN
### 8. FILE UPLOAD SECURITY ⚠️ LOW
**Status Saat Ini:** ⚠️ Cukup
```javascript
// ✅ Ada validasi MIME type
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const mimetype = allowedTypes.test(file.mimetype);
// ✅ Ada file size limit (5MB)
limits: { fileSize: 5 * 1024 * 1024 }
```
**Rekomendasi Improvement:**
1. **Validasi file lebih ketat:**
```javascript
const FileType = require('file-type');
// Validasi magic bytes (bukan hanya extension)
const fileType = await FileType.fromBuffer(file.buffer);
if (!['image/jpeg', 'image/png', 'image/webp'].includes(fileType?.mime)) {
throw new Error('Invalid file type');
}
```
2. **Scan file dengan antivirus:**
```javascript
const ClamScan = require('clamscan');
const clamscan = await new ClamScan().init({
clamdscan: { host: 'localhost', port: 3310 }
});
const { isInfected } = await clamscan.scanFile(filePath);
if (isInfected) {
fs.unlinkSync(filePath);
throw new Error('Malicious file detected');
}
```
**Timeline:** LOW - Dalam 1 bulan
---
### 9. LOGGING & MONITORING ⚠️ LOW
**Status Saat Ini:** ✅ Baik
- Winston logger terimplementasi dengan baik
- Terpisah antara error.log, combined.log, exceptions.log
- Console output di development
**Rekomendasi:**
1. **Audit trail untuk sensitive operations:**
```javascript
const auditTrailSvc = require('./services/auditTrailService');
// Log setiap action sensitive
auditTrailSvc.logAction({
user_id: req.session.adminId,
action: 'invoice_generated',
resource_type: 'invoice',
resource_id: invoiceId,
changes: { status: 'generated' },
ip_address: req.ip,
timestamp: new Date()
});
```
2. **Centralized log aggregation:**
```javascript
// Contoh: Kirim logs ke ELK Stack atau Datadog
const winston = require('winston');
const WinstonCloudWatch = require('winston-cloudwatch');
logger.add(new WinstonCloudWatch({
logGroupName: 'billing-rtrwnet',
logStreamName: 'production',
awsRegion: 'ap-southeast-1'
}));
```
**Timeline:** LOW - Dalam 1 bulan
---
## 🎯 KUALITAS KODE
### 10. ERROR HANDLING ✅ BAIK
**Status:** ✅ Baik
- Middleware error handler sudah terimplementasi
- Custom error classes (ValidationError, UnauthorizedError, etc)
- Proper HTTP status codes
**Minor Improvements:**
```javascript
// Pastikan semua error di-catch dan di-log
router.get('/api/data', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
logger.error(`API Error: ${err.message}`, {
stack: err.stack,
endpoint: req.path,
method: req.method,
user_id: req.session?.userId
});
next(err); // Pass ke error handler middleware
}
});
```
---
### 11. DATABASE QUERIES ✅ BAIK
**Status:** ✅ Baik
- Menggunakan parameterized queries (prepared statements)
- Tidak ada string concatenation pada SQL queries
- Foreign key constraints aktif
**Contoh aman:**
```javascript
// ✅ AMAN - Parameterized query
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId);
// ✅ AMAN - Multiple parameters
const result = db.prepare('SELECT * FROM customers WHERE router_id = ? AND status = ?').all(routerId, 'active');
```
**Minor Issue - Dynamic SQL:**
```javascript
// ⚠️ PERLU REVIEW - Jika search input tidak di-sanitize
const base = `SELECT * FROM customers WHERE ...${userInput}...`;
// ✅ SOLUSI - Gunakan parameterized dengan conditions array
const whereClauses = [];
const params = [];
if (search) {
whereClauses.push('name LIKE ?');
params.push(`%${search}%`);
}
```
---
### 12. ASYNC/AWAIT HANDLING ⚠️ MEDIUM
**Issue:**
Beberapa function masih menggunakan callback style yang bisa error-prone.
**Contoh yang perlu improvement:**
```javascript
// ❌ CALLBACK STYLE
fs.readFile(path, (err, data) => {
if (err) handleError(err);
// process data
});
// ✅ ASYNC/AWAIT STYLE
try {
const data = await fs.promises.readFile(path);
// process data
} catch (err) {
handleError(err);
}
```
**Timeline:** LOW - Refactoring gradual
---
## 🗄️ DATABASE INTEGRITY
### 13. Database Schema ✅ SOLID
**Status:** ✅ Sudah baik
- Foreign key constraints aktif
- Proper data types
- Indexes pada frequently queried fields
- WAL mode untuk reliability
**Rekomendasi:**
```sql
-- Pastikan ada indexes pada frequently queried columns
CREATE INDEX IF NOT EXISTS idx_customers_router_id ON customers(router_id);
CREATE INDEX IF NOT EXISTS idx_invoices_customer_id ON invoices(customer_id);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
CREATE INDEX IF NOT EXISTS idx_pppoe_users_username ON pppoe_users(username);
```
---
### 14. Backup & Recovery ⚠️ MEDIUM
**Status Saat Ini:** ⚠️ Ada backup service tapi belum clear
- `backupService.js` sudah ada
- `auto_backup_enabled: false` di settings.json (disabled!)
**Rekomendasi:**
```javascript
// Enable automatic backup
const settings = {
"auto_backup_enabled": true,
"auto_backup_interval": "0 2 * * *", // Jam 2 pagi setiap hari
"auto_backup_retention_days": 30,
"backup_encryption_enabled": true
};
// Implementasi di cronService.js
const backup = require('./services/backupService');
schedule.scheduleJob('0 2 * * *', async () => {
try {
const result = await backup.createBackup();
logger.info(`Backup created: ${result.filename}`);
} catch (err) {
logger.error(`Backup failed: ${err.message}`);
}
});
```
**Timeline:** MEDIUM - 2 minggu
---
## 📋 ACTION ITEMS & TIMELINE
### IMMEDIATE (Hari ini - 48 jam) 🔴
- [ ] Implementasi bcrypt untuk password hashing
- [ ] Hapus default credentials dari settings.json
- [ ] Setup .env file untuk credentials
- [ ] Update .gitignore untuk .env dan settings.json
- [ ] Create setup wizard untuk first-time admin password
### HIGH PRIORITY (Minggu 1) 🟠
- [ ] Migrate semua password di database dengan bcrypt
- [ ] Implementasi CSRF token protection
- [ ] Setup session store eksternal (SQLite session)
- [ ] Enable auto backup dalam cron jobs
- [ ] Update API key management
### MEDIUM PRIORITY (Minggu 2-3) 🟡
- [ ] Input validation dengan joi/express-validator
- [ ] Enhanced file upload scanning
- [ ] Session regeneration saat login
- [ ] Centralized logging setup
- [ ] Database query audit
### LOW PRIORITY (Bulan 1) 🟢
- [ ] Antivirus integration untuk file upload
- [ ] Log aggregation (ELK/Datadog)
- [ ] Async/await refactoring
- [ ] Performance optimization
- [ ] Documentation update
---
## 📊 SECURITY CHECKLIST - PRODUCTION DEPLOYMENT
Sebelum go live ke production, pastikan:
- [ ] **Authentication**
- [ ] Password menggunakan bcrypt (cost ≥ 10)
- [ ] Admin password bukan default
- [ ] Session timeout implemented
- [ ] Session regeneration saat login
- [ ] **Authorization**
- [ ] Role-based access control diterapkan
- [ ] API endpoints check permission
- [ ] Admin endpoints protected
- [ ] **Data Protection**
- [ ] Sensitive data encrypted at rest
- [ ] HTTPS enforced (secure cookie flag)
- [ ] CSRF protection aktif
- [ ] XSS protection aktif
- [ ] **Configuration**
- [ ] Semua credentials di .env
- [ ] .env tidak di-commit
- [ ] settings.json di-review untuk sensitive data
- [ ] API keys di-rotate
- [ ] **Logging & Monitoring**
- [ ] Audit trail untuk sensitive operations
- [ ] Error logging configured
- [ ] Monitoring alerts setup
- [ ] **Backup & Recovery**
- [ ] Automated backup enabled
- [ ] Backup encryption enabled
- [ ] Recovery procedure tested
- [ ] Backup storage off-site
- [ ] **Network**
- [ ] Firewall configured
- [ ] Only needed ports open
- [ ] Rate limiting implemented
- [ ] DDoS protection (jika applicable)
- [ ] **Testing**
- [ ] Security testing completed
- [ ] Penetration testing done (jika budget ada)
- [ ] Load testing completed
- [ ] Recovery testing done
---
## 📞 REKOMENDASI LANJUTAN
### 1. Security Code Review
Jadwalkan code review berkala (monthly) oleh developer berpengalaman untuk:
- Review pull requests sebelum merge
- Check untuk security vulnerabilities
- Update dependencies
### 2. Dependency Management
```bash
# Regular security audit
npm audit
# Update dependencies (carefully)
npm update
# Check for outdated packages
npm outdated
```
### 3. Environment Management
```bash
# Development
NODE_ENV=development npm start
# Staging
NODE_ENV=staging npm start
# Production
NODE_ENV=production npm start
```
### 4. Rate Limiting
```javascript
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 menit
max: 5, // 5 attempts
message: 'Terlalu banyak login attempts, coba lagi nanti'
});
router.post('/login', loginLimiter, authController.login);
```
### 5. Security Headers
```javascript
const helmet = require('helmet');
app.use(helmet()); // Adds various HTTP headers for security
```
---
## 📈 PERFORMANCE OPTIMIZATION
### Database Performance
```javascript
// Add connection pooling
const DatabasePool = require('pg-pool');
// Add query optimization
EXPLAIN ANALYZE SELECT ...; // Check query plans
```
### Caching Strategy
```javascript
// Implement Redis caching untuk frequently accessed data
const redis = require('redis');
const client = redis.createClient();
// Cache customer data
const getCachedCustomer = async (id) => {
const cached = await client.get(`customer:${id}`);
if (cached) return JSON.parse(cached);
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(id);
await client.setex(`customer:${id}`, 3600, JSON.stringify(customer));
return customer;
};
```
### API Response Compression
```javascript
const compression = require('compression');
app.use(compression()); // Compress responses
```
---
## 🎓 DEVELOPER TRAINING
Recommended training topics untuk team:
1. OWASP Top 10 vulnerabilities
2. Secure coding practices
3. Dependency vulnerability scanning
4. Database security
5. API security
6. Authentication & Authorization best practices
---
## 📝 KESIMPULAN
Aplikasi RTRWNET Billing System memiliki **foundation yang kuat** dengan:
- ✅ Arsitektur yang well-structured
- ✅ Comprehensive feature set
- ✅ Good error handling & logging
- ✅ Proper database design
Namun ada **beberapa kerentanan keamanan KRITIS** yang harus diperbaiki:
- 🔴 Password plaintext storage
- 🔴 Default credentials
- 🔴 Unencrypted credentials in config
Dengan mengikuti rekomendasi di audit ini, aplikasi dapat mencapai **production-ready security level** dalam waktu 2-3 minggu.
---
**Status Audit: COMPLETE**
**Recommendation: DO NOT DEPLOY TO PRODUCTION** sampai critical issues di-fix.
**Next Review:** 30 hari setelah fixes di-implementasi

View File

@@ -1,458 +0,0 @@
# AUDIT SUMMARY - Aplikasi Billing B481
**Tanggal Audit:** Juni 2026
**Status:** ✅ COMPLETE & DOCUMENTED
**Aplikasi Status:** 🟢 PRODUCTION READY
---
## 📌 Executive Summary
Telah dilakukan audit **lengkap & teliti** terhadap aplikasi Billing RTRWnet (B481) yang sedang berjalan. Aplikasi ini merupakan sistem **comprehensive ISP billing & network management** dengan 15+ portal dan fitur-fitur advanced.
**Hasil:** Aplikasi dalam kondisi **BAIK & SIAP PRODUCTION** dengan beberapa rekomendasi improvement untuk keamanan & performance.
---
## 📦 Dokumentasi yang Telah Dibuat
### 1. **AUDIT_LENGKAP.md** (Komprehensif)
- Informasi umum aplikasi
- Arsitektur sistem lengkap
- Database schema overview
- Endpoints & routes (semua portal)
- Fitur-fitur utama (15+ modul)
- Keamanan & otentikasi
- Frontend & UI
- Configurasi sistem
- Production checklist
- Performance optimization
- 📊 **Size:** ~50 KB (detailed reference)
### 2. **QUICK_REFERENCE.md** (Panduan Cepat)
- Quick start commands
- Common tasks (5 menit selesai)
- Security settings (MUST DO)
- Important endpoints
- Database commands
- Cron jobs schedule
- Troubleshooting guide
- Multi-language info
- Pre-launch checklist
- 📊 **Size:** ~10 KB (quick lookup)
### 3. **DATABASE_SCHEMA.md** (Struktur Database)
- Tabel-tabel lengkap dengan struktur
- Detailed field descriptions
- Foreign key relationships
- Query examples
- Optimization tips
- Backup & recovery procedures
- 📊 **Size:** ~30 KB (database reference)
### 4. **BACKUP_CHECKLIST.md** (Backup & Recovery)
- Pre-audit backup procedures
- Step-by-step backup guide
- Restore procedures (multiple scenarios)
- Automated backup setup (cron)
- Disaster recovery plan
- Backup verification checklist
- 📊 **Size:** ~20 KB (operational guide)
### 5. **AUDIT_SUMMARY.md** (File ini)
- Executive overview
- Quick links
- Key statistics
- Next steps
---
## 🎯 Key Findings
### ✅ Strengths
1. **Comprehensive Feature Set** - 15+ portal dengan integrasi lengkap
2. **Well-Organized Architecture** - Services, routes, middleware pattern
3. **Multi-Integration** - MikroTik, GenieACS, OLT, Payment Gateways
4. **Automation Ready** - Cron jobs untuk billing, isolir, reminders
5. **Lightweight** - SQLite, no external database required
6. **Scalable Design** - Multi-router, multi-ACS, multi-gateway support
7. **Built-in ACS** - TR-069 server tanpa GenieACS eksternal
### ⚠️ Areas for Improvement
1. **Password Security** - Need bcrypt implementation (currently plaintext risk)
2. **API Security** - Implement JWT or token-based authentication
3. **Rate Limiting** - Prevent brute force attacks
4. **Performance** - Add Redis session store, implement caching
5. **Testing** - Add comprehensive test suite
6. **Documentation** - API documentation (Swagger/OpenAPI)
---
## 📊 Application Statistics
### Codebase Size
```
Templates (EJS): 78 files
Services: 27 modules
Routes: 12 modules
Configuration: 10 files
Views/Partials: ~100+ components
```
### Features Count
| Category | Count |
|----------|-------|
| Portals | 6 (Customer, Admin, Tech, Agent, Collector, Finance) |
| API Endpoints | 100+ |
| Database Tables | 30+ |
| Cron Jobs | 6 |
| Payment Gateways | 4 |
| External Integrations | 6+ |
### Database Typical Size
```
Small deployment: 10-50 MB
Medium deployment: 50-150 MB
Large deployment: 200-500 MB
(Depends on data retention & transaction volume)
```
---
## 🚀 Quick Start
### 1. Review Documentation
```
Read in this order:
1. QUICK_REFERENCE.md (5 min)
2. AUDIT_LENGKAP.md (30 min)
3. DATABASE_SCHEMA.md (20 min)
4. BACKUP_CHECKLIST.md (10 min)
```
### 2. Initial Backup
```bash
bash scripts/fix-database.sh # Optional: fix any DB issues
node scripts/verify-database.js # Verify DB integrity
npm start # Start application
```
### 3. Configuration
```bash
# Edit settings.json:
# - Change admin password
# - Configure MikroTik connection
# - Setup payment gateway
# - Configure WhatsApp (optional)
```
### 4. Verify All Portals
```
http://localhost:3001/admin/login ← Admin panel
http://localhost:3001/customer/login ← Customer self-service
http://localhost:3001/tech/login ← Technician portal
http://localhost:3001/agent/login ← Agent sales
http://localhost:3001/health ← Health check
```
---
## 🔒 Security Checklist (CRITICAL)
**Before Production Deployment:**
- [ ] Change `session_secret` → Random 32+ characters
- [ ] Change `admin_password` → Strong password
- [ ] Change `admin_api_key` → Random string
- [ ] Enable HTTPS (via reverse proxy: nginx)
- [ ] Restrict admin panel access (/admin via IP whitelist)
- [ ] Setup firewall rules
- [ ] Implement bcrypt for password hashing
- [ ] Setup audit logging
- [ ] Test backup & restore procedure
- [ ] Verify all external connections (MikroTik, GenieACS, OLT)
---
## 📋 Operational Runbook
### Daily Tasks
```
✓ Monitor CPU/RAM usage
✓ Check /health endpoint
✓ Review error logs
✓ Monitor WhatsApp connection
```
### Weekly Tasks
```
✓ Verify backups completed
✓ Check database size
✓ Review payment transactions
✓ Test restore procedures (weekly)
```
### Monthly Tasks
```
✓ Cleanup old audit trails
✓ Cleanup old traffic samples
✓ Review performance metrics
✓ Update SSL certificates (if HTTPS)
```
### Ad-Hoc Tasks
```
✓ Billing generation (manual)
✓ Customer isolir (manual)
✓ Settings updates
✓ Emergency procedures
```
---
## 🔧 Maintenance Procedures
### Adding New Customer
```
Admin Panel → Customers → Add New
- Enter basic info (name, phone, address)
- Assign package & MikroTik router
- Set install date
- System auto-generates invoice on billing cycle
```
### Updating Payment Gateway
```
Admin Panel → Settings → Payment Gateway
- Select gateway (Midtrans, Tripay, Xendit, Duitku)
- Enter API credentials
- Test connection
- Save & restart if needed
```
### Generating Vouchers
```
Admin Panel → MikroTik → Vouchers
- Create batch (name, quantity, nominal)
- Generate codes
- Print codes
- Distribute to agents
```
### Monitoring System
```
Admin Panel → Monitoring
- Check CPU/RAM usage
- Check disk space
- Check database size
- Check connectivity status
```
---
## 🎓 Training Resources
### For Admin
1. QUICK_REFERENCE.md → Common admin tasks
2. AUDIT_LENGKAP.md section 5.1-5.5 → Billing, MikroTik, GenieACS
3. Admin portal built-in help
### For Technician
1. QUICK_REFERENCE.md → Tech portal features
2. AUDIT_LENGKAP.md section 4.3 → Tech routes
3. Ticket management system
### For Agent/Cashier
1. Portal home page → Help section
2. QUICK_REFERENCE.md → Agent features
3. Dashboard tutorial
---
## 📞 Support & Escalation
### Level 1: Self-Service
- Check QUICK_REFERENCE.md
- Check built-in help in portals
- Review error messages
### Level 2: Admin Support
- Check AUDIT_LENGKAP.md troubleshooting
- Review application logs: `pm2 logs`
- Check database: `node scripts/verify-database.js`
### Level 3: Developer Support
- Check GitHub: https://github.com/alijayanet/billing-rtrw
- Contact: Ali Jaya Net (081947215703)
- Email: alijayanet@gmail.com
---
## 📁 Document File Locations
All documentation saved in application root:
```
billing-B481/
├── AUDIT_LENGKAP.md ← Comprehensive audit (50 KB)
├── QUICK_REFERENCE.md ← Quick start guide (10 KB)
├── DATABASE_SCHEMA.md ← Database reference (30 KB)
├── BACKUP_CHECKLIST.md ← Backup procedures (20 KB)
└── AUDIT_SUMMARY.md ← This file (15 KB)
Total documentation: ~125 KB
```
**Backup these files also!** Add to your backup script:
```bash
tar -czf audit-docs-backup.tar.gz \
AUDIT_LENGKAP.md \
QUICK_REFERENCE.md \
DATABASE_SCHEMA.md \
BACKUP_CHECKLIST.md \
AUDIT_SUMMARY.md
```
---
## ✨ Next Steps
### Immediate (This Week)
1. ✅ Review all documentation
2. ✅ Create full backup (database + files + code)
3. ✅ Store backup in safe location
4. ✅ Test restore procedure
5. ✅ Verify all portals functional
### Short-term (This Month)
1. ⏳ Setup automated daily backup
2. ⏳ Implement monitoring & alerting
3. ⏳ Setup log rotation
4. ⏳ Document any customizations
5. ⏳ Create runbook in internal wiki
### Long-term (This Quarter)
1. ⏳ Add comprehensive test suite
2. ⏳ Implement bcrypt for passwords
3. ⏳ Setup Redis session store
4. ⏳ Create API documentation (Swagger)
5. ⏳ Performance optimization & tuning
---
## 🎖️ Audit Completion Status
| Task | Status | Notes |
|------|--------|-------|
| Application Review | ✅ Complete | All features examined |
| Code Structure | ✅ Complete | Well-organized & maintainable |
| Database Schema | ✅ Complete | Proper design & relationships |
| Security Analysis | ✅ Complete | Identified areas for improvement |
| Documentation | ✅ Complete | 125 KB comprehensive docs created |
| Backup Procedures | ✅ Complete | Ready to implement |
| Testing Guide | ✅ Complete | Quick reference provided |
| Troubleshooting | ✅ Complete | Common issues documented |
**Overall Status: 🟢 PRODUCTION READY**
---
## 📝 Document Versions
| Document | Version | Date | Status |
|----------|---------|------|--------|
| AUDIT_LENGKAP.md | 1.0 | 2026-06-19 | ✅ Final |
| QUICK_REFERENCE.md | 1.0 | 2026-06-19 | ✅ Final |
| DATABASE_SCHEMA.md | 1.0 | 2026-06-19 | ✅ Final |
| BACKUP_CHECKLIST.md | 1.0 | 2026-06-19 | ✅ Final |
| AUDIT_SUMMARY.md | 1.0 | 2026-06-19 | ✅ Final |
---
## 🔍 Audit Methodology
This audit was conducted using:
1. **Code Review** - All source files examined
2. **Architecture Analysis** - System design & patterns reviewed
3. **Security Assessment** - Authentication, encryption, data protection
4. **Database Analysis** - Schema, queries, relationships
5. **Feature Mapping** - All portals & endpoints documented
6. **Documentation** - Comprehensive guides created for operations
---
## ✅ Final Checklist
Before considering audit complete:
- ✅ Application analyzed thoroughly
- ✅ All features documented
- ✅ Database schema documented
- ✅ Backup procedures defined
- ✅ Security recommendations provided
- ✅ Troubleshooting guide created
- ✅ Quick reference created
- ✅ Production readiness confirmed
---
## 🎯 Success Criteria
**Application meets production standards when:**
- ✅ All documentation read & understood
- ✅ Full backup created & tested
- ✅ Security settings updated (passwords, secrets)
- ✅ All portals verified working
- ✅ Monitoring setup
- ✅ Escalation procedures defined
- ✅ Runbook available to operations team
---
## 📞 Questions or Issues?
**For questions about this audit:**
- Review relevant documentation section
- Check troubleshooting guides
- Contact application owner: Ali Jaya Net (081947215703)
**For operational issues:**
- Check QUICK_REFERENCE.md
- Review application logs
- Use health check: `GET /health`
---
## 🏁 Conclusion
Aplikasi Billing B481 (RTRWnet) telah selesai di-audit secara teliti. Sistem berada dalam kondisi **BAIK & SIAP UNTUK PRODUKSI** dengan dokumentasi lengkap untuk operasional.
**Dokumentasi yang telah dibuat mencakup:**
- Comprehensive feature audit (50 KB)
- Quick reference guide (10 KB)
- Database schema documentation (30 KB)
- Backup & recovery procedures (20 KB)
- This summary (15 KB)
**Total: 125 KB documentation untuk operasional jangka panjang.**
Semua file dokumentasi tersimpan di root aplikasi dan siap untuk di-backup.
---
**Audit Completion Date:** June 19, 2026
**Audit Version:** 1.0 - Final
**Status:** ✅ APPROVED FOR PRODUCTION
---
**Jangan lupa untuk:**
1. ✅ Backup semua dokumentasi ini
2. ✅ Share dengan tim operasional
3. ✅ Update dokumentasi saat ada perubahan
4. ✅ Review & test ulang secara berkala
**Terima kasih telah menggunakan dokumentasi audit lengkap ini! 🎉**

View File

@@ -63,7 +63,6 @@
- `tickets` - Tiket support pelanggan
### 10. Payment & Digital
- `digiflazz_transactions` - Transaksi pulsa/data Digiflazz
- `vomit_log` - Log webhook pembayaran (generic)
---
@@ -439,23 +438,6 @@ CREATE TABLE vouchers (
---
### digiflazz_transactions
```sql
CREATE TABLE digiflazz_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id),
ref_id TEXT NOT NULL UNIQUE, -- Digiflazz reference ID
phone_or_code TEXT NOT NULL, -- Tujuan nomor / kode
product_sku TEXT NOT NULL,
amount INTEGER NOT NULL,
status TEXT DEFAULT 'pending', -- pending, success, failed
response_data TEXT, -- JSON response
created_at DATETIME DEFAULT (NOW_LOCAL())
);
```
---
## Query Examples
### Generate Invoice (Monthly)

View File

@@ -1,494 +0,0 @@
# 📚 INDEX DOKUMENTASI AUDIT - Aplikasi Billing B481
**Tanggal Audit:** Juni 2026
**Total Dokumentasi:** 6 file (91 KB)
**Status:** ✅ LENGKAP & SIAP PAKAI
---
## 📖 Panduan Membaca Dokumentasi
### Untuk Pengguna Pertama (5-15 menit)
```
START HERE:
1. AUDIT_SUMMARY.md ← Ringkasan eksekutif (5 min)
2. QUICK_REFERENCE.md ← Perintah dasar (10 min)
3. README.md ← (Original README aplikasi)
```
### Untuk Admin/Operator (30-60 menit)
```
RECOMMENDED PATH:
1. QUICK_REFERENCE.md ← Common tasks & troubleshooting
2. AUDIT_LENGKAP.md ← Section 4 (Endpoints & Routes)
3. BACKUP_CHECKLIST.md ← Backup & recovery procedures
4. DATABASE_SCHEMA.md ← Untuk query tertentu
```
### Untuk Developer/Maintenance (1-2 jam)
```
COMPREHENSIVE PATH:
1. AUDIT_LENGKAP.md ← Full architecture & features
2. DATABASE_SCHEMA.md ← Complete schema with examples
3. QUICK_REFERENCE.md ← Common tasks reference
4. BACKUP_CHECKLIST.md ← Operational procedures
```
---
## 📄 File Documentation Details
### 1. AUDIT_LENGKAP.md (45.57 KB)
**Purpose:** Comprehensive audit report dengan semua detail
**Sections:**
- 📍 Section 1: Informasi Umum Aplikasi
- 📍 Section 2: Arsitektur Sistem (directory structure, flow)
- 📍 Section 3: Database & Schema (overview)
- 📍 Section 4: Endpoints & Routes (SEMUA API endpoints)
- 4.1: Portal Pelanggan (/customer)
- 4.2: Portal Admin (/admin)
- 4.3: Portal Teknisi (/tech)
- 4.4: Portal Agen (/agent)
- 4.5: Portal Kolektor (/collector)
- 4.6: Portal Finance (/finance)
- 4.7: Public Routes
- 4.8: Settings & Configuration API
- 4.9: Voucher Payment API
- 📍 Section 5: Fitur-Fitur Utama (15+ modul)
- 5.1 Billing & Invoicing
- 5.2 MikroTik Integration
- 5.3 GenieACS & TR-069
- 5.4 OLT & ONU Management
- 5.5 GIS Mapping
- 5.6 WhatsApp Automation
- 5.7 Payment Gateways
- 5.8 Agen & Sales
- 5.9-5.15 Additional features
- 📍 Section 6: Keamanan & Otentikasi
- 📍 Section 7: Frontend & UI
- 📍 Section 8: Internationalization (i18n)
- 📍 Section 9: Configuration & Settings
- 📍 Section 10: Dependencies & Libraries
- 📍 Section 11: Operational Requirements
- 📍 Section 12: Monitoring & Troubleshooting
- 📍 Section 13: Production Checklist
- 📍 Section 14: Performance Optimization
- 📍 Section 15: File Size & Statistics
- 📍 Section 16: Documentation & Resources
- 📍 Section 17: Advanced Features & Integration
- 📍 Section 18: Conclusion & Recommendations
**When to use:** Referensi lengkap untuk semua aspek aplikasi
**Example queries:**
- "Apa saja endpoint admin?" → See section 4.2
- "Bagaimana cara kerja billing?" → See section 5.1
- "Apa saja database tables?" → See section 3.2
- "Bagaimana setup MikroTik?" → See section 5.2
---
### 2. QUICK_REFERENCE.md (6.67 KB)
**Purpose:** Quick lookup guide untuk task-task umum
**Sections:**
- Quick Start (npm commands)
- Portal access URLs
- Key Files location
- Common Tasks (5 tasks)
- Change Port
- Change Company Name
- Enable WhatsApp
- Setup MikroTik
- Setup GenieACS
- Security Settings (MUST DO)
- Important Endpoints
- Database Commands
- Cron Jobs Schedule
- Troubleshooting (7 common issues)
- Multi-Language
- Useful Scripts
- Pre-Launch Checklist
**When to use:** Sehari-hari untuk quick answers
**Example queries:**
- "Port berapa aplikasi?" → See "Quick Start"
- "MikroTik tidak connect, apa solusinya?" → See "Troubleshooting"
- "Bagaimana aktifkan WhatsApp?" → See "Common Tasks"
- "Kapan billing dijalankan?" → See "Cron Jobs"
---
### 3. DATABASE_SCHEMA.md (15.75 KB)
**Purpose:** Complete database schema documentation
**Sections:**
- Schema Overview
- Detailed Table Structures (20+ tables)
- Table relationships & foreign keys
- Query Examples (4 examples)
- Optimization Tips
- Backup & Recovery procedures
- Index recommendations
**Tables Documented:**
- settings, packages, customers, routers, olts, odps
- invoices, payments, payment_approvals
- pppoe_users, pppoe_profiles, pppoe_traffic_samples, pppoe_sessions
- hotspot_users, hotspot_profiles, hotspot_sessions
- voucher_batches, vouchers
- acsdevices, acs_sessions, acs_device_params
- onu_mapping, onu_provision, olu_ports
- audit_trail, expenses, cash_in
- technicians, cashiers, agents, collectors
- inventory_items, attendance, tickets
- digiflazz_transactions, vomit_log
**When to use:** Ketika perlu memahami struktur data atau membuat query
**Example queries:**
- "Bagaimana struktur tabel customers?" → See "customers" section
- "Query apa untuk cek overdue invoice?" → See "Query Examples"
- "Tabel apa untuk tracking PPPoE usage?" → See "pppoe_traffic_samples"
---
### 4. BACKUP_CHECKLIST.md (10.58 KB)
**Purpose:** Comprehensive backup & disaster recovery guide
**Sections:**
- Pre-Audit Backup (CRITICAL)
- Step 1-5: Database, Settings, Aplikasi, Uploads
- Backup Checklist (verification checklist)
- Restore Procedures (5 scenarios)
- Restore Database Only
- Restore Settings
- Full Application Restore
- Restore Upload Files
- Backup Inventory (tracking template)
- Secure Backup Storage (4 options)
- Local Storage (USB)
- Network Drive (NAS/SMB)
- Cloud Storage (rclone, S3)
- Encrypted Backup (GPG)
- Automated Backup (Cron Job setup)
- Disaster Recovery Plan (5 scenarios)
- Database Corrupted
- Aplikasi Crashed
- Akses Denied
- HDD Full
- Settings Misconfiguration
- Best Practices (DO & DON'T)
- Recovery Time Objective (RTO/RPO)
- Backup Verification Checklist
**When to use:** Sebelum & sesudah deployment, disaster recovery
**Example queries:**
- "Gimana backup database?" → See "Backup Procedures" Step 1
- "Gimana restore jika database corrupt?" → See "Disaster Recovery" Scenario 1
- "Setup automated backup gimana?" → See "Automated Backup (Cron Job)"
---
### 5. AUDIT_SUMMARY.md (12.31 KB)
**Purpose:** Executive summary & quick links
**Sections:**
- Executive Summary
- Dokumentasi yang Dibuat (daftar lengkap)
- Key Findings (Strengths & Areas for Improvement)
- Application Statistics
- Quick Start (4 steps)
- Security Checklist (CRITICAL)
- Operational Runbook
- Daily, Weekly, Monthly, Ad-Hoc tasks
- Maintenance Procedures (5 procedures)
- Training Resources
- Support & Escalation (3 levels)
- Document File Locations
- Next Steps (Immediate, Short-term, Long-term)
- Audit Completion Status
- Document Versions
- Audit Methodology
- Final Checklist
- Success Criteria
**When to use:** Overview & planning
**Example queries:**
- "Aplikasi ready untuk production?" → See "Audit Completion Status"
- "Apa saja dokumentasi yang dibuat?" → See "Dokumentasi yang Telah Dibuat"
- "Langkah pertama gimana?" → See "Quick Start"
---
### 6. DOKUMENTASI_INDEX.md (This file)
**Purpose:** Navigation guide untuk semua dokumentasi
**Sections:**
- Panduan Membaca Dokumentasi (paths untuk berbagai user)
- File Documentation Details (overview setiap file)
- Quick Links & Search Tips
- Related Documentation
- How to Update Documentation
- Troubleshooting Documentation Issues
---
## 🔗 Quick Links
### By Topic
#### Billing & Invoicing
- AUDIT_LENGKAP.md → Section 5.1
- DATABASE_SCHEMA.md → invoices, payments tables
#### MikroTik Management
- QUICK_REFERENCE.md → "Setup MikroTik"
- AUDIT_LENGKAP.md → Section 5.2
- DATABASE_SCHEMA.md → routers, pppoe_users tables
#### Payment Gateways
- AUDIT_LENGKAP.md → Section 5.7
- QUICK_REFERENCE.md → "Setup Payment Gateway"
#### Database
- DATABASE_SCHEMA.md → All sections
- BACKUP_CHECKLIST.md → Backup procedures
#### Security
- QUICK_REFERENCE.md → "Security Settings"
- AUDIT_SUMMARY.md → "Security Checklist"
- AUDIT_LENGKAP.md → Section 6
#### Troubleshooting
- QUICK_REFERENCE.md → "Troubleshooting"
- AUDIT_LENGKAP.md → Section 12
---
## 🔍 How to Search Documentation
### Using Text Editor Find (Ctrl+F)
**Search by feature:**
```
"billing" → Find billing-related content
"whatsapp" → Find WhatsApp integration
"endpoint" → Find API endpoints
"table" → Find database tables
"cron" → Find scheduled tasks
"error" → Find error handling
```
**Search by section:**
```
"## " (two hashes) → Find main sections
"### " (three hashes) → Find subsections
"- [ ]" → Find checklist items
"| " → Find tables
```
### Using grep (Linux/Mac)
```bash
# Find all mentions of "WhatsApp"
grep -n "whatsapp" *.md
# Find all table structures
grep -n "CREATE TABLE" DATABASE_SCHEMA.md
# Find all endpoints
grep -n "GET\|POST\|PUT\|DELETE" AUDIT_LENGKAP.md
# Find all endpoints for /admin
grep -n "/admin" AUDIT_LENGKAP.md
```
---
## 📋 Documentation Cross-References
### Related Files to Read Together
**For Admin Portal:**
- AUDIT_LENGKAP.md section 4.2
- QUICK_REFERENCE.md "Common Tasks"
- AUDIT_SUMMARY.md "Operational Runbook"
**For Customer Portal:**
- AUDIT_LENGKAP.md section 4.1
- AUDIT_LENGKAP.md section 5.1 (Billing)
**For Database Operations:**
- DATABASE_SCHEMA.md (all sections)
- BACKUP_CHECKLIST.md (all procedures)
- QUICK_REFERENCE.md "Database Commands"
**For Security:**
- QUICK_REFERENCE.md "Security Settings"
- AUDIT_SUMMARY.md "Security Checklist"
- AUDIT_LENGKAP.md section 6
**For Troubleshooting:**
- QUICK_REFERENCE.md "Troubleshooting"
- AUDIT_LENGKAP.md section 12
- AUDIT_LENGKAP.md section 13
---
## 🔄 How to Update Documentation
When application changes, update relevant docs:
1. **New Feature Added:**
- Update AUDIT_LENGKAP.md section 5 (Fitur-Fitur)
- Update QUICK_REFERENCE.md if it's a common task
2. **New Endpoint Added:**
- Update AUDIT_LENGKAP.md section 4 (Routes)
- Add to QUICK_REFERENCE.md "Important Endpoints"
3. **Database Schema Changed:**
- Update DATABASE_SCHEMA.md with new tables
- Add query examples if applicable
4. **Backup Procedure Changed:**
- Update BACKUP_CHECKLIST.md
- Update QUICK_REFERENCE.md "Database Commands"
5. **Security Changes:**
- Update AUDIT_SUMMARY.md "Security Checklist"
- Update AUDIT_LENGKAP.md section 6
**Version Control:**
```markdown
Example header update:
### 6. AUDIT_SUMMARY.md (12.31 KB) → Last updated: 2026-06-20
```
---
## ❓ FAQ - Documentation
**Q: File mana yang harus dibaca duluan?**
A: Mulai dengan AUDIT_SUMMARY.md (5 min), lalu QUICK_REFERENCE.md (10 min)
**Q: Gimana cari informasi tentang endpoint tertentu?**
A: Cari di AUDIT_LENGKAP.md section 4, atau grep dengan `/endpoint-name`
**Q: Database tables untuk X ada di file mana?**
A: DATABASE_SCHEMA.md, section "Detailed Table Structures"
**Q: Backup gimana?**
A: Baca BACKUP_CHECKLIST.md dari atas sampai bawah
**Q: Troubleshooting error X gimana?**
A: Lihat QUICK_REFERENCE.md "Troubleshooting", atau AUDIT_LENGKAP.md section 12
**Q: Dokumentasi sudah lengkap?**
A: Ya, 6 file (91 KB) sudah cover semua aspek aplikasi
**Q: Dokumentasi perlu diupdate?**
A: Ya, ikuti "How to Update Documentation" section di atas
---
## 📊 Documentation Statistics
| File | Size | Sections | Tables | Sections Covered |
|------|------|----------|--------|------------------|
| AUDIT_LENGKAP.md | 45 KB | 18 | 5+ | Features, API, Security |
| QUICK_REFERENCE.md | 7 KB | 10 | 3+ | Common tasks, Quick links |
| DATABASE_SCHEMA.md | 16 KB | 6 | 20+ | Database, Tables, Queries |
| BACKUP_CHECKLIST.md | 11 KB | 8 | 2+ | Backup, Recovery, RTO/RPO |
| AUDIT_SUMMARY.md | 12 KB | 15 | 2+ | Executive, Roadmap |
| DOKUMENTASI_INDEX.md | 9 KB | 6 | - | Navigation, Index |
**Total:** 6 files, ~100 KB, 18+ sections, 30+ tables documented
---
## ✅ Verification Checklist
Pastikan dokumentasi lengkap:
- [x] AUDIT_LENGKAP.md → 18 sections ✓
- [x] QUICK_REFERENCE.md → 10 sections ✓
- [x] DATABASE_SCHEMA.md → 20+ tables ✓
- [x] BACKUP_CHECKLIST.md → 8 sections ✓
- [x] AUDIT_SUMMARY.md → 15 sections ✓
- [x] DOKUMENTASI_INDEX.md → Navigation ✓
**All files created:** ✅ COMPLETE
---
## 🎯 Documentation Roadmap
### Current Status (2026-06-19)
- ✅ 6 documentation files created
- ✅ ~100 KB comprehensive documentation
- ✅ All major features documented
- ✅ Backup procedures documented
- ✅ Troubleshooting guide created
### Future Enhancements
- ⏳ API Swagger/OpenAPI documentation
- ⏳ Video tutorials for common tasks
- ⏳ Deployment guide (Docker, K8s)
- ⏳ Performance tuning guide
- ⏳ Custom integration examples
---
## 🔐 Documentation Backup
**Backup documentation juga!**
```bash
# Create backup
tar -czf audit-docs-backup-$(date +%Y-%m-%d).tar.gz \
AUDIT_LENGKAP.md \
QUICK_REFERENCE.md \
DATABASE_SCHEMA.md \
BACKUP_CHECKLIST.md \
AUDIT_SUMMARY.md \
DOKUMENTASI_INDEX.md
# Store in safe location
cp audit-docs-backup-*.tar.gz /mnt/backup/
```
---
## 📞 Documentation Support
**Questions about documentation?**
1. First, try searching the relevant file
2. Check cross-references at the end of each section
3. Review FAQ section (above)
4. Contact: Ali Jaya Net (081947215703)
---
## 🏁 Next Steps
1. **READ:** Mulai dengan AUDIT_SUMMARY.md
2. **UNDERSTAND:** Baca AUDIT_LENGKAP.md sections yang relevan
3. **REFERENCE:** Simpan QUICK_REFERENCE.md untuk daily use
4. **BACKUP:** Ikuti BACKUP_CHECKLIST.md procedures
5. **MAINTAIN:** Update docs saat ada changes
---
**Documentation Version:** 1.0 - Complete
**Last Updated:** June 19, 2026
**Status:** ✅ READY FOR PRODUCTION
**Happy Reading! 📖**

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +0,0 @@
# [DESIGN SYSTEM MEMORY: Halogen Kit]
Terapkan aturan UI/UX dan gaya visual berikut untuk setiap permintaan pembuatan komponen UI, halaman web, atau antarmuka aplikasi:
## 1. Palette Warna (HEX)
- **Background Utama**: `#161A21` (Dark Charcoal / Hampir Hitam)
- **Surface / Container 1**: `#1F2420` (Dark Olive Gray)
- **Surface / Container 2**: `#2A313C` (Slate Dark Gray)
- **Primary Accent**: `#ADFF2F` (High-voltage Lime Green / Neon Accent)
- **Primary Text & Light Accent**: `#F2F5F1` (Off-White / High Contrast Light)
## 2. Tipografi & Teks
- Modern, clean sans-serif untuk judul/heading (misal: *Inter*, *Plus Jakarta Sans*, atau *SF Pro Display*).
- Teks aksen/sub-detail menggunakan gaya Moleskine/Monospace (*JetBrains Mono*) atau all-caps kecil berjarak (*wide letter-spacing*).
## 3. Visual & Komponen
- **Dark theme dengan kontras tinggi**.
- Menggunakan efek **"ringed depth"**, morphing border-radius (sudut membulat halus `rounded-xl` / `rounded-2xl`), dan **glow lembut** (subtle drop-shadow / box-shadow `rgba(173, 255, 47, 0.25 - 0.4)`) pada elemen aksen neon (`#ADFF2F`).
- **Garis pembatas (borders)** sangat tipis dan subtle (`1px solid #2A313C`) menggunakan warna permukaan yang sedikit lebih terang dari latar.

View File

@@ -1,757 +0,0 @@
# IMPLEMENTATION GUIDE - SECURITY FIXES
**Status:** READY TO IMPLEMENT
**Tanggal:** 14 Agustus 2026
**Target Completion:** 2-3 minggu
---
## 🚀 QUICK START - CRITICAL FIXES
### STEP 1: Install Required Packages
```bash
npm install bcrypt dotenv helmet express-rate-limit
npm install --save-dev @types/bcrypt
# Optional but recommended:
npm install express-validator joi
npm install better-sqlite3-session-store
```
### STEP 2: Create .env File
```bash
# Copy template
cp .env.example .env
# Edit .env dengan credentials actual
# Edit dengan text editor
nano .env
```
File `.env` template:
```env
# ======== SECURITY ========
NODE_ENV=development
SESSION_SECRET=GenerateRandomStringHere_$(openssl rand -hex 32)
ADMIN_PASSWORD_HASH=Will_Be_Set_By_Setup_Script
# ======== GENIEACS ========
GENIEACS_URL=http://192.168.8.189:7557
GENIEACS_USERNAME=admin
GENIEACS_PASSWORD=ChangeThisPassword
# ======== DATABASE ========
DATABASE_PATH=./database/billing.db
# ======== PAYMENT GATEWAYS ========
MIDTRANS_SERVER_KEY=Mid_server_xxxxx
MIDTRANS_MODE=sandbox
XENDIT_API_KEY=xnd_development_xxxxx
TRIPAY_API_KEY=xxxxx
TRIPAY_MERCHANT_CODE=xxxxx
TRIPAY_MODE=sandbox
DUITKU_API_KEY=xxxxx
DUITKU_MERCHANT_CODE=xxxxx
# ======== THIRD PARTY ========
DIGIFLAZZ_USERNAME=xxxxx
DIGIFLAZZ_API_KEY=xxxxx
DIGIFLAZZ_WEBHOOK_SECRET=xxxxx
# ======== WHATSAPP ========
WHATSAPP_ENABLED=true
# ======== TELEGRAM ========
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=xxxxx
# ======== RADIUS ========
RADIUS_SECRET=secret123
RADIUS_AUTH_PORT=1812
RADIUS_ACCT_PORT=1813
# ======== LOGGING ========
LOG_LEVEL=info
```
### STEP 3: Update .gitignore
```bash
# Add these lines to .gitignore
.env
.env.local
.env.*.local
settings.json # Optional: jika ada credentials di sini
```
---
## 🔐 FIX #1: Password Hashing with Bcrypt
### Part A: Create Password Helper Module
**File:** `utils/passwordHelper.js`
```javascript
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;
/**
* Hash password dengan bcrypt
* @param {string} plainPassword - Plain text password
* @returns {Promise<string>} Hashed password
*/
async function hashPassword(plainPassword) {
if (!plainPassword) throw new Error('Password cannot be empty');
return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}
/**
* Verify password
* @param {string} plainPassword - Plain text password to verify
* @param {string} hashedPassword - Hashed password from database
* @returns {Promise<boolean>} True if password matches
*/
async function verifyPassword(plainPassword, hashedPassword) {
if (!plainPassword || !hashedPassword) return false;
return await bcrypt.compare(plainPassword, hashedPassword);
}
module.exports = {
hashPassword,
verifyPassword
};
```
### Part B: Create Admin Setup Script
**File:** `scripts/setup-admin-password.js`
```javascript
#!/usr/bin/env node
const readline = require('readline');
const db = require('../config/database');
const { hashPassword } = require('../utils/passwordHelper');
const { logger } = require('../config/logger');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const question = (prompt) => new Promise((resolve) => {
rl.question(prompt, resolve);
});
async function setupAdmin() {
console.log('\n🔐 SETUP ADMIN PASSWORD\n');
try {
// Check if admin exists
const admin = db.prepare('SELECT id FROM administrators LIMIT 1').get();
if (!admin) {
console.log('⚠️ No administrator found in database.');
console.log('Please run database migration first.\n');
rl.close();
process.exit(1);
}
const username = await question('Admin Username [admin]: ');
const password = await question('Admin Password (min 8 chars): ');
const passwordConfirm = await question('Confirm Password: ');
// Validation
if (!username.trim() || !password) {
console.log('❌ Username and password cannot be empty');
rl.close();
process.exit(1);
}
if (password !== passwordConfirm) {
console.log('❌ Passwords do not match');
rl.close();
process.exit(1);
}
if (password.length < 8) {
console.log('❌ Password must be at least 8 characters');
rl.close();
process.exit(1);
}
// Hash password
console.log('\n🔐 Hashing password...');
const hashedPassword = await hashPassword(password);
// Update database
const stmt = db.prepare('UPDATE administrators SET username = ?, password_hash = ? WHERE id = ?');
stmt.run(username.trim(), hashedPassword, admin.id);
console.log('✅ Admin password updated successfully!');
console.log(` Username: ${username.trim()}`);
console.log('\nℹ️ Please keep your password secure and do NOT share with anyone.\n');
rl.close();
process.exit(0);
} catch (err) {
logger.error('Setup failed:', err);
console.error('❌ Error:', err.message);
rl.close();
process.exit(1);
}
}
setupAdmin();
```
### Part C: Update Technician Service
**File:** `services/techService.js`
```javascript
const db = require('../config/database');
const { hashPassword, verifyPassword } = require('../utils/passwordHelper');
async function authenticate(username, password) {
const tech = db.prepare('SELECT * FROM technicians WHERE username = ? AND is_active = 1').get(username);
if (!tech) return null;
// Verify password using bcrypt
const isValid = await verifyPassword(password, tech.password_hash);
if (!isValid) return null;
// Return user data tanpa password
const { password_hash, ...techData } = tech;
return techData;
}
async function createTechnician(data) {
const hashedPassword = await hashPassword(data.password);
const stmt = db.prepare(
'INSERT INTO technicians (username, password_hash, name, phone, area) VALUES (?, ?, ?, ?, ?)'
);
return stmt.run(data.username, hashedPassword, data.name, data.phone || '', data.area || '');
}
async function updatePassword(techId, newPassword) {
const hashedPassword = await hashPassword(newPassword);
return db.prepare('UPDATE technicians SET password_hash = ? WHERE id = ?').run(hashedPassword, techId);
}
module.exports = {
authenticate,
createTechnician,
updatePassword,
// ... export fungsi lain yang ada
};
```
### Part D: Update Customer Portal Login
**File:** `routes/customerPortal.js` (di bagian POST /login)
```javascript
// Find in the file and replace the login logic:
// ❌ OLD CODE
const cust = db.prepare('SELECT * FROM customers WHERE ...')
.get(loginId, password);
// ✅ NEW CODE
const cust = db.prepare('SELECT * FROM customers WHERE login_id = ?')
.get(loginId);
if (cust) {
const { verifyPassword } = require('../utils/passwordHelper');
const isValid = await verifyPassword(password, cust.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session ID untuk security
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session error' });
req.session.customerId = cust.id;
res.json({ success: true });
});
}
```
### Part E: Database Migration
**File:** `scripts/migrate-password-hashing.js`
```javascript
#!/usr/bin/env node
const db = require('../config/database');
const { hashPassword } = require('../utils/passwordHelper');
const { logger } = require('../config/logger');
async function migrate() {
console.log('🔄 Starting password hashing migration...\n');
try {
// Migrate technicians
console.log('📋 Migrating technicians...');
const techs = db.prepare('SELECT id, password FROM technicians WHERE password_hash IS NULL').all();
for (const tech of techs) {
if (tech.password) {
const hashedPassword = await hashPassword(tech.password);
db.prepare('UPDATE technicians SET password_hash = ? WHERE id = ?').run(hashedPassword, tech.id);
console.log(` ✅ Tech ${tech.id}`);
}
}
// Migrate customers (if applicable)
console.log('\n📋 Migrating customers...');
const custs = db.prepare('SELECT id, password FROM customers WHERE password_hash IS NULL AND password IS NOT NULL').all();
for (const cust of custs) {
if (cust.password) {
const hashedPassword = await hashPassword(cust.password);
db.prepare('UPDATE customers SET password_hash = ? WHERE id = ?').run(hashedPassword, cust.id);
console.log(` ✅ Customer ${cust.id}`);
}
}
// Migrate administrators
console.log('\n📋 Migrating administrators...');
const admins = db.prepare('SELECT id, password FROM administrators WHERE password_hash IS NULL').all();
for (const admin of admins) {
if (admin.password) {
const hashedPassword = await hashPassword(admin.password);
db.prepare('UPDATE administrators SET password_hash = ? WHERE id = ?').run(hashedPassword, admin.id);
console.log(` ✅ Admin ${admin.id}`);
}
}
console.log('\n✅ Migration completed successfully!');
console.log('\n⚠️ IMPORTANT:');
console.log(' 1. Delete plain password columns: ALTER TABLE technicians DROP COLUMN password;');
console.log(' 2. Ensure all code uses password_hash column');
console.log(' 3. Update settings.json to not store admin password\n');
logger.info('Password hashing migration completed');
process.exit(0);
} catch (err) {
logger.error('Migration failed:', err);
console.error('❌ Error:', err.message);
process.exit(1);
}
}
migrate();
```
### Part F: Update Database Schema
```sql
-- Add password_hash column to tables (if not exists)
ALTER TABLE technicians ADD COLUMN password_hash TEXT;
ALTER TABLE administrators ADD COLUMN password_hash TEXT;
ALTER TABLE customers ADD COLUMN password_hash TEXT;
-- After migration, drop old password columns:
-- ALTER TABLE technicians DROP COLUMN password;
-- ALTER TABLE administrators DROP COLUMN password;
-- ALTER TABLE customers DROP COLUMN password;
```
---
## 🗝️ FIX #2: Environment Variables & Config Management
### Part A: Create .env.example
**File:** `.env.example`
```env
# Copy this file to .env and fill in actual values
# DO NOT commit .env to git!
# ======== APPLICATION ========
NODE_ENV=development
SERVER_PORT=3001
SERVER_HOST=localhost
# ======== SECURITY ========
SESSION_SECRET=change-me-to-random-string-minimum-32-chars
COOKIE_SECURE=false # Set to true in production with HTTPS
# ======== ADMIN SETUP ========
# Set these during first-time setup, will be migrated to database
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-to-strong-password-min-8-chars
# ======== GENIEACS ========
GENIEACS_URL=http://192.168.8.189:7557
GENIEACS_USERNAME=admin
GENIEACS_PASSWORD=change-me
# ======== PAYMENT GATEWAYS ========
MIDTRANS_ENABLED=true
MIDTRANS_SERVER_KEY=Mid_server_xxxxx
MIDTRANS_MODE=sandbox
XENDIT_ENABLED=true
XENDIT_API_KEY=xnd_development_xxxxx
TRIPAY_ENABLED=false
TRIPAY_API_KEY=xxxxx
TRIPAY_PRIVATE_KEY=xxxxx
TRIPAY_MERCHANT_CODE=xxxxx
TRIPAY_MODE=sandbox
DUITKU_ENABLED=true
DUITKU_API_KEY=xxxxx
DUITKU_MERCHANT_CODE=xxxxx
DUITKU_MODE=sandbox
# ======== DIGIFLAZZ ========
DIGIFLAZZ_USERNAME=
DIGIFLAZZ_API_KEY=
DIGIFLAZZ_WEBHOOK_SECRET=
# ======== WHATSAPP ========
WHATSAPP_ENABLED=true
WHATSAPP_AUTH_FOLDER=auth_info_baileys
# ======== TELEGRAM ========
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
# ======== RADIUS ========
RADIUS_ENABLED=0
RADIUS_SECRET=secret123
# ======== LOGGING ========
LOG_LEVEL=info
# ======== DATABASE ========
DATABASE_PATH=./database/billing.db
# ======== TIMEZONE ========
TIMEZONE=Asia/Jakarta
```
### Part B: Update config/settingsManager.js
```javascript
// Add at the top of settingsManager.js:
require('dotenv').config();
// Modify getSetting function to check .env first:
function getSetting(key, defaultValue = null) {
// 1. Check environment variable first (highest priority)
const envKey = key.toUpperCase().replace(/([A-Z])/g, '_$1');
if (process.env[envKey] !== undefined) {
return process.env[envKey];
}
// 2. Check settings.json
const settings = getSettings();
if (settings[key] !== undefined) {
return settings[key];
}
// 3. Return default value
return defaultValue;
}
```
### Part C: Update .gitignore
```bash
# Add to .gitignore:
.env
.env.local
.env.*.local
.env.development.local
.env.staging.local
.env.production.local
# Optional: exclude settings.json if it contains credentials
# settings.json
# Instead: settings.json.example (commit this template)
```
---
## 🛡️ FIX #3: Enhanced CSRF Protection
### Part A: Install CSURF
```bash
npm install csurf
```
### Part B: Add CSRF to app-customer.js
```javascript
const csrf = require('csurf');
// Create CSRF protection middleware
const csrfProtection = csrf({
cookie: false // Use session instead of cookie
});
// Apply to POST/PUT/DELETE routes
app.post('/admin/:path*', csrfProtection, adminRoutes);
app.post('/customer/:path*', csrfProtection, customerRoutes);
```
### Part C: Add CSRF Token to Forms
```ejs
<!-- In EJS templates, add hidden CSRF token field: -->
<form method="POST" action="/admin/billing/generate">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<!-- Other form fields -->
<button type="submit">Generate Invoice</button>
</form>
<!-- For AJAX requests, include in headers: -->
<script>
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(data)
});
</script>
```
---
## 🚨 FIX #4: Rate Limiting
### Part A: Install & Setup
```bash
npm install express-rate-limit
```
### Part B: Create Rate Limiter Middleware
**File:** `middleware/rateLimiter.js`
```javascript
const rateLimit = require('express-rate-limit');
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: 'Terlalu banyak percobaan login, coba lagi dalam beberapa menit',
standardHeaders: true,
legacyHeaders: false,
});
const apiRateLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Terlalu banyak request ke API, coba lagi nanti'
});
const webhookRateLimiter = rateLimit({
windowMs: 1 * 60 * 1000,
max: 1000, // Higher limit for webhooks
skip: (req) => {
// Skip rate limiting if webhook signature is valid
return req.session?.webhookVerified;
}
});
module.exports = {
loginRateLimiter,
apiRateLimiter,
webhookRateLimiter
};
```
### Part C: Apply Rate Limiters
```javascript
// In routes
const { loginRateLimiter, apiRateLimiter } = require('../middleware/rateLimiter');
// Apply to login endpoints
router.post('/login', loginRateLimiter, loginController);
router.post('/customer/login', loginRateLimiter, customerLoginController);
// Apply to API endpoints
router.get('/api/data', apiRateLimiter, apiController);
```
---
## 🔒 FIX #5: Session Management Improvements
### Part A: Setup Session Store
```bash
npm install better-sqlite3-session-store
```
### Part B: Update app-customer.js
```javascript
const SessionStore = require('better-sqlite3-session-store')(session);
const store = new SessionStore({
client: db,
expired: {
clear: true,
intervalMs: 15 * 60 * 1000 // Clear expired sessions every 15 minutes
}
});
app.use(session({
store: store, // Use database store instead of memory
secret: process.env.SESSION_SECRET || getSetting('session_secret'),
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.COOKIE_SECURE === 'true',
httpOnly: true,
sameSite: 'strict', // More restrictive
maxAge: 24 * 60 * 60 * 1000 // 24 hours
},
name: 'customer.sid'
}));
```
### Part C: Session Regeneration on Login
```javascript
// After successful login:
req.session.regenerate((err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
req.session.userId = user.id;
req.session.userRole = user.role;
req.session.loginTime = Date.now();
// Set inactivity timeout
req.session.inactivityTimeout = 30 * 60 * 1000; // 30 minutes
res.json({ success: true, redirect: '/dashboard' });
});
```
---
## ✅ VERIFICATION CHECKLIST
After implementing all fixes, verify:
- [ ] bcrypt password hashing implemented
- [ ] Old plain passwords migrated to hashed
- [ ] .env file created and populated
- [ ] .env added to .gitignore
- [ ] Settings.json reviewed for sensitive data
- [ ] CSRF protection implemented
- [ ] Rate limiting configured
- [ ] Session store using database
- [ ] Session regeneration on login working
- [ ] All tests passing
- [ ] Application running without errors
---
## 🧪 TESTING FIXES
### Test Password Hashing
```bash
node -e "
const { hashPassword, verifyPassword } = require('./utils/passwordHelper');
(async () => {
const hash = await hashPassword('test123456');
const valid = await verifyPassword('test123456', hash);
console.log('Hash:', hash);
console.log('Valid:', valid);
})();
"
```
### Test Environment Variables
```bash
node -e "
require('dotenv').config();
console.log('NODE_ENV:', process.env.NODE_ENV);
console.log('SESSION_SECRET:', process.env.SESSION_SECRET ? '✅ Set' : '❌ Not set');
console.log('GENIEACS_PASSWORD:', process.env.GENIEACS_PASSWORD ? '✅ Set' : '❌ Not set');
"
```
### Test Login Flow
```bash
# Start application
npm start
# Open browser and test login with new credentials
# Check console logs for any errors
# Verify session is created in database
```
---
## 📞 TROUBLESHOOTING
### Issue: "bcrypt is not defined"
```bash
Solution: npm install bcrypt
```
### Issue: ".env file not loading"
```bash
Solution: Ensure dotenv is imported at the top of app-customer.js:
require('dotenv').config();
```
### Issue: "CSRF token mismatch"
```bash
Solution: Ensure csrfToken is included in all forms and AJAX requests
```
### Issue: "Session not persisting"
```bash
Solution: Verify database schema has session table:
sqlite3 database/billing.db ".schema sessions"
```
---
## 📝 NEXT STEPS
1. **Immediate (Today):**
- Review this guide with team
- Create .env file
- Test bcrypt locally
2. **This Week:**
- Implement bcrypt in all auth modules
- Run password migration script
- Deploy to staging for testing
3. **Next Week:**
- Implement CSRF protection
- Add rate limiting
- Full security testing
4. **Following Week:**
- Monitor production (if live)
- Collect logs and monitoring data
- Review and optimize
---
**Questions?** Review AUDIT_SECURITY_2026.md for detailed security analysis.

View File

@@ -1,279 +0,0 @@
# Multi-Router Implementation - Verification Status
**Last Updated**: 2026-06-19
**Status**: ✅ COMPLETE - Ready for production testing
---
## Implementation Summary
### 1. Core Features Implemented
#### Smart Router Detection (`getEffectiveRouterId()`)
- **Location**: `services/customerService.js`
- **Mode: Aktif (Active Multi-Router)**
- Returns customer's explicit `router_id` (can be NULL)
- Customers MUST explicitly choose router during setup
- Best for environments with multiple independent routers
- **Mode: Nonaktif (Single Router Fallback)**
- Returns customer's `router_id` if set
- Falls back to `default_router_id` setting if customer has NULL
- Auto-detects first available router if no default set
- Best for migration from single-router to multi-router
- Allows old customers (NULL router_id) to work transparently
#### Router Connection Fallback (`getConnection()`)
- **Location**: `services/mikrotikService.js`
- Priority 1: Use specified `routerId` from database
- Priority 2: Auto-detect first active router from database
- Priority 3: Fallback to `settings.json` (backward compatibility)
- Includes TCP connectivity probing + cache (5s) for performance
#### Configuration
- **Location**: `settings.json`
- `multi_router_mode`: "active" | "disabled" (default: "disabled")
- `default_router_id`: Optional ID for fallback router
---
## Integration Points - All Verified
### ✅ Customer Suspension (Isolir)
**Admin Portal Route**: `POST /customers/:id/isolate`
- Calls `customerSvc.suspendCustomer(id)`
- Uses `getEffectiveRouterId()` internally
- Updates MikroTik profile → updates database status
- Sends WhatsApp notification to customer phone
**WhatsApp Bot Command**: `!isolir <customer_id>`
- Calls `customerSvc.suspendCustomer(id)`
- Same multi-router support as admin route
**Code Flow**:
```
Admin/Bot Request
→ suspendCustomer(id)
→ getCustomerById(id)
→ getEffectiveRouterId(customer.router_id)
→ mikrotikSvc.setPppoeProfile() [respects effectiveRouterId]
→ updateCustomer() [database update]
→ sendWA() [notification]
```
### ✅ Customer Activation
**Admin Portal Route**: `POST /customers/:id/unisolate`
- Calls `customerSvc.activateCustomer(id)`
- Uses `getEffectiveRouterId()` internally
- Updates MikroTik profile → updates database status
**Code Flow**:
```
Admin Request
→ activateCustomer(id)
→ getCustomerById(id)
→ getEffectiveRouterId(customer.router_id)
→ mikrotikSvc.setPppoeProfile() [respects effectiveRouterId]
→ updateCustomer() [database update]
```
### ✅ Payment Processing Integration
**Payment Service**: Routes at `/voucher-payment`, payment callbacks
- Customer payment → `activateCustomer()` → multi-router support
- Files: `services/paymentService.js`, `routes/voucherPaymentAPI.js`
### ✅ Export/Import Functionality
**Admin Portal Routes**: `GET /customers/export`, `POST /customers/import`
- Export includes `Router` column (maps to `router_name`)
- Import supports both old files (NULL router) and new (router specified)
- Backward compatible with existing export files
- File: `routes/adminPortal.js` (lines 1900-2000)
### ✅ Portal Login Support
**Customer Portal** (`routes/customerPortal.js`)
- PPPoE profile loading: Supports multi-router ✅
- Queries include `r.name as router_name` join
- Router filter applied by customer's effective router
**Tech Portal** (`routes/techPortal.js`)
- PPPoE profiles with router query param support ✅
- Route: `/tech/pppoe-profiles?routerId=1`
- Validates customer matches requested router
**ACS Portal** (`routes/acsPortal.js`)
- PPPoE profile loading for ONU management ✅
- Respects customer's effective router
### ✅ Connection Types - All Supported
| Connection Type | Suspend Implementation | Activate Implementation | Router Validation |
|---|---|---|---|
| **PPPoE** | `setPppoeProfile(username, 'isolir', routerId)` | `setPppoeProfile(username, profileName, routerId)` | Required for isolir ✅ |
| **Static IP** | `manageStaticIp({...isolate: true}, routerId)` | `manageStaticIp({...isolate: false}, routerId)` | Required ✅ |
| **Hotspot** | `setHotspotUserDisabled(user, true, routerId)` | `upsertHotspotUser({...disabled: false}, routerId)` | Required ✅ |
---
## Current Configuration
### Database Routers
```
ID | Name | is_active
----|----------------|----------
1 | Main Router | 1
2 | Main Router | 1
3 | Main Router | 1
```
### Application Settings
```
multi_router_mode: "disabled" (default) [from settings.json]
default_router_id: "" (empty/unset) [from settings.json]
```
---
## Testing Checklist
### Phase 1: Basic Multi-Router Operations
- [ ] **Test 1.1**: Suspend customer with explicit router_id (Router 1)
- Expected: Isolir works, customer can see they're suspended
- Check: Database status = "suspended", WhatsApp notification received
- [ ] **Test 1.2**: Suspend customer with NULL router_id in "disabled" mode
- Expected: Falls back to first router in database
- Check: Customer isolir works even though router_id = NULL
- [ ] **Test 1.3**: Activate customer from suspended state
- Expected: Customer can reconnect, service restored
- Check: Database status = "active", PPPoE profile changed back
### Phase 2: Different Connection Types
- [ ] **Test 2.1**: Suspend customer with Static IP connection
- Expected: IP rate-limited to isolir profile
- Check: MikroTik queue rules applied
- [ ] **Test 2.2**: Suspend customer with Hotspot connection
- Expected: Hotspot user disabled = true
- Check: Customer cannot login to hotspot
### Phase 3: WhatsApp Bot Integration
- [ ] **Test 3.1**: Run `!isolir <customer_id>` command via WhatsApp
- Expected: Customer isolir works, admin gets response
- Check: Admin receives ✅ confirmation in WhatsApp
- [ ] **Test 3.2**: Run on customer with NULL router_id
- Expected: Uses fallback router, works correctly
- Check: Customer gets isolir'd despite NULL router
### Phase 4: Payment & Auto-Isolir
- [ ] **Test 4.1**: Customer makes payment after isolir
- Expected: Auto-activates via payment webhook
- Check: Customer service restored immediately
- [ ] **Test 4.2**: Auto-isolir on missed payment (cron job)
- Expected: Customers automatically suspended on isolir_day
- Check: Log shows "Auto-isolir" entries
### Phase 5: Export/Import Cycle
- [ ] **Test 5.1**: Export customers, verify "Router" column present
- Expected: CSV has "Router" column with router names
- Check: Column exists in exported file
- [ ] **Test 5.2**: Modify export, re-import with different router
- Expected: Customer router_id updated in database
- Check: New router_id reflected in customer edit view
### Phase 6: Mode Switching (Aktif ↔ Nonaktif)
- [ ] **Test 6.1**: Switch from "Nonaktif" → "Aktif"
- Expected: Mode changes, no auto-assignments (existing customers keep current router)
- Check: Settings updated, app doesn't crash
- [ ] **Test 6.2**: Switch from "Aktif" → "Nonaktif"
- Expected: Mode changes, customers without router_id get assigned default
- Check: Auto-assignment completed, NULL router_id → router_id populated
---
## Troubleshooting Guide
### Symptom: Customer suspendsion not working
**Check 1**: Verify customer has router_id set
```bash
SELECT id, name, router_id, pppoe_username FROM customers WHERE id = ?
```
- If NULL: Check multi_router_mode setting - must be "disabled" for fallback to work
- If not NULL: Verify router exists and is active
**Check 2**: Verify MikroTik connectivity
```bash
node -e "const m = require('./services/mikrotikService'); m.checkConnection(1).then(ok => console.log('Router 1:', ok))"
```
**Check 3**: Check application logs
```bash
tail -f logs/combined.log | grep -i "isolir\|suspend"
tail -f logs/error.log
```
### Symptom: WhatsApp isolir command fails
**Check**: Verify WhatsApp is enabled and logged in
- Check `settings.json`: `whatsapp_enabled: true`
- Check logs for "whatsapp" entries
- Verify phone number is in `whatsapp_admin_numbers`
### Symptom: Mode switching doesn't auto-assign routers
**Check**: Review logs during mode switch
```bash
grep -i "auto" logs/combined.log
```
- Should see messages about finding default router
- Verify at least one router has `is_active = 1`
---
## Performance Notes
### MikroTik Connection Caching
- Connection probe cache: 5 seconds
- PPPoE profiles cache: 15 seconds
- PPPoE secrets cache: 5 seconds
- Active sessions cache: 2 seconds (real-time)
### Optimization Implemented
- SOAP fault reduction: ~40% fewer unnecessary queries
- Removed invalid WiFi refresh attempts on CIOT ONUs
- WANConnectionDevice loop reduced from 5 to 3
---
## Next Steps
1. **Run Phase 1-3 Tests** on staging environment with multiple routers
2. **Verify WhatsApp bot** works with multi-router customers
3. **Test payment** auto-activation with mixed router customers
4. **Monitor logs** for any connection errors during testing
5. **Deploy** to production after all tests pass
---
## Code References
- Smart router logic: `services/customerService.js` lines 12-38
- Suspend implementation: `services/customerService.js` lines 360-415
- Activate implementation: `services/customerService.js` lines 417-468
- MikroTik fallback: `services/mikrotikService.js` lines 169-240
- Admin routes: `routes/adminPortal.js` lines 2024-2032, 2034-2042
- WhatsApp bot: `services/whatsappBot.mjs` line 1187
---
## Rollback Plan
If issues occur in production:
1. Set `multi_router_mode: "disabled"` in `settings.json`
2. Set `default_router_id` to the ID of your primary router
3. Restart application with `pm2 restart all`
4. All NULL router_id customers will automatically use the default router
5. Existing operations continue working (backward compatible)

View File

@@ -1,207 +0,0 @@
# 📱 Panduan Lengkap & Dokumen Listing Google Play Store
**Aplikasi:** Alijaya Customer & Billing Management System
**Package Name:** `com.alijaya.customer`
**File Bundle:** `AlijayaCustomer.aab` (5.56 MB)
**Keystore:** `alijaya-release.jks`
Dokumen ini berisi seluruh teks deskripsi, kebijakan privasi, keamanan data (data safety), kredensial akun pengujian untuk tim Google, dan panduan langkah demi langkah yang siap di-*copy-paste* langsung ke **Google Play Console**.
---
## 1. 📋 Data Utama Listing Toko (Store Listing)
### A. Judul Aplikasi (App Title)
> **Batas:** Maksimal 30 Karakter
```text
Alijaya Digital
```
*(Alternatif jika ingin nama lain: `Alijaya Billing`, `Alijaya Net`, atau `Alijaya Customer`)*
---
### B. Deskripsi Singkat (Short Description)
> **Batas:** Maksimal 80 Karakter
```text
Aplikasi layanan internet pelanggan, tagihan, beli pulsa, dan absensi petugas.
```
---
### C. Deskripsi Lengkap (Full Description)
> **Batas:** Maksimal 4.000 Karakter
```text
Alijaya Digital adalah aplikasi resmi layanan internet dan manajemen billing terpadu untuk pelanggan, agen, teknisi, kolektor, dan kasir jaringan Alijaya Digital Network.
Aplikasi ini memberikan kemudahan akses informasi layanan internet, pembayaran tagihan, pembelian paket data/pulsa, hingga operasional lapangan secara cepat, transparan, dan terpercaya.
Fitur Unggulan untuk Pelanggan:
• Cek Tagihan & Status Langganan: Informasi tagihan bulanan real-time, jatuh tempo, dan rincian pemakaian paket internet.
• Pembayaran Otomatis & QRIS: Pembayaran tagihan mudah dan instan menggunakan QRIS dinamis serta berbagai metode pembayaran digital lainnya.
• Dompet & Top-Up Saldo: Isi saldo dompet akun untuk kemudahan transaksi berkala.
• Pembelian Pulsa & PPOB: Pembelian pulsa seluler, paket internet operator, dan token listrik langsung dari aplikasi.
• Cek Kecepatan & Tiket Gangguan: Uji kecepatan koneksi internet dan ajukan tiket keluhan gangguan langsung ke tim teknis dengan respon cepat.
• Manajemen Router Wi-Fi: Pantau dan atur jaringan Wi-Fi rumah secara praktis.
Fitur Khusus Petugas & Mitra (Teknisi, Kolektor, Kasir, & Agen):
• Absensi Kerja Berbasis Geo-Tagging: Sistem presensi kehadiran harian dengan validasi GPS akurat dan foto selfie ber-watermark otomatis (lokasi, tanggal, dan waktu).
• Manajemen Tugas SPK Lapangan: Penanganan tiket gangguan pelanggan dan instalasi pasang baru secara terorganisir.
• Manajemen Tagihan & Rute Kolektor: Navigasi rute penagihan berbasis peta Google Maps dan pencetakan struk pembayaran thermal via Bluetooth.
• Loket Kasir & Mitra Agen: Pembelian voucher hotspot dan transaksi PPOB secara aman.
Unduh aplikasi Alijaya Digital sekarang dan nikmati kemudahan mengelola seluruh kebutuhan layanan internet Anda dalam satu genggaman!
```
---
## 2. 🏷️ Kategori & Kontak Pengembang (Store Settings)
- **Jenis Aplikasi:** Aplikasi (Application)
- **Kategori:** Alat (Tools) atau Produktivitas (Productivity) / Bisnis (Business)
- **Email Dukungan (Support Email):** `alijayanet@gmail.com` *(atau email resmi Anda)*
- **Nomor Telepon Dukungan:** `+6287820851413`
- **Situs Web (Website):** `http://alijaya.net` *(atau domain server Anda)*
---
## 3. 🛡️ Kebijakan Privasi (Privacy Policy)
> [!IMPORTANT]
> Google Play mewajibkan URL Kebijakan Privasi aktif karena aplikasi menggunakan izin Kamera dan Lokasi. Anda dapat menaruh URL berikut:
> `http://<domain-server-anda>/privacy-policy` atau gunakan draf teks resmi di bawah ini:
```markdown
# KEBIJAKAN PRIVASI (PRIVACY POLICY) - ALIJAYA DIGITAL
Terakhir diperbarui: 30 Agustus 2026
Alijaya Digital Network ("kami") berkomitmen untuk melindungi dan menghormati privasi pengguna aplikasi mobile Alijaya Digital ("Aplikasi"). Kebijakan Privasi ini menjelaskan bagaimana kami mengumpulkan, menggunakan, dan melindungi data pribadi Anda.
1. INFORMASI YANG KAMI KUMPULKAN
Kami hanya mengumpulkan informasi yang relevan dan dibutuhkan untuk menjalankan fitur aplikasi:
a. Informasi Akun: Nama, nomor telepon/WhatsApp, alamat rumah/pemasangan internet, ID pelanggan, dan username PPPoE/Hotspot.
b. Informasi Lokasi (GPS): Data koordinat lokasi (Latitude & Longitude) digunakan saat:
- Pelanggan mengajukan instalasi pasang baru atau pemetaan lokasi ODP/tiang terdekat.
- Petugas (Teknisi, Kolektor, Kasir) melakukan absensi kehadiran kerja berbasis validasi radius kantor (Geofencing).
- Petugas kolektor membuka rute penagihan pelanggan melalui Google Maps.
c. Kamera & Foto: Izin kamera digunakan untuk:
- Pengambilan foto selfie wajah petugas saat absensi kehadiran dengan watermark Geo-Tagging posisi.
- Pengunggahan foto bukti kendala teknis saat pelaporan tiket gangguan.
- Pemindaian QR Code (Barcode voucher / QRIS).
d. Akses Bluetooth: Digunakan oleh petugas kolektor dan kasir untuk menghubungkan printer thermal Bluetooth dalam pencetakan struk pembayaran.
2. PENGGUNAAN INFORMASI
Informasi yang dikumpulkan digunakan semata-mata untuk:
- Memverifikasi identitas pengguna dan mengelola akun langganan internet.
- Memproses pembayaran tagihan dan riwayat transaksi.
- Memvalidasi presensi kerja dan koordinat lokasi tugas lapangan karyawan resmi.
- Menghubungkan komunikasi layanan pelanggan melalui WhatsApp.
3. KEAMANAN DATA
Kami menerapkan standar keamanan teknis dan enkripsi data (HTTPS/SSL) dalam transmisi data antara aplikasi dan server. Kami tidak pernah menjual, menyewakan, atau membagikan data pribadi Anda kepada pihak ketiga mana pun untuk kepentingan pemasaran.
4. HAK PENGGUNA & PENGHAPUSAN DATA
Pengguna memiliki hak untuk meminta rincian data yang tersimpan atau meminta penghapusan akun beserta data terkait dengan menghubungi kami melalui email dukungan: alijayanet@gmail.com atau WhatsApp resmi customer support kami.
5. HUBUNGI KAMI
Jika Anda memiliki pertanyaan seputar Kebijakan Privasi ini, silakan hubungi:
Alijaya Digital Network
Email: alijayanet@gmail.com
Telepon/WA: +62 878-2085-1413
Alamat: Indramayu, Jawa Barat, Indonesia.
```
---
## 4. 🔒 Deklarasi Keamanan Data (Data Safety Questionnaire)
Saat mengisi menu **Keamanan Data (Data Safety)** di Google Play Console, pilih jawaban berikut:
| Pertanyaan Google Play | Jawaban yang Dipilih |
| :--- | :--- |
| **Apakah aplikasi mengumpulkan data pengguna?** | **Ya (Yes)** |
| **Apakah semua data dienkripsi saat transit?** | **Ya (Yes, HTTPS/TLS)** |
| **Apakah ada cara bagi pengguna untuk meminta penghapusan data?** | **Ya (Yes)** |
### Rincian Kategori Data yang Dikumpulkan:
1. **Lokasi (Location)**:
- *Lokasi Akurat (Precise Location)* & *Lokasi Perkiraan (Approximate Location)*.
- **Tujuan:** Fungsionalitas aplikasi (App functionality) & Pengelolaan akun (Account management) untuk absensi karyawan dan rute pelanggan.
- **Apakah dibagikan ke pihak ketiga?** Tidak (No).
2. **Foto dan Video (Photos and Videos)**:
- *Foto (Photos)*.
- **Tujuan:** Fungsionalitas aplikasi (Absensi selfie karyawan dan lampiran tiket komplain).
- **Apakah dibagikan ke pihak ketiga?** Tidak (No).
3. **Info Pribadi (Personal Info)**:
- *Nama (Name)*, *Nomor Telepon (Phone number)*, *Alamat (Address)*.
- **Tujuan:** Fungsionalitas aplikasi & Komunikasi pelanggan.
- **Apakah dibagikan ke pihak ketiga?** Tidak (No).
---
## 5. 🎯 Target Audiens & Rating Konten
1. **Target Usia (Target Age):**
- Pilih: **18 tahun ke atas (18+)** *(karena mencakup pembayaran tagihan langganan internet rumah)*.
2. **Aplikasi Berita (News App):** Pilih **Tidak (No)**.
3. **Iklan (Ads):** Pilih **Tidak, aplikasi saya tidak mengandung iklan (No, my app does not contain ads)**.
4. **Pelacak Kontak COVID-19:** Pilih **Tidak (No)**.
5. **Aplikasi Keuangan:** Pilih **Tidak (Bukan institusi perbankan/pinjol)**, hanya sistem pembayaran tagihan ISP sendiri.
---
## 6. 👤 Akun Akses Pengujian untuk Reviewer Google (App Access)
Google mewajibkan penyediaan akun demo agar penguji dari pihak Google dapat masuk dan menguji fitur di dalam aplikasi tanpa terhalang login:
- **Opsi yang Dipilih:** *Sebagian atau semua fungsi dibatasi (All or some functionality is restricted)*
- **Nama Kredensial:** `Akun Penguji Google Play`
- **Username / ID Pelanggan:** `demo` *(atau buat akun khusus pengujian di admin)*
- **Password:** `demo123`
- **Instruksi Pengujian:**
```text
Aplikasi ini menggunakan koneksi ke server Alijaya Billing System.
Gunakan username: demo dan password: demo123 untuk menguji portal pelanggan.
Koneksi internet aktif diperlukan.
```
---
## 7. 🔑 Kredensial Keystore & Spesifikasi Teknis
Simpan data teknis ini di tempat yang aman:
| Parameter | Nilai |
| :--- | :--- |
| **Package Name** | `com.alijaya.customer` |
| **Target SDK** | `Android 15 (SDK 35)` |
| **Min SDK** | `Android 7.0 (SDK 24)` |
| **Version Code** | `2` |
| **Version Name** | `1.2.0` |
| **File Keystore** | `alijaya-release.jks` |
| **Keystore Alias** | `alijaya` |
| **Keystore Password** | `AlijayaRelease2026!` |
| **Key Password** | `AlijayaRelease2026!` |
| **Validity** | 10.000 Hari (~27 Tahun) |
---
## 8. 🎨 Kebutuhan Aset Grafis (Graphic Assets)
Sebelum menekan tombol rilis, siapkan file gambar berikut:
1. **Ikon Aplikasi (App Icon):**
- Ukuran: `512 x 512 piksel`
- Format: PNG 32-bit dengan transparansi
- Ukuran file maksimal: 1 MB
2. **Grafis Fitur (Feature Graphic):**
- Ukuran: `1024 x 500 piksel`
- Format: JPEG atau PNG 24-bit (tanpa transparansi)
- Ukuran file maksimal: 15 MB
3. **Tangkapan Layar Ponsel (Phone Screenshots):**
- Minimal 2 screenshot, maksimal 8 screenshot.
- Rasio 16:9 atau 9:16 (contoh resolusi ideal: `1080 x 1920 piksel` atau `1080 x 2400 piksel`).
- Disarankan mengambil screenshot dari:
1. Layar Beranda Pelanggan (Cek Tagihan & Beli Pulsa)
2. Layar Pembayaran QRIS / Invoice
3. Layar Speedtest & Tiket Gangguan
4. Layar Dashboard Tugas Teknisi Lapangan
5. Layar Absensi Selfie dengan Watermark Geo-Tagging

View File

@@ -1,346 +0,0 @@
================================================================================
AUDIT LENGKAP APLIKASI BILLING B481
STATUS: ✅ SELESAI
================================================================================
TANGGAL AUDIT: Juni 2026
APLIKASI STATUS: 🟢 PRODUCTION READY
================================================================================
DOKUMENTASI YANG TELAH DIBUAT (6 FILE - 104 KB TOTAL)
================================================================================
1. AUDIT_LENGKAP.md (45.57 KB)
Audit komprehensif dengan 18 section mencakup:
- Informasi umum aplikasi
- Arsitektur sistem lengkap
- Database schema overview
- Semua endpoints & routes (100+ API)
- 15+ fitur utama yang didokumentasikan
- Keamanan & otentikasi
- Production checklist
📖 BACA INI UNTUK: Referensi lengkap semua aspek aplikasi
---
2. QUICK_REFERENCE.md (6.67 KB)
Panduan cepat untuk task-task umum:
- Quick start commands
- Common configuration tasks
- Security settings (MUST DO)
- Important endpoints
- Database commands
- Troubleshooting (7 issues)
- Pre-launch checklist
📖 BACA INI UNTUK: Daily operations & quick lookups
---
3. DATABASE_SCHEMA.md (15.75 KB)
Dokumentasi lengkap database:
- Schema overview
- 30+ tabel dengan struktur lengkap
- Foreign key relationships
- Query examples
- Optimization tips
- Backup & recovery procedures
📖 BACA INI UNTUK: Database operations & queries
---
4. BACKUP_CHECKLIST.md (10.58 KB)
Prosedur backup & disaster recovery:
- Pre-audit backup procedures
- Step-by-step backup guide
- Restore procedures (5 scenarios)
- Automated backup setup
- Disaster recovery plan
- Best practices
📖 BACA INI UNTUK: Backup planning & restoration
---
5. AUDIT_SUMMARY.md (12.31 KB)
Executive summary & roadmap:
- Key findings & recommendations
- Security checklist (CRITICAL)
- Operational runbook
- Maintenance procedures
- Success criteria
- Next steps
📖 BACA INI UNTUK: Overview & planning
---
6. DOKUMENTASI_INDEX.md (13.69 KB)
Navigation guide untuk semua dokumentasi:
- Quick links by topic
- How to search documentation
- Cross-references
- FAQ section
- Documentation roadmap
📖 BACA INI UNTUK: Mencari informasi spesifik
================================================================================
MULAI DARI MANA?
================================================================================
UNTUK PEMULA (5-15 MENIT):
1. Baca file ini (README_AUDIT.txt)
2. Baca AUDIT_SUMMARY.md (5 menit) - ringkasan lengkap
3. Baca QUICK_REFERENCE.md (10 menit) - perintah dasar
UNTUK ADMIN/OPERATOR (30-60 MENIT):
1. QUICK_REFERENCE.md - common tasks
2. AUDIT_LENGKAP.md section 4 - endpoints & routes
3. BACKUP_CHECKLIST.md - backup procedures
4. QUICK_REFERENCE.md troubleshooting - tips
UNTUK DEVELOPER/MAINTENANCE (1-2 JAM):
1. AUDIT_LENGKAP.md - full architecture
2. DATABASE_SCHEMA.md - database structure
3. BACKUP_CHECKLIST.md - operational procedures
4. DOKUMENTASI_INDEX.md - untuk searching
================================================================================
FITUR UTAMA YANG DIDOKUMENTASIKAN
================================================================================
✅ Portal Pelanggan (Self-service)
✅ Portal Admin (Manajemen lengkap)
✅ Portal Teknisi (Field support)
✅ Portal Agen (Sales & pembayaran)
✅ Portal Kolektor (Approval pembayaran)
✅ Portal Finance (Laporan keuangan)
✅ Billing & Invoicing (Tagihan otomatis)
✅ MikroTik Integration (PPPoE, Hotspot, Voucher)
✅ GenieACS & TR-069 (CPE/ONU management)
✅ OLT PON Management (SNMP-based)
✅ GIS Mapping (Peta pelanggan & ODP)
✅ WhatsApp Automation (Baileys)
✅ Payment Gateway (Midtrans, Tripay, Xendit, Duitku)
✅ Inventory Management (Gudang)
✅ Ticketing System (Support)
✅ Attendance Geofencing (Kehadiran GPS)
================================================================================
STATISTIK DOKUMENTASI
================================================================================
Total files: 6
Total size: 104.56 KB
Sections documented: 18+
Database tables: 30+
API endpoints: 100+
Features covered: 15+
Backup procedures: 5 scenarios
Troubleshooting issues: 15+
================================================================================
CHECKLIST - APA YANG HARUS DILAKUKAN SEGERA
================================================================================
CRITICAL (BEFORE PRODUCTION):
□ Baca AUDIT_SUMMARY.md security checklist
□ Change session_secret di settings.json
□ Change admin password
□ Ganti admin_api_key
□ Enable HTTPS (via reverse proxy)
□ Test backup & restore procedure
IMPORTANT (THIS WEEK):
□ Baca semua dokumentasi yang relevan
□ Create full backup (database + files + code)
□ Store backup di lokasi aman
□ Setup automated daily backup
□ Test restore backup
MEDIUM (THIS MONTH):
□ Setup monitoring & alerting
□ Setup log rotation
□ Document customizations
□ Create operational runbook
================================================================================
FILE LOCATIONS
================================================================================
Semua file dokumentasi tersimpan di root aplikasi:
d:\billing-B481\
├── AUDIT_LENGKAP.md (45.57 KB) ← Main reference
├── QUICK_REFERENCE.md (6.67 KB) ← Daily use
├── DATABASE_SCHEMA.md (15.75 KB) ← DB operations
├── BACKUP_CHECKLIST.md (10.58 KB) ← Backup procedures
├── AUDIT_SUMMARY.md (12.31 KB) ← Overview & planning
├── DOKUMENTASI_INDEX.md (13.69 KB) ← Navigation
├── README_AUDIT.txt (This file)
└── [original files...]
================================================================================
PENTING: BACKUP DOKUMENTASI INI JUGA!
================================================================================
Jangan lupa backup semua file dokumentasi ke lokasi aman:
OPTION 1 - Manual Backup:
cp AUDIT_*.md QUICK_REFERENCE.md DATABASE_SCHEMA.md BACKUP_CHECKLIST.md /backup/
OPTION 2 - Tar Backup:
tar -czf audit-docs-backup-$(date +%Y-%m-%d).tar.gz \
AUDIT_*.md QUICK_REFERENCE.md DATABASE_SCHEMA.md BACKUP_CHECKLIST.md
OPTION 3 - Via Script:
Add to your backup script & cron job
================================================================================
STRUKTUR DOKUMENTASI
================================================================================
┌─ QUICK START
│ ├─ AUDIT_SUMMARY.md (5 min overview)
│ └─ QUICK_REFERENCE.md (10 min guide)
│
├─ DETAILED REFERENCE
│ ├─ AUDIT_LENGKAP.md (Complete audit - 18 sections)
│ ├─ DATABASE_SCHEMA.md (Database reference - 30+ tables)
│ └─ DOKUMENTASI_INDEX.md (Navigation guide)
│
├─ OPERATIONAL
│ └─ BACKUP_CHECKLIST.md (Backup & recovery)
│
└─ NAVIGATION
└─ README_AUDIT.txt (This guide)
================================================================================
QUICK LINKS - CARI JAWABAN CEPAT
================================================================================
Q: Apa saja portal aplikasi?
A: AUDIT_LENGKAP.md section 1.3
Q: Bagaimana setup MikroTik?
A: QUICK_REFERENCE.md atau AUDIT_LENGKAP.md section 5.2
Q: Bagaimana backup database?
A: BACKUP_CHECKLIST.md section "Backup Database"
Q: Apa saja table database?
A: DATABASE_SCHEMA.md
Q: Bagaimana troubleshooting error X?
A: QUICK_REFERENCE.md "Troubleshooting" section
Q: Aplikasi ready untuk production?
A: AUDIT_SUMMARY.md "Audit Completion Status"
Q: Gimana setup automated backup?
A: BACKUP_CHECKLIST.md "Automated Backup (Cron Job)"
Q: Apa saja security settings yang harus diubah?
A: QUICK_REFERENCE.md "Security Settings (MUST DO)"
================================================================================
VERIFIKASI SEMUA FILE SUDAH ADA
================================================================================
✅ AUDIT_LENGKAP.md (45.57 KB)
✅ QUICK_REFERENCE.md (6.67 KB)
✅ DATABASE_SCHEMA.md (15.75 KB)
✅ BACKUP_CHECKLIST.md (10.58 KB)
✅ AUDIT_SUMMARY.md (12.31 KB)
✅ DOKUMENTASI_INDEX.md (13.69 KB)
✅ README_AUDIT.txt (This file)
TOTAL: 7 files, 104.56 KB dokumentasi lengkap
================================================================================
NEXT STEPS (URUTAN PENTING)
================================================================================
1. READ (This week)
□ AUDIT_SUMMARY.md (5 min) - Wajib dibaca semua orang!
□ QUICK_REFERENCE.md (10 min) - Reference untuk daily use
□ AUDIT_LENGKAP.md (30 min) - Jika butuh detail
□ DATABASE_SCHEMA.md (20 min) - Jika ada query khusus
□ BACKUP_CHECKLIST.md (10 min) - Sebelum backup
2. BACKUP (Immediately)
□ Create full backup (database + files + code)
□ Store backup in safe location (USB/NAS/Cloud)
□ Test restore procedure
□ Document backup location
3. SECURE (This week)
□ Follow security checklist di AUDIT_SUMMARY.md
□ Change passwords & secrets
□ Setup HTTPS
□ Setup firewall rules
4. MAINTAIN (Ongoing)
□ Setup automated daily backup
□ Setup monitoring & alerting
□ Review logs regularly
□ Test restore monthly
□ Update documentation when changes occur
================================================================================
SUPPORT & RESOURCES
================================================================================
Original README:
README.md (Original aplikasi documentation)
GitHub:
https://github.com/alijayanet/billing-rtrw
Contact:
Ali Jaya Net
Phone: 081947215703
Email: alijayanet@gmail.com
================================================================================
DOKUMEN STATUS
================================================================================
Audit Date: Juni 2026
Audit Version: 1.0 (Final)
Status: ✅ COMPLETE & APPROVED FOR PRODUCTION
Documentation: 6 files, 104 KB total
Last Updated: 2026-06-19
================================================================================
CATATAN PENTING
================================================================================
1. Dokumentasi ini adalah hasil audit teliti & lengkap
2. Semua file tersimpan di root aplikasi billing-B481
3. Documentasi sudah production-ready & siap digunakan
4. Update dokumentasi jika ada perubahan pada aplikasi
5. Backup dokumentasi ini juga (jangan lupa!)
6. Share dokumentasi dengan tim operasional
7. Review dokumentasi secara berkala
================================================================================
SELAMAT MENGGUNAKAN! 🎉
================================================================================
Jika ada pertanyaan atau butuh informasi lebih lanjut:
1. Cek dokumentasi terlebih dahulu (gunakan DOKUMENTASI_INDEX.md untuk search)
2. Baca FAQ section di file-file yang relevan
3. Hubungi tim technical support
Terima kasih telah menggunakan dokumentasi audit lengkap ini!
================================================================================
Generated: 2026-06-19
Status: ✅ AUDIT COMPLETE & DOCUMENTED

View File

@@ -24,10 +24,10 @@ Rilis pertama OPEN-ISP: sistem **billing & manajemen ISP (fiber optic)** open-so
## Kredit
- Base kode & repo: [alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw) oleh **Alijaya Net** — terima kasih.
- Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder.
- Dikembangkan oleh [oxpoe](https://github.com/oxpoe) — **code by DeepSeek V4.1 Flash — max, on opencode**.
- Dikembangkan oleh [oxpoe](https://gitea.oxpoe.qzz.io/oxpoe) — **code by DeepSeek V4.1 Flash — max, on opencode**.
## Instalasi singkat
```bash
git clone https://github.com/oxpoe/open-isp.git && cd open-isp
git clone https://gitea.oxpoe.qzz.io/oxpoe/open-isp.git && cd open-isp
docker compose up -d # http://localhost:3001 (admin / demo-isp123)
```

View File

@@ -0,0 +1,66 @@
# OPEN-ISP v0.5.3 — "forge-lime"
Rilis ini menggabungkan pembaruan fitur dari repo upstream (`alijayanet/billing-rtrw`) yang relevan untuk fork ini, plus perbaikan bug login/sesi.
## Yang baru
### 1. Pembatasan wilayah kerja
- Teknisi hanya melihat/mengambil tiket, pelanggan, perangkat, dan peta di area tugasnya.
- Kolektor hanya melihat & menagih pelanggan di area/tugasnya, termasuk lewat APK (dashboard & pay-bill).
- Mendukung multi-area (`area` dipisah koma), kosong/`semua` = tanpa batas.
- Pelanggan baru yang didaftarkan teknisi otomatis masuk area teknisi tersebut.
### 2. Inventaris gudang
- Stok keluar dengan FIFO atau serial number spesifik.
- Retur barang bekas/copotan.
- Stock opname (sesuaikan stok sistem dengan fisik, selisih dicatat).
- Detail unit per barang, daftar serial tersedia, statistik stok, harga & supplier.
- Semua transaksi tercatat dengan sisa stok di log.
### 3. Edit slip payroll
- Rincian gaji bisa diubah selama berstatus draft; gross, potongan, dan net dihitung ulang otomatis.
### 4. AI & Self-Healing
- Provider AI lewat satu jalur OpenAI-compatible: **DeepSeek (default)**, OpenAI, Groq, OpenRouter, atau custom (Ollama/LM Studio).
- Bot Telegram menjawab pertanyaan admin (termasuk tool: cek pelanggan, perangkat, dll).
- Self-healing memeriksa & memperbaiki:
- pelanggan terisolir padahal tagihan lunas,
- secret PPPoE hilang / tertukar profil isolir,
- pelanggan terisolir tapi masih aktif di MikroTik,
- sesi RADIUS menggantung,
- IP modem ACS kosong.
- **Toggle on/off di Pengaturan** (default nonaktif) + tombol Diagnosa / Perbaiki Sekarang.
### 5. Security Watchdog
- Deteksi brute-force login admin/kasir/teknisi/kolektor dan login MikroTik.
- Blokir IP sementara 15 menit setelah 5 percobaan gagal.
- Alert suara (TTS) via soundbox + notifikasi admin.
### 6. Batas sesi login
- Admin/kasir/teknisi/kolektor: maksimal **2** sesi per akun.
- Pelanggan: maksimal **3** sesi per akun.
- Sesi tertua diputus otomatis saat melebihi batas; session ID dirotasi saat login (anti session fixation).
- Sesi tetap tersimpan saat service restart (cookie 30 hari, rolling).
## Perbaikan
- Tab **Status** di login pelanggan kini mengikuti setting `uptime_url`, bukan URL lama.
- Kolom API key AI disamarkan di halaman Pengaturan.
## Dokumentasi
- 10 screenshot (desktop + mobile) di `docs/screenshots/`.
## Cara upgrade
```bash
git pull
npm install
# restart service (pm2 / systemd / docker compose)
```
Migrasi database berjalan otomatis saat aplikasi start (kolom inventaris & tabel sesi login).
Pengaturan AI (opsional): **Pengaturan → AI & Self-Healing** — pilih provider, isi API key/model, lalu nyalakan auto-repair bila sudah yakin.
---
**Full changelog:** [CHANGELOG.md](../CHANGELOG.md)

View File

@@ -1,202 +0,0 @@
# Rencana Desain & Modernisasi UI/UX — Billing RT/RW Net
*(Mengadopsi Referensi uiverse.io, ColorlibHQ/gentelella, dan Google Fonts)*
> **Status Server Biznet**: ✅ **Berhasil Dimatikan & Dinonaktifkan** (`systemctl stop/disable`, `pm2 stop/delete`). Tidak ada lagi tabrakan bot Telegram ataupun service jaringan.
> **Server Produksi Aktif**: VPS `deneva` (`116.212.74.115` — port `3001`).
---
## 1. Konsep Desain & Pendekatan Dua Arah (Dual-Persona)
Aplikasi ini melayani dua profil pengguna yang berbeda secara mendasar:
1. **Pelanggan (Customer Portal & Landing/Login)**: Menggunakan smartphone, membutuhkan antarmuka yang modern, bersih, intuitif, cepat, dan kaya animasi mikro.
* **Referensi Utama**: **`uiverse.io`** (Glassmorphism, animasi tombol neon/glow, switch toggle interaktif, floating dock, card 3D halus).
2. **Admin & Teknisi (Admin & NOC Portal)**: Membutuhkan visualisasi data densitas tinggi (*high-density information*), pemantauan lalu lintas real-time, status ONU dalam jumlah ratusan, serta tabel data yang efisien.
* **Referensi Utama**: **`ColorlibHQ/gentelella`** (Gentelella Admin — Tile KPI counters, multi-tab `x_panel` containers, collapsible sidebar, traffic charts, dense status badges).
```mermaid
graph TD
subgraph UI_Reference_System["Sistem Referensi Desain"]
UIVERSE["uiverse.io (Modern CSS Components)"]
GENTELELLA["ColorlibHQ/gentelella (Admin Dashboard)"]
GFONTS["Google Fonts (Universal Typography)"]
end
subgraph Customer_Side["Portal Pelanggan (PWA & Login)"]
CustLogin["views/login.ejs (Hero & Glass Card)"]
CustDash["views/dashboard.ejs (5-Tab App Shell)"]
CustIsolated["views/isolated.ejs (Walled Garden)"]
end
subgraph Admin_Side["Portal Admin, NOC & Teknisi"]
AdminDash["views/admin/dashboard.ejs (Tile Stats & Charts)"]
AdminTables["views/admin/* (Customers, OLT, Billing)"]
AdminSidebar["views/admin/partials/sidebar.ejs"]
AdminMap["views/admin/map.ejs (Network Topology)"]
end
UIVERSE -->|Glass Cards, Neon Toggles, Pill Buttons| Customer_Side
GENTELELLA -->|Tile Counters, X-Panels, Dense Tables| Admin_Side
GFONTS -->|Plus Jakarta Sans, Inter, JetBrains Mono| Customer_Side
GFONTS --> Admin_Side
```
---
## 2. Tipografi Universal (Google Fonts)
Untuk memastikan kompatibilitas rendering 100% di semua browser (Chrome, Safari iOS, Firefox, Edge, Android WebView) tanpa ketergantungan pada font sistem lokal:
| Peruntukan | Font Family | Bobot (Weights) | Karakteristik & Kegunaan |
|---|---|---|---|
| **Customer Portal & Landing** | **Plus Jakarta Sans** | `300, 400, 500, 600, 700, 800` | Modern geometric sans, sangat nyaman dibaca di layar AMOLED HP, angka tagihan tegas dan modern. |
| **Admin & Operasional** | **Inter** | `300, 400, 500, 600, 700, 800` | Standar industri dashboard, metrik angka rapat (*tabular numbers*), hierarki teks rapi. |
| **Data Teknis & Jaringan** | **JetBrains Mono** | `400, 500, 700` | Alamat IP, MAC Address, OID SNMP, Serial Number ONU, nilai redaman optik (dBm). |
### Standar Preconnect & Loading:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
```
---
## 3. Peta Komponen & Transformasi Desain
### 3.1 Portal Admin (Adopsi `ColorlibHQ/gentelella`)
#### A. Gentelella Top Metric Stats ("Tile Count")
Pada dashboard admin (`views/admin/dashboard.ejs`), susunan metrik diganti dengan pola **Gentelella Tile Count**:
* **Format**: Baris kartu metrik ringkas horizontal dengan ikon latar samar, angka besar tebal, label deskriptif, dan indikator persentase tren (hijau naik / merah turun).
* **Komponen yang Diterapkan**:
1. `Total Pelanggan`: Angka aktif vs total, delta pertumbuhan bulanan.
2. `PPPoE Online`: Real-time active sessions dengan status dot hijau berkedip.
3. `Total ONU & Signal`: Jumlah online/offline + ONU dengan redaman kritis (< -27 dBm).
4. `Tagihan & Kas Masuk`: Pendapatan bulan berjalan vs tagihan tertunda (*unpaid*).
```
┌─────────────────┬─────────────────┬─────────────────┬─────────────────┐
│ TOTAL PELANGGAN │ PPPoE ONLINE │ STATUS FTTH │ PENDAPATAN BULAN │
│ 1,248 │ 1,180 │ 102 / 105 │ Rp 42.500.000 │
│ ▲ +12 bln ini │ ● 94.5% online │ ⚠️ 3 Redaman Low│ ▲ 88% Tertagih │
└─────────────────┴─────────────────┴─────────────────┴─────────────────┘
```
#### B. Kontainer Widget Bergaya `x_panel`
* Seluruh card data admin menggunakan struktur `x_panel`:
* `x_title`: Judul section dengan border bawah tipis, sub-teks muted, dan *toolbox* kanan (tombol refresh data via AJAX, filter tanggal, expand/collapse).
* `x_content`: Area konten responsif dengan padding lega, kompatibel dengan mode gelap dan terang.
#### C. Tabel Data Responsif (*Dense Table*)
* Border halus (`border-slate-800`), font `Inter` 13px, baris zebra-stripe halus saat hover.
* Badge status terstandarisasi:
* Online: Badge hijau lembut (`bg-emerald-500/10 text-emerald-400 border border-emerald-500/20`).
* Offline / Suspend: Badge merah lembut (`bg-rose-500/10 text-rose-400 border border-rose-500/20`).
* Isolir: Badge amber (`bg-amber-500/10 text-amber-400 border border-amber-500/20`).
---
### 3.2 Portal Pelanggan & Login (Adopsi `uiverse.io`)
#### A. Card Glassmorphism dengan Border Glow
* Card profil router, tagihan, dan paket internet menggunakan teknik glassmorphism tingkat tinggi dari `uiverse.io`:
* Background: `rgba(30, 41, 59, 0.75)` dengan `backdrop-filter: blur(16px)`.
* Border: Gradasi tipis `linear-gradient(135deg, rgba(45, 212, 191, 0.3), rgba(99, 102, 241, 0.1), transparent)`.
* Shadow: `0 10px 30px -10px rgba(0, 0, 0, 0.5)`.
#### B. Switch Toggle & Input Kustom (Form Ganti WiFi & Settings)
* Mengganti checkbox standar dengan **animated toggle switch** gaya `uiverse.io`:
* Toggle 2.4 GHz vs 5 GHz dengan slider pill bercahaya.
* Input password dengan animated floating border dan icon eye reveal responsif.
* Tombol submit: Efek hover *lift-up* (`translateY(-2px)`) dengan box-shadow glow warna Teal `#14b8a6`.
#### C. Floating Action Dock (PWA Mobile Navigation)
* Penyempurnaan 5-tab dock navigasi bawah:
* Efek frosted glass yang lebih pekat (`backdrop-blur-xl`).
* Active pill bergradasi halus dengan transisi kurva cubic-bezier (`cubic-bezier(0.34, 1.56, 0.64, 1)`).
* Touch feedback haptic visual saat tab ditekan di layar HP.
---
## 4. Design Tokens & Palette Warna
```css
:root {
/* Brand Core (Skyfiber Identity) */
--brand-teal-50: #f0fdfa;
--brand-teal-400: #2dd4bf;
--brand-teal-500: #14b8a6;
--brand-teal-600: #0d9488;
--brand-indigo: #6366f1;
--brand-cyan: #06b6d4;
/* Dark Theme Palette (Default) */
--bg-dark-base: #0b0f19;
--bg-dark-card: #1e293b;
--bg-dark-panel: #161b26;
--border-dark: rgba(148, 163, 184, 0.15);
--text-dark-high: #f8fafc;
--text-dark-med: #94a3b8;
--text-dark-low: #64748b;
/* Light Theme Palette */
--bg-light-base: #f8fafc;
--bg-light-card: #ffffff;
--bg-light-panel: #f1f5f9;
--border-light: rgba(15, 23, 42, 0.08);
--text-light-high:#0f172a;
--text-light-med: #475569;
--text-light-low: #94a3b8;
/* Status Colors */
--status-online: #10b981;
--status-offline: #f43f5e;
--status-warning: #f59e0b;
--status-info: #38bdf8;
}
```
---
## 5. Rencana Tahapan Eksekusi (Implementation Milestones)
```mermaid
gantt
title Roadmap Eksekusi Desain & Modernisasi
dateFormat X
axisFormat Fase %s
section Persiapan
Matikan Service Biznet & Verifikasi Port :done, 0, 1
Integrasi Google Fonts Universal :done, 1, 2
section Portal Pelanggan (uiverse.io)
Modernisasi Komponen WiFi & Card Glass :active, 2, 4
Input Micro-Animations & Button Glow : 4, 5
Verifikasi PWA Mobile & Responsive Check : 5, 6
section Portal Admin (Gentelella)
Implementasi Top Tile Stats Count : 6, 8
Standardisasi X-Panel & Action Toolbox : 8, 9
Penyelarasan Tabel Data & Badges : 9, 10
section Validasi & Deploy
Pengujian Lintas Browser & Dark/Light Mode : 10, 11
Restart Service & Verifikasi Uptime : 11, 12
```
### Tahap 1: Penguatan Komponen Customer Portal (Uiverse Style)
1. Terapkan card glassmorphism dengan glow gradient pada form ganti SSID & Password di `views/dashboard.ejs`.
2. Sempurnakan animated button untuk aksi "Simpan Wi-Fi", "Beli Voucher", dan "Cek Tagihan".
3. Pastikan visual state mode terang (*light mode*) memiliki kontras tajam pada teks muted dan input field.
### Tahap 2: Modernisasi Admin Dashboard (Gentelella Style)
1. Perbarui bagian atas `views/admin/dashboard.ejs` dengan modul **Gentelella Tile Stats**:
* Total Pelanggan, Status Sesi MikroTik Aktif, Status ONU (Online/Offline/Warning Redaman), dan Tagihan Lunas/Tertunda.
2. Tata ulang card monitoring ke dalam format `x_panel` yang memiliki toolbar aksi (Refresh AJAX, filter status, expand).
### Tahap 3: Uji Coba Lintas Perangkat & Validasi Akhir
1. Uji di mobile browser (Android Chrome & iOS Safari).
2. Verifikasi seluruh endpoint AJAX, ganti SSID modem, dan refresh ONU tetap berfungsi tanpa gangguan.
3. Pastikan log journalctl pada `billing-rtrw.service` di `deneva` tetap bersih dan stabil.

View File

@@ -1,729 +0,0 @@
# RINGKASAN APLIKASI - RTRWNET BILLING & MANAGEMENT SYSTEM
**Status:** ✅ FULLY EXPLORED & DOCUMENTED
**Tanggal:** 14 Agustus 2026
**Format:** Quick Reference untuk understanding aplikasi
---
## 🎯 APLIKASI OVERVIEW
### What is RTRWNET?
**RTRWNET** adalah sistem manajemen terintegrasi untuk **ISP/RTRW** yang mengelola:
- ✅ Billing & invoicing otomatis
- ✅ Manajemen pelanggan & paket
- ✅ Jaringan fisik (MikroTik, OLT, ODP, cable routing)
- ✅ Device management (CPE/ONU via TR-069/GenieACS)
- ✅ Payment gateway integration (Midtrans, Tripay, Xendit, Duitku)
- ✅ Agent/reseller management
- ✅ WhatsApp automation
- ✅ Support ticketing
- ✅ Peta jaringan & GIS
- ✅ Monitoring & health check
**Single Platform:** Satu dashboard untuk admin, teknisi, agen, dan pelanggan.
---
## 📚 PORTAL UTAMA
| Portal | URL | User | Fungsi Utama |
|--------|-----|------|--------------|
| **Customer** | `/customer` | Pelanggan | Cek tagihan, bayar, ubah Wi-Fi, lihat status |
| **Admin** | `/admin` | Admin | Dashboard, billing, customer, MikroTik, settings |
| **Teknisi** | `/tech` | Teknisi | Tiket, peta, device monitoring, support |
| **Agen** | `/agent` | Reseller/Agent | Bayar tagihan pelanggan, jual voucher, jual pulsa |
| **Kolektor** | `/collector` | Collector | Cek & ajukan pembayaran (pending approval) |
| **Finance** | `/finance` | Admin/Finance | Laporan keuangan & pengeluaran |
| **Public** | `/` | Publik | Cek tagihan tanpa login, halaman info |
---
## 🏗️ ARSITEKTUR TEKNIS
### Backend Services (34 services)
```
1. billingService.js → Invoice generation, promo, prorata
2. customerService.js → CRUD customer, paket, isolir
3. mikrotikService.js → PPPoE, hotspot, firewall, monitoring
4. oltService.js → OLT management via SNMP
5. whatsappService.js → WhatsApp gateway (Baileys)
6. paymentService.js → Payment gateway integration
7. voucherPaymentService.js → E-voucher & payment methods
8. agentService.js → Agent & Digiflazz integration
9. cronService.js → Scheduled tasks automation
10. monitoringService.js → System health & metrics
11. ticketService.js → Support ticketing
12. inventoryService.js → Stock & asset management
13. auditTrailService.js → Action logging
14. backupService.js → Database backup
15. onuProvisionService.js → ONU TR-069 provisioning
... + 19 services lainnya (acsServerService, radiusServerService, dll)
```
### Database (SQLite)
```
Total: 50+ tables
Kategori:
- Master: packages, customers, routers, olts, odps
- Billing: invoices, payments, payment_approvals
- Network: pppoe_users, hotspot_users, vouchers
- Device: acs_devices, onu_mapping
- Internal: technicians, agents, collectors, audit_trail
- Other: expenses, cash_in, tickets, inventory_items
```
### Routes/Endpoints (250+ endpoints)
```
/customer/* → Customer portal (login, billing, device)
/admin/* → Admin panel (all management)
/tech/* → Technician portal (tickets, map, devices)
/agent/* → Agent portal (payment, voucher, pulsa)
/collector/* → Collector portal (approval workflow)
/finance/* → Finance reporting
/acs/* → Built-in TR-069 ACS server
/api/webhook/* → Payment webhooks
```
### Frontend (EJS Templates)
```
18 main views + partials:
- customer/ → 6 views (billing, device, voucher, etc)
- admin/ → Subdirectory dengan main routes
- tech/ → 3 views (dashboard, tickets, monitoring)
- agent/ → 3 views
- collector/ → 2 views
- Public pages → login, register, check_billing, tos, privacy
```
---
## 💰 FITUR BILLING
### Invoice Cycle
```
1. Otomatis: Cron @ 1 Jan setiap bulan jam 00:01
- Generate invoice untuk semua customer
- Kalkulasi promo price (jika applicable)
- Kalkulasi prorata (bulan pertama)
- Kalkulasi tax (PPN 11%)
- Status: 'unpaid'
2. Notifikasi WhatsApp otomatis ke customer
3. Customer bayar via:
- Payment gateway (Midtrans, Tripay, Xendit, Duitku)
- QRIS static (QR fixed dengan nominal unik)
- Manual via admin/agen
- Kolektor (pending approval)
4. Status berubah: 'unpaid' → 'paid'
5. Notifikasi paid + auto-unisolir jika dalam suspend
6. Late payment → Auto-isolir @ hari yang ditentukan
```
### Promo & Prorata
```
Promo:
- Paket A: Rp 350rb/bulan, promo Rp 250rb (3 siklus)
- Pelanggan baru: dapat promo 3 bulan pertama
- Counter ditrack di customer.promo_cycles_used
- Reset otomatis saat ganti paket
Prorata:
- Tanggal pasang: 15 Agustus
- Invoice pertama: Rp 350rb * (16 hari / 31 hari) = Rp 180rb
- Bulan berikutnya: Rp 350rb penuh
```
### Payment Gateway
```
Aktif (bisa kombinasi):
- Midtrans → CC, Transfer, E-wallet
- Tripay → Bank Transfer, QRIS, E-wallet
- Xendit → Virtual Account, QR
- Duitku → Aggregator
- QRIS Static → Fixed QR code
Webhook Generic:
- Endpoint: POST /api/webhook/v1/payment-notif
- Accept dari bank/e-wallet apapun
- Auto-match nominal → mark paid
- Log semua untuk audit
```
---
## 👥 CUSTOMER MANAGEMENT
### Data per Customer
```
Identitas:
- Nama, HP, Email, NIK, Alamat
- GPS Latitude/Longitude
- Area/Kabupaten
Jaringan:
- Paket (Starter/Pro/Premium)
- MikroTik Router assign
- PPPoE username
- Hotspot username (optional)
- Static IP (optional)
- OLT & ONU Port
- GenieACS Tag
Status & Billing:
- Status: active/suspended/inactive
- Isolir day (tanggal isolir otomatis)
- Auto-isolir enable/disable
- Promo cycles used
```
### Isolir Workflow
```
Manual Isolir:
1. Admin klik "Isolir" di customer detail
2. Sistem hapus/disable PPPoE user di MikroTik
3. Status berubah: active → suspended
4. Redirect ke page /isolated (jika online)
Auto-Isolir:
1. Cron job setiap hari jam 02:00
2. Cek customer: status=active + unpaid invoices + today >= isolate_day
3. Suspend PPPoE ke MikroTik
4. Notifikasi WhatsApp
Unisolir:
1. Pelanggan bayar
2. Admin klik "Buka Isolir"
3. Restore PPPoE user
4. Status: suspended → active
5. Customer online lagi
```
---
## 🌐 MIKROTIK INTEGRATION
### PPPoE Workflow
```
1. Create customer dengan PPPoE username
2. Sistem generate PPPoE secret (password)
3. Connect ke MikroTik Router API
4. Add user ke PPPoE service
5. Customer bisa login dengan username/password
Live Monitoring:
- View active sessions
- Bytes in/out per session
- Session duration
- IP address
FUP (Fair Usage Policy):
- Paket: limit 100GB/bulan
- Cron setiap jam cek usage
- Jika > 100GB: ganti profile ke 1Mbps
- Reset 1 Jan bulan berikutnya
Night Mode (Jam Kalong):
- 00:00 - 06:00: Speed 20Mbps
- 06:00 - 00:00: Speed 10Mbps
- Cron @ 00:00 & 06:00 switch profile
```
### Hotspot
```
Similar ke PPPoE:
- Create user dengan limit (uptime/data)
- Monitor active sessions
- Bandwidth limiting
```
### Voucher Hotspot
```
1. Admin create batch (qty 100, validity 30 hari)
2. Sistem generate 100 kode voucher unik
3. Sync ke MikroTik hotspot
4. Customer pakai untuk login hotspot portal
5. Agent bisa jual voucher ke pelanggan
6. Track status: created/active/used/expired
```
---
## 🎛️ GENIEACS & TR-069
### Built-in ACS
```
Aplikasi punya ACS internal (lightweight):
- Tidak perlu GenieACS eksternal
- Support ZTE, Huawei, FiberHome, dll
- Auto-detect device properties
- Port: 7547 (configurable)
```
### Device Management
```
Features:
- List semua ONU/CPE
- View device status (online/offline)
- Change SSID / Wi-Fi password
- Bulk SSID change
- Reboot device
- Provision WAN (VLAN, bridge, PPPoE)
- View device logs
- Parameter configuration
```
### Workflow
```
1. ONU/CPE boot → connect ke ACS server
2. Sistem auto-detect device properties
3. Admin bisa configure via panel
4. Change SSID → send ke device via TR-069
5. Device update config otomatis
6. Customer notif via WhatsApp
```
---
## 🔌 OLT & ONU MANAGEMENT
### OLT Setup
```
Konfigurasi:
- IP address OLT
- SNMP community string
- Telnet credentials (untuk config WAN)
- Web interface credentials
Multi-OLT:
- Bisa manage banyak OLT sekaligus
- Per pelanggan assign ke OLT mana
```
### ONU Monitoring
```
Via SNMP query:
- Status (online/offline)
- Serial number
- Optical signal (dBm)
- Distance ke OLT
- RX/TX power
Operations:
- Reboot ONU
- Rename (friendly name)
- Authorize ONU (EPON)
- Configure WAN (via Telnet/TR-069/REST API)
```
---
## 💳 PAYMENT FLOW
### Customer Bayar Invoice
```
1. Customer login portal
2. Lihat tagihan belum bayar
3. Click "Bayar"
4. Pilih metode pembayaran (dari gateway aktif)
5. Redirect ke payment gateway
6. Customer input data (CC/transfer/etc)
7. Gateway proses
8. Success → Webhook ke aplikasi
9. Aplikasi verify amount & signature
10. Mark invoice PAID
11. Send WhatsApp confirmation
12. Auto-unisolir jika customer dalam suspend
13. Dashboard terupdate
```
### Webhook Payment
```
Generic endpoint untuk bank/e-wallet:
POST /api/webhook/v1/payment-notif
Bisa terima dari:
- Bank (SMS gateway → webhook)
- E-wallet (API callback)
- QRIS (bank QRIS provider)
Sistem:
- Parse amount dari notifikasi
- Find invoice / voucher dengan nominal cocok
- Verify jika nominal sudah digunakan (duplikasi)
- Mark as PAID
- Log untuk audit
```
---
## 🗺️ GIS MAPPING
### Peta Admin
```
Features:
- Leaflet map (OpenStreetMap + Satellite)
- Marker customer (warna: hijau/merah/biru)
- Marker ODP (orange)
- Draw cable path (customer → ODP)
- Popup detail (klik marker)
- Zoom/pan/locate
Interaksi:
- Click customer → detail + isolir button
- Click ODP → info + available ports
- Draw polyline → cable path (simpan)
- Real-time PPPoE traffic graph
- Google Maps route (untuk tech)
```
### Cable Path
```
Simpan rute dari customer ke ODP:
- Array of [lat, lng] coordinates
- Calculate distance (KM)
- Description (aerial/underground/etc)
- Edit/delete existing path
```
### Tech GPS (Optional)
```
- Tech login portal dengan mobile
- Sistem track GPS lokasi tech
- Geofence alert (keluar/masuk area kantor)
- Nearby customers di radius tertentu
- Chat WhatsApp dari peta
```
---
## 💬 WHATSAPP AUTOMATION
### Setup
```
1. Admin buka /admin/whatsapp
2. Scan QR code dengan WhatsApp app
3. Verify & approve
4. Bot siap mengirim pesan
Gateway:
- Baileys (Unofficial WebSocket)
- Meta Cloud API (Official, jika setup)
```
### Automated Messages
```
Invoice Created:
"Tagihan Anda bulan ini siap!
Nominal: Rp 350.000
Bayar di: [link]
Due: 10 Agustus"
Invoice Paid:
"✅ Pembayaran diterima!
Tagihan Rp 350.000 LUNAS
Terima kasih!"
Isolir Reminder (H-1):
"⚠️ Reminder: Jatuh tempo BESOK
Tagihan Rp 350.000
Bayar untuk hindari isolir"
Isolir Notice:
"⚠️ Layanan Anda DIPUTUS
Penyebab: Tagihan belum dibayar
Hubungi [support]"
Unisolir:
"✅ Layanan Anda kembali AKTIF
Terima kasih pembayarannya!"
```
### Admin Commands (via WhatsApp)
```
"BILLING" → Send laporan billing hari ini
"PPPOE" → List active PPPoE sessions
"BALANCE" → Cek saldo Digiflazz
"TOPUP 1000000" → Top-up agent
"INVOICE" → List unpaid invoices
```
### Broadcast
```
Select customers → Create message → Schedule → Send
- Set delay antar pesan (3-5 detik)
- Track delivery status
- Pause/resume/stop anytime
- Retry failed messages
```
---
## 👔 AGENT MANAGEMENT
### Agent Fitur
```
Dashboard:
- Total saldo (Rp)
- Today's transaction
- Commission earned
- Top customers
Pembayaran:
- Cari pelanggan
- Lihat unpaid invoices
- Bayar dari saldo agent
- Print struk
Voucher:
- Lihat inventory
- Jual ke customer
- Print code
- Track sale
Digiflazz (Pulsa/Data):
- Browse produk (Telkomsel, Indosat, dll)
- Category → Brand → Nominal
- Buy untuk customer
- Webhook status update
```
### Admin Agent Control
```
- Create/edit agent
- Top-up balance
- Set commission %
- View transaction history
- Special pricing per agent
- Performance report
```
---
## 🎫 TICKETING SYSTEM
### Ticket Status Flow
```
open → in_progress → resolved → closed
Open:
- Customer create di portal
- Auto-assign ke admin/tech
In Progress:
- Tech ambil ticket
- Add notes/photos
- Update status
Resolved:
- Tech mark selesai
- Notif ke customer
Closed:
- Customer confirm selesai
- Optional: give rating (1-5)
```
### Admin Features
```
- Dashboard tickets (count per status)
- Assign ke technician
- Reassign jika perlu
- View ticket history
- SLA tracking
- Performance report
```
### Tech Features
```
- Pool tickets (assigned to me)
- View detail
- Take ticket
- Upload photos (before/after)
- Add resolution notes
- Mark resolved
```
### Customer Features
```
- Create ticket
- View my tickets
- See status update
- Add comment/photo
- Rate satisfaction
```
---
## ⚙️ AUTOMATION (CRON JOBS)
| Waktu | Tugas | Detail |
|-------|-------|--------|
| 1 Jan @ 00:01 | Generate invoices | Monthly billing untuk semua customer |
| Setiap hari @ 02:00 | Auto-isolir | Suspend customer overdue |
| Setiap hari @ 09:00 | Reminder tagihan | WhatsApp reminder H-1 isolir |
| 00:00 & 06:00 | Night mode | Ganti profile PPPoE malam/siang |
| Setiap jam | FUP check | Check usage vs limit, ganti profile |
| Setiap 10 menit | Usage sync | Sinkron PPPoE traffic dari MikroTik |
| Setiap hari @ 02:00 | Database backup | Auto-backup dengan retention |
---
## 📊 DATABASE OVERVIEW
### Key Tables
```
customers
├── Identitas (name, phone, email, nik)
├── Jaringan (pppoe_username, hotspot_username, static_ip)
├── Device (olt_id, onu_port, genieacs_tag)
├── Billing (package_id, isolate_day, auto_isolate)
└── Status (status, created_at, updated_at)
invoices
├── Amount (amount, status, qris_amount_unique)
├── Period (period_month, period_year)
├── Payment (paid_at, payment_method)
└── Notes (promo/prorata info)
payments
├── Transaction data (gateway_id, signature, status)
├── Customer info
└── Invoice link
pppoe_users
├── Credentials (username, secret)
├── Profile & limits
└── Session tracking
... + 40+ tables lainnya
```
---
## 🎯 SUMMARY - FITUR UTAMA YANG BERJALAN
✅ **Billing & Invoicing**
- Auto generate bulanan
- Promo cycles tracking
- Prorata bulan pertama
- Multi-gateway payment
✅ **Customer Lifecycle**
- CRUD pelanggan
- Paket management
- Auto-isolir/unisolir
- Status tracking
✅ **Network Management**
- MikroTik PPPoE/Hotspot
- Voucher hotspot
- Firewall & rate limiting
- Night mode automation
✅ **Device Management**
- Built-in TR-069 ACS
- GenieACS integration
- SSID/password change
- Device provisioning
✅ **OLT/ONU Management**
- SNMP monitoring
- Reboot/rename/authorize
- WAN configuration
- Multi-brand support
✅ **Payment Processing**
- 4 payment gateways
- QRIS static
- Generic webhook
- Payment verification
✅ **GIS Mapping**
- Leaflet maps
- Cable path tracking
- GPS tech tracking
- Real-time graphs
✅ **WhatsApp Automation**
- Invoice notification
- Payment reminder
- Isolir notice
- Broadcast messaging
✅ **Agent Management**
- Pembayaran dari saldo
- Voucher sales
- Pulsa/data (Digiflazz)
- Commission tracking
✅ **Support Ticketing**
- Create/assign/resolve
- Photo upload
- Rating system
- History tracking
✅ **Monitoring**
- System health metrics
- Service status
- Alert system
- Performance tracking
✅ **Automation**
- 7 scheduled jobs
- Configurable timing
- Error handling
- Logging
✅ **Multi-Portal**
- Customer self-service
- Admin full control
- Technician field support
- Agent sales
- Collector approval
✅ **Compliance**
- Audit trail logging
- Role-based access
- Session management
- Data validation
---
## 🚀 PRODUCTION STATUS
**Aplikasi ini SUDAH BERJALAN** dan mencakup:
- ✅ Complete billing system
- ✅ Full customer management
- ✅ Network device integration
- ✅ Payment processing
- ✅ Multi-user portals
- ✅ Automation & scheduling
- ✅ Monitoring & health check
**Siap untuk:**
- ✅ Production deployment
- ✅ Scaling operasi ISP
- ✅ Integration ke sistem existing
- ✅ Customization per requirements
---
## 📖 DOKUMENTASI TERSEDIA
1. **FITUR_APLIKASI_LENGKAP.md** ← Dokumentasi ini (16 fitur utama + detail)
2. **AUDIT_LENGKAP.md** (Existing) ← Technical deep dive
3. **DATABASE_SCHEMA.md** (Existing) ← Struktur data lengkap
4. **QUICK_REFERENCE.md** (Existing) ← Common commands
5. **AUDIT_SECURITY_2026.md** (Baru) ← Security audit findings
6. **IMPLEMENTATION_GUIDE_SECURITY.md** (Baru) ← Fix guide
7. **README.md** (Existing) ← Instalasi & setup
---
**Status:** ✅ Aplikasi komprehensif, well-documented, siap production
**Next Step:** Implementasi security fixes (bcrypt, .env, CSRF) sebelum production

View File

@@ -1,81 +0,0 @@
# RUJUKAN ARSITEKTUR & FITUR SISTEM BILLING RTRW-NET
Dokumen ini disimpan sebagai **rujukan utama** arsitektur, fitur backend, frontend, dan skema database sebelum dilakukan penambahan fitur RADIUS Server.
---
## 1. DOKUMENTASI MODUL & KOMPONEN EKSISTING
### A. Core Engine & Entry Point
- **`app-customer.js`**: Main entry point Express.js v4.
- Memuat middleware security: CORS, Referer/Origin CSRF Protection, Express Session, Body Parser limit 1MB.
- Pengatur i18n multi-bahasa (`locales/`).
- Menjalankan background service: Auto-backup (`scheduleAutoBackup`), Cron Billing (`cronService.js`), WhatsApp Bot, Telegram Bot.
- Mengunci DNS ke IPv4 (`dns.setDefaultResultOrder('ipv4first')`) untuk mencegah timeout IPv6 di server produksi.
### B. Database & Stored Layer (`config/database.js`)
- **Engine**: SQLite 3 menggunakan driver `better-sqlite3` (Native C++ extension, high performance).
- **Mode**: `WAL` (Write-Ahead Logging) dengan `foreign_keys = ON`.
- **Fungsi Kustom SQLite**: `NOW_LOCAL()` (memformat tanggal-waktu berdasarkan timezone lokal `Asia/Jakarta`).
- **Tabel Utama**:
1. `settings` (Dynamic Key-Value Store)
2. `packages` (Paket Internet: harga, speed up/down, promo, jam kalong, FUP)
3. `customers` (Data Pelanggan: no_pelanggan, nama, phone, pppoe_user, pppoe_secret, status, isolir_date, router_id, odp_id, genieacs_tag)
4. `invoices` & `payments` (Tagihan & Pembayaran)
5. `routers` (Daftar Router MikroTik: host, port 8728, username, password, status)
6. `pppoe_users` & `pppoe_traffic_samples` & `pppoe_sessions`
7. `hotspot_users` & `hotspot_profiles` & `hotspot_sessions`
8. `voucher_batches` & `vouchers` & `public_voucher_orders`
9. `olts`, `odps`, `onu_mapping`, `acsdevices` (Modul FTTH & TR-069 ACS)
10. `technicians`, `cashiers`, `collectors`, `agents` (Akses multi-role)
11. `audit_trail` (Log aktivitas pengguna)
### C. Services Layer (`services/`)
1. **`mikrotikService.js`**:
- Menghubungkan aplikasi ke RouterOS via API port 8728/8729 menggunakan `routeros-client`.
- Mengelola PPPoE Secret (Add, Update, Delete, Toggle Enable/Disable).
- Mengelola Hotspot User & Active Sessions.
- Mengambil trafik interface & active connections secara realtime.
- Mengatur isolir pelanggan via MikroTik API (mengubah profile / IP pool).
2. **`customerService.js`**:
- CRUD Pelanggan, komparasi data lokal vs router.
- Penanganan siklus isolir & pemulihan status otomatis.
3. **`billingService.js`**:
- Penjadwalan & pembuatan invoice bulanan.
- Pembayaran invoice (Cash, Transfer, QRIS, Gateway Midtrans/Tripay/Xendit).
- Eksekusi auto-isolir saat jatuh tempo.
4. **`cronService.js`**:
- Menjalankan penagihan otomatis jam 00:05.
- Cek auto-isolir harian jam 01:00.
- Sampling statistik trafik jam 10 menit sekali.
- Notifikasi reminder tagihan via WA / Telegram.
5. **`oltService.js` & `onuProvisionService.js` & `acsServerService.js`**:
- Integrasi OLT ZTE/Huawei/FiberHome via SNMP & Telnet.
- TR-069 Auto-Provisioning CPE via GenieACS.
### D. Route & Portal Layer (`routes/`)
1. **`/admin` (`adminPortal.js`)**: Portal Utama Admin (Dashboard, Pelanggan, Paket, Router, Transaksi, Laporan, OLT/ACS, Settings).
2. **`/customer` (`customerPortal.js`)**: Portal Pelanggan Self-Service (Cek Tagihan, Bayar QRIS, Tiket Komplain).
3. **`/agent` (`agentPortal.js`)**: Portal Agen (Jual Voucher, Topup Pulsa Digiflazz).
4. **`/collector` (`collectorPortal.js`)**: Portal Kolektor Penagih Lapangan.
5. **`/tech` (`techPortal.js`)**: Portal Teknisi (Tiket Gangguan, Pasang Baru, Mapping ODP).
6. **`/finance` (`financePortal.js`)**: Portal Keuangan (Pemasukan & Pengeluaran Kas).
### E. Frontend & Views (`views/`)
- Menggunakan **EJS (Embedded JavaScript)** dengan template responsive.
- Menggunakan CSS Vanilla & Custom Theme (Dark/Light Mode), Bootstrap Icons / FontAwesome.
- AJAX Fetch API untuk interaksi data tanpa reload halaman.
---
## 2. ATURAN INTEGRASI FITUR RADIUS (NON-BREAKING GUARANTEE)
1. **Prinsip Utama**: Seluruh modul MikroTik API eksisting **TIDAK BOLEH DIUBAH ATAU DIHAPUS**.
2. **Fleksibilitas**: Sistem akan mendukung 2 mode otentikasi router:
- **Mode 1 (Default Eksisting)**: MikroTik API (`routeros-client`).
- **Mode 2 (Fitur Baru)**: RADIUS Server (UDP Port 1812/1813).
- **Mode 3 (Hybrid)**: MikroTik API + RADIUS Server berjalan bersamaan.
3. **Database Integration**: RADIUS Server akan langsung membaca & menulis ke `database/billing.db` eksisting:
- Mengotentikasi kredensial pengguna dari tabel `customers` / `pppoe_users` / `hotspot_users`.
- Mengirimkan atribut RADIUS standar (`Framed-IP-Address`, `Mikrotik-Rate-Limit`, `Session-Timeout`).
- Menerima paket RADIUS Accounting (Start/Interim/Stop) dan mencatat penggunaan kuota/bandwidth ke tabel `pppoe_traffic_samples` & `radius_accounting_logs`.

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

@@ -1,98 +0,0 @@
# UI/UX Rules — Billing RTRW (SKYFIBER)
> Dokumen acuan wajib untuk **semua** pekerjaan UI/UX di project ini.
> Setiap kali membuat / mengubah tampilan, ikuti aturan di bawah.
---
## Peran
**Act as an Expert Senior Frontend Developer.**
Your task is to generate a highly responsive, modern, and fluid web layout for
**[Sebutkan apa yang ingin dibuat, misal: a Dashboard / Landing Page / Navigation Bar]**
using **[Sebutkan teknologi, misal: HTML & Tailwind CSS / HTML & Vanilla CSS / EJS]**.
> Di project ini umumnya: **EJS + Vanilla CSS** (`public/css/admin.css`, `landing.css`,
> `auth.css`, `admin-theme.css`) dan **Bootstrap Icons** (bukan emoji di UI).
---
## Strict Layout & Responsiveness Requirements
### 1. Fluid Typography & Spacing
- Gunakan fungsi CSS **`clamp()`** untuk ukuran font, padding, dan margin agar skala
mulus di semua ukuran layar (mobile → desktop 4K) **tanpa** hanya mengandalkan
media query yang patah-patah.
- Contoh: `font-size: clamp(0.95rem, 0.9rem + 0.5vw, 1.25rem);`
`padding: clamp(12px, 2vw, 28px);`
### 2. Zero Overlap Guarantee
Pastikan teks, kartu, tombol, dan ikon **tidak pernah** tumpang tindih atau keluar
dari kontainernya:
- `overflow-wrap: break-word;` atau `word-break: break-word;` untuk teks panjang
(mis. link, nama panjang, kode).
- `min-width: 0;` pada **flex/grid children** agar tidak meluber keluar parent.
- Gunakan **`flex-wrap: wrap` + CSS `gap`** (bukan margin tetap) untuk jarak antar item.
- Gambar & ikon harus responsif: `max-width: 100%; height: auto;` atau
`aspect-ratio` + `object-fit: contain|cover`.
### 3. Flexible & Mappable Structure (CSS Grid / Flexbox)
- Bangun layout yang **self-adapting**.
- **CSS Grid** dengan `repeat(auto-fit, minmax(..., 1fr))` untuk grid kartu agar
otomatis memetakan ke ruang layar yang tersedia.
Contoh: `grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));`
- **Flexbox** untuk perataan (alignment) & distribusi.
- **Hindari tinggi tetap** (`height: 100px`); gunakan **`min-height`** agar konten
bisa memanjang dengan aman.
### 4. Mobile-First Approach
- Mulai dari layout **vertikal** yang solid untuk mobile.
- Lalu naikkan dengan **media query** (`@media (min-width: ...)`) atau breakpoint
Tailwind (`md:`, `lg:`) untuk memetakan ke **grid/flex horizontal** di layar besar.
### 5. Debug-Friendly & Minimal Bugs
- Tulis HTML yang **bersih, semantik, dan dangkal** (hindari *div soup*).
- Gunakan **CSS Custom Properties (Variabel) terpusat** untuk warna, spacing, dan
font agar perubahan cepat & mudah.
Contoh: `--primary: #ADFF2F; --bg: #0A0D14; --radius: 14px;`
- Beri **komentar jelas** di kode yang menjelaskan logika layout.
---
## Output
Output **tepat kode yang diperlukan**, pastikan:
- **siap dijalankan** (ready to run),
- **bebas bug** (bug-free),
- **mudah di-debug**.
---
## Catatan khusus project ini
- **Tema**: dark (default) + lime `#ADFF2F` di atas near-black `#0A0D14`; harus ada
toggle dark/light di setiap halaman.
- **Ikon**: **Bootstrap Icons** saja untuk UI (emoji hanya untuk teks WhatsApp).
- **Brand**: nama & logo diambil dari settings (`brandName` / `brandLogo`), jangan hardcode.
- **Bilingual (ID/EN)** via `t('key', 'fallback')` — identifier kode tetap Inggris.
- **Admin**: markup sidebar & `admin.css`/`dashboard.ejs` diperlakukan "frozen";
penyesuaian via overlay `admin-theme.css`.
- **Scroll**: area konten admin = `.mw` (scroll container). Jangan bikin konten
terpotong; `.page` dibiarkan tumbuh (`flex: 0 0 auto`) agar `.mw` bisa scroll.
- **Modal**: satu scroll saja (`.mb` yang scroll, `.mbody` `overflow: visible`) —
hindari *nested scroll* yang mengunci.
- **Responsif**: gunakan `100dvh` (bukan `100vh`) untuk area penuh-layar di mobile.
---
## Checklist cepat sebelum selesai
- [ ] Ukuran font/spacing pakai `clamp()` di elemen utama.
- [ ] Tidak ada teks/ikon yang meluber/overlap (cek `min-width:0`, `overflow-wrap`, `flex-wrap`).
- [ ] Grid kartu pakai `repeat(auto-fit, minmax(..., 1fr))`.
- [ ] Tidak ada `height` tetap pada blok konten (pakai `min-height`).
- [ ] Mobile-first + breakpoint untuk layar besar.
- [ ] HTML semantik & dangkal; warna/spacing via CSS variables.
- [ ] Dark/light berfungsi; ikon Bootstrap Icons; bilingual ID/EN.
- [ ] Scroll & modal normal di mobile **dan** desktop.

View File

@@ -1,567 +0,0 @@
# Changelog Perubahan Kode — Billing RTRW
Project : `/opt/billing-rtrw`
Host : `deneva`
Service : `billing-rtrw` (systemd, port 3001)
Owner : `billing:billing`
> Project ini **belum memakai Git**, jadi file ini adalah satu-satunya catatan
> perubahan. Setiap perubahan kode selalu disertai backup file asli (lihat
> bagian *Rollback* pada tiap entri).
Format: `## [versi] — tanggal` (terbaru di atas).
---
## [v1.9.7] — 2026-09-28 16:55 — Full UI/UX Modernization & Database Optimization (Gentelella Tiles, uiverse.io Effects & Google Fonts)
### 1. Modernisasi Lengkap Seluruh Halaman Utama Admin (Gentelella Admin Style)
- **views/admin/dashboard.ejs**: Implementasi Tile KPI Stat horizontal (Total ONU, Online, Offline, Warning Redaman) dan Tile Keuangan (Pendapatan, Pelanggan Aktif, Tagihan Belum Bayar, Total Piutang) dengan indikator tren visual.
- **views/admin/customers.ejs**: Mengubah stat-grid filter pelanggan menjadi Gentelella Tile Cards interaktif (Total, Aktif, Disuspend, Nonaktif/Baru) dengan highlight filter aktif dan angka monospaced.
- **views/admin/billing.ejs**: Mengubah summary tagihan menjadi Gentelella Tile Cards (Total Tagihan, Sudah Bayar, Belum Bayar, Rasio Lunas).
- **views/admin/monitoring.ejs & views/admin/olts.ejs**: Menambahkan Google Fonts universal (Inter + JetBrains Mono).
### 2. Harmonisasi Desain Portal Pelanggan & Login (uiverse.io Style)
- **views/login.ejs**: Menyelaraskan seluruh palette warna dari hardcoded Indigo ke tema resmi Skyfiber (Teal #0d9488 & Cyan #06b6d4), menambahkan Google Fonts Plus Jakarta Sans, dan uiverse elevated glass card.
- **views/dashboard.ejs**: Menambahkan Google Fonts JetBrains Mono untuk pembacaan parameter teknis (IP, MAC, redaman dBm) dan efek micro-animation tombol .btn-uiverse-glow.
### 3. Optimasi Database SQLite
- Eksekusi PRAGMA integrity_check (hasil: ok).
- Eksekusi PRAGMA optimize untuk query planner performa tinggi.
### File disentuh
- public/css/admin.css
- views/admin/dashboard.ejs
- views/admin/customers.ejs
- views/admin/billing.ejs
- views/admin/monitoring.ejs
- views/admin/olts.ejs
- views/login.ejs
- views/dashboard.ejs
- views/isolated.ejs
- update-code.md
### Rollback
```bash
cp /opt/billing-rtrw/views/admin/customers.ejs.bak-gentelella /opt/billing-rtrw/views/admin/customers.ejs
cp /opt/billing-rtrw/views/admin/billing.ejs.bak-gentelella /opt/billing-rtrw/views/admin/billing.ejs
cp /opt/billing-rtrw/views/admin/dashboard.ejs.bak-gentelella /opt/billing-rtrw/views/admin/dashboard.ejs
cp /opt/billing-rtrw/views/login.ejs.bak-plusjakarta /opt/billing-rtrw/views/login.ejs
cp /opt/billing-rtrw/views/dashboard.ejs.bak-uiverse /opt/billing-rtrw/views/dashboard.ejs
sudo systemctl restart billing-rtrw.service
```
---
## [v1.9.6] — 2026-09-28 16:35 — Modernisasi UI/UX (Gentelella Admin Tiles, uiverse.io Effects & Google Fonts) + Matikan Service Biznet
### 1. Pematian Layanan di Server Biznet (Pencegahan Tabrakan Service)
- Service `billing-rtrw.service` di host `biznet` di-stop dan di-disable (`sudo systemctl stop & disable`).
- Process PM2 di `biznet` di-stop dan di-delete.
- Hasil: Menghilangkan konflik 409 pada Telegram Bot dan mencegah duplikasi trigger cron isolir/tagihan antar-server.
### 2. Adopsi Desain Gentelella Admin (views/admin/dashboard.ejs & public/css/admin.css)
- Mengganti grid metrik lama dengan modul **Gentelella Tile Stats (`.tile_count` & `.tile_stats_count`)**:
- Border samping berwarna aksen (`tile-primary`, `tile-success`, `tile-danger`, `tile-warning`).
- Label `count_top` uppercase dengan ikon jelas.
- Nilai metrik `count` berbobot 800 dengan font monospaced tabular.
- Sub-label `count_bottom` dengan indikator tren (`trend-up`, `trend-down`, `trend-warn`).
- Menambahkan class kontainer `.x_panel` & `.x_title` dan soft badges (`.badge-soft-success`, `.badge-soft-danger`, dll).
### 3. Adopsi Desain uiverse.io (views/dashboard.ejs)
- Menambahkan efek glassmorphism tingkat tinggi dengan border glow gradient dan shadow elevasi.
- Menambahkan animasi tombol mikro-interaktif (`.btn-uiverse-glow`) dengan hover lift-up (`translateY(-2px)`) dan klik aktif responsif.
### 4. Standarisasi Google Fonts Universal
- Mengintegrasikan **Inter** (antarmuka admin & tabel data), **Plus Jakarta Sans** (portal pelanggan & public landing), dan **JetBrains Mono** (data teknis IP, MAC, serial, dBm) melalui Google Fonts dengan koneksi teroptimasi (`preconnect` dan `display=swap`).
### File disentuh
- public/css/admin.css
- views/admin/dashboard.ejs
- views/dashboard.ejs
- update-code.md
### Rollback
```bash
cp /opt/billing-rtrw/public/css/admin.css.bak-gentelella /opt/billing-rtrw/public/css/admin.css
cp /opt/billing-rtrw/views/admin/dashboard.ejs.bak-gentelella /opt/billing-rtrw/views/admin/dashboard.ejs
cp /opt/billing-rtrw/views/dashboard.ejs.bak-uiverse /opt/billing-rtrw/views/dashboard.ejs
sudo systemctl restart billing-rtrw.service
```
---
## [v1.9.5] — 2026-09-28 16:20 — Fix Telegram Polling Flood, Unifikasi Tipografi (Plus Jakarta Sans) & Harmonize Login UI
### 1. Perbaikan Telegram Bot Polling Error Flood (services/telegramBot.js)
- Log server sebelumnya dibanjiri pesan error setiap 8 detik: error: Telegram Polling Error:.
- Penyebab: Terdeteksi error code 409 Conflict dari API Telegram karena bot token yang sama masih aktif dijalankan di server lama (biznet/migrasi).
- Solusi:
1. Menambahkan penanganan error detail (error?.response?.body?.description || error.message) agar terbaca jelas di logger.
2. Implementasi throttling logger (maksimal 1 log per 2 menit untuk error berulang yang sama).
3. Memisahkan level warning khusus bila terjadi 409 Conflict, sehingga journalctl bersih tanpa spam error.
### 2. Unifikasi Tipografi & Harmonisasi Desain (views/login.ejs & views/isolated.ejs)
- Menyelaraskan seluruh font pada portal publik & login pelanggan ke **Plus Jakarta Sans** (Google Fonts).
- Menyesuaikan palette warna primer & hover pada halaman login (views/login.ejs) menggunakan tema Teal/Cyan (#0d9488, #14b8a6, #2dd4bf) agar selaras dengan dashboard pelanggan.
- Menambahkan **Plus Jakarta Sans** pada halaman terisolir (views/isolated.ejs).
### File disentuh
- services/telegramBot.js
- views/login.ejs
- views/isolated.ejs
- update-code.md
### Rollback
```bash
cp /opt/billing-rtrw/services/telegramBot.js.bak-pollfix /opt/billing-rtrw/services/telegramBot.js
cp /opt/billing-rtrw/views/login.ejs.bak-plusjakarta /opt/billing-rtrw/views/login.ejs
cp /opt/billing-rtrw/views/isolated.ejs.bak-plusjakarta /opt/billing-rtrw/views/isolated.ejs
sudo systemctl restart billing-rtrw.service
```
---
## [v1.9.4] — 2026-09-27 18:35 — Fix Theme Toggle (Dark/Light Mode), FOUC Prevention & Light Theme Styling
### Masalah
- Tombol toggle tema (#theme-icon di header dashboard pelanggan) tidak berfungsi sama sekali saat diklik.
- Penyebab:
1. Logika Negasi Terbalik di toggleTheme(): Saat mode awal gelap, contains('light') bernilai false sehingga !false adalah true. Kode terus-menerus menyimpan dan menerapkan mode 'dark', tidak pernah beralih ke 'light'.
2. Class Collision pada <html>: <html class='dark'> memiliki class 'dark' permanen. applyTheme lama hanya menambahkan class 'light' tanpa menghapus class 'dark'. Tailwind CSS (darkMode: 'class') tetap mengaktifkan semua style dark:... selama class 'dark' ada di <html>.
3. FOUC saat reload: Tidak ada script sinkronisasi langsung di <head>, sehingga halaman sempat berkedip gelap sebelum DOMContentLoaded berjalan.
4. Missing data-theme: Tidak mengeset atribut data-theme='light'/'dark' yang diperlukan oleh theme.css.
### Solusi & Perbaikan
1. Perbaikan toggleTheme() & applyTheme(mode):
- Menghapus class 'dark' dan menambahkan 'light' saat beralih ke mode terang, dan sebaliknya saat mode gelap.
- Menetapkan atribut data-theme pada <html> secara konsisten.
- Sinkronisasi kedua key penyimpanan (skyfiber-customer-theme & app-theme).
- Mengubah ikon toggle secara dinamis: fa-sun text-amber-500 pada mode terang, fa-moon text-amber-400 pada mode gelap.
2. Inline Script di <head>:
- Menambahkan skrip sinkronisasi tema instan di dalam <head> sebelum browser merender body untuk mencegah kedip (FOUC).
3. Penyempurnaan Styling Mode Terang (Light Mode):
- Floating glass dock disesuaikan dengan latar frosted transparan putih dan sliding pill bergradasi lembut.
- Penyesuaian kontras input form, teks judul, card modal, dan gradien banner (tagihan lunas, tagihan aktif, info router).
### File disentuh
- views/dashboard.ejs
- update-code.md
### Rollback
```bash
cp /opt/billing-rtrw/views/dashboard.ejs.bak-themefix-20260927183449 /opt/billing-rtrw/views/dashboard.ejs
systemctl restart billing-rtrw.service
```
---
## [v1.9.3] — 2026-09-27 18:30 — Fix PWA Auto-Login, Persistent Remember Token & Session Lifetime
### Masalah
- Ketika PWA di-install di HP dan user login, saat aplikasi PWA di-kill atau ditutup, user terlempar ke halaman login lagi.
- Penyebab utama:
1. `start_url` di `manifest.webmanifest` diset ke `/customer/login?source=pwa`.
2. Rute `/customer/login` dan `/` memeriksa `req.session.customer`, sementara rute login hanya menyimpan `req.session.phone` dan `req.session.pppoe_username` (`customer` selalu undefined), sehingga selalu merender form login ulang.
3. Masa berlaku cookie sesi bawaan hanya 24 jam dan disimpan di RAM (MemoryStore) tanpa token persistensi native.
4. Service worker (`public/sw.js`) melakukan fallback navigasi ke `/customer/login` saat jaringan lambat/offline.
### Solusi & Perbaikan
1. **PWA Start URL:** Mengubah `start_url` di `/manifest.webmanifest` menjadi `/customer/dashboard`.
2. **Persistent Auto-Login Token (90 Hari):**
- Layanan baru `services/customerAuthTokenService.js` dengan tabel SQLite `customer_auth_tokens`.
- Mengeluarkan cookie `customer_remember` (90 hari, httpOnly, secure, lax) saat login langsung maupun verifikasi OTP.
- Middleware auto-login di `routes/customerPortal.js` yang secara otomatis memulihkan sesi pelanggan ketika aplikasi dibuka kembali.
3. **Penyelarasan Guard Sesi:**
- Rute `/customer/` dan `/customer/login` kini memeriksa `req.session.phone || req.session.customer` sehingga pengguna yang sudah login langsung dialihkan ke `/customer/dashboard`.
- Objek `req.session.customer` kini diisi lengkap pada direct login dan OTP.
4. **Masa Berlaku Cookie Sesi:** Ditingkatkan dari 24 jam menjadi 30 hari.
5. **Logout Bersih:** Rute `/customer/logout` (mendukung POST dan GET) mencabut token dari database dan menghapus cookie `customer_remember` serta `customer.sid`.
6. **Fallback Offline SW v6:** Navigasi offline dialihkan ke `/offline.html` dengan tombol "Coba Lagi", bukan ke halaman login.
7. **Penyimpanan Lokal Form:** Menyimpan ID pelanggan terakhir di `localStorage` pada `views/login.ejs`.
### File disentuh
- `services/customerAuthTokenService.js` (file baru)
- `app-customer.js`
- `routes/customerPortal.js`
- `public/sw.js`
- `views/login.ejs`
---
## [v1.9.2] — 2026-09-27 18:15 — Multi-Vendor WiFi SSID & Password Changer (Huawei / Nokia / FiberHome / ZTE)
### Perbaikan Ganti SSID & Password WiFi Multi-Vendor
- Mengatasi kegagalan ganti password TR-069 akibat penumpukan multiple parameter paths dalam satu payload SOAP atomik.
- Menghindari pengiriman `PreSharedKey.1.PreSharedKey` (format hex 64 digit) bersamaan dengan `KeyPassphrase`, yang sebelumnya memicu SOAP fault 9007 (Invalid Parameter Value) di modem.
- **Deteksi Vendor Otomatis (`HWTC`, `FiberHome`, `Nokia`, `ZTE`, `ZICG`, `CIOT`, `CDTC`):**
- Huawei / FiberHome / Nokia: memprioritaskan `PreSharedKey.1.KeyPassphrase` (menghindari fault 9007 karena penolakan `KeyPassphrase` top-level).
- ZTE / ZICG / CIOT: memprioritaskan `KeyPassphrase`.
- **SSID 2.4G & 5G:** Mengirim path tunggal yang tepat untuk 2.4 GHz (`WLANConfiguration.1.SSID`) dan 5 GHz (`WLANConfiguration.5.SSID`) jika perangkat dual-band, tanpa menimpa interface guest/sekunder.
- Fallback cerdas berbasis standar TR-098 per vendor.
### File disentuh
- `services/customerDeviceService.js`
### Rollback
```bash
cp /opt/billing-rtrw/services/customerDeviceService.js.bak-wififix /opt/billing-rtrw/services/customerDeviceService.js
sudo systemctl restart billing-rtrw
```
---
## [v1.9.1] — 2026-09-27 16:30 — 5-Tab Floating Dock, PPOB Soon, Profil Tiket & WhatsApp Anti-Ban
### 1. Floating Dock 5-Tab (Dashboard Pelanggan)
- Menghilangkan tombol tengah (+) Aksi Cepat dan popup modal sheet.
- Menjadikan navbar 5 tombol langsung: Beranda, Router, PPOB (badge gradient "Soon"), Tagihan (ping unread), dan Profil.
- Pusat Bantuan & Pembuatan Tiket Kendala diintegrasikan ke dalam Tab Profil (`tab-profile`) dengan tombol cepat ke form pelaporan dan WhatsApp CS.
- Ditambahkan tombol "Kembali ke Profil" di halaman tiket bantuan (`tab-bantuan`).
- Banner "Layanan PPOB Segera Hadir" pada tab PPOB (`tab-ppob`).
### 2. Sinkronisasi Upstream & Fix Anti-Ban WhatsApp
- Verifikasi repository upstream `alijayanet/billing-rtrw`: base commit `ffd3c62` sudah mencakup seluruh perbaikan OLT Hioso (gap-fill DDM, Buffer UTF-8 decoding, slow SNMP session) dan timezone midnight hourCycle h23.
- Menambahkan proteksi anti-ban WhatsApp pada alert monitoring (`services/whatsappBot.mjs`):
- Mematuhi toggle `genieacs_monitoring_enabled` (tidak kirim alert jika toggle OFF).
- Jeda acak (8-20 detik) antar penerima saat kirim broadcast alert ke admin/teknisi.
- Menjadwalkan pemeriksaan monitoring harian pada jam 07:00 pagi (`config/genieacs.js`) lewat `node-cron` untuk mencegah spam alert berulang.
### File disentuh
- `views/dashboard.ejs`
- `services/whatsappBot.mjs`
- `config/genieacs.js`
---
## [v1.9.0] — 2026-09-26 22:55 — Floating Dock Navbar + CDN Pin + SW v5
Perubahan UI besar (navbar) sekaligus perbaikan regresi PWA/offline.
### Navbar: floating glass dock
- Bottom nav lama (waterdrop, 6 tombol horizontal) diganti **floating glass
dock** sesuai referensi `adaptive_glassmorphism_floating_dock.html`.
- Struktur: 4 tombol langsung (**Beranda, Router, Tagihan, Profil**) + 1 tombol
tengah `+` yang membuka **quick-action sheet** berisi
**PPOB, Bantuan, Reconnect, Poll ONU**.
PPOB & Bantuan sengaja dipindah ke sheet agar dock tidak terlalu padat.
- ** keenam tab tetap bisa diakses**, `id="tab-*"` tidak berubah:
`tab-beranda`, `tab-router`, `tab-ppob`, `tab-tagihan`, `tab-bantuan`,
`tab-profile`.
- CSS baru: `.dock-glass`, `.dock-pill` (sliding pill), `.dock-item`,
`.dock-icon`, quick-sheet overlay. `safe-area-inset-bottom` untuk iPhone.
- JS baru: `updatePillPosition()`, `toggleQuickSheet()`, `openQuickSheet()`,
`closeQuickSheet()`, state `currentTab`, `isSheetOpen`.
`switchTab()` sekarang menandai `.dock-item` aktif dan memindahkan pill.
- Support keyboard: angka `1`–`6` pindah tab, `Escape` menutup sheet.
- Ikon memakai **Lucide** (`data-lucide`) + `lucide.createIcons()`.
- CSS `.waterdrop-active` yang tidak terpakai lagi dibuang.
### Perbaikan regresi offline/PWA
- **Semua CDN dashboard kini di-pin ke versi tetap** (sebelumnya `@latest` /
tanpa versi → bisa berubah atau break tanpa notice):
||Aset|URL|
|---|---|
|Lucide|`https://unpkg.com/lucide@1.48.0/dist/umd/lucide.min.js`|
|Chart.js|`https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js`|
|Tailwind|`https://cdn.tailwindcss.com/3.4.17`|
`https://unpkg.com/lucide@latest` dan `https://cdn.tailwindcss.com`
(tanpa versi) sama-sama **redirect 302** tiap load → di-pin ke URL final.
- **Bug:** service worker hanya cache-first `cdn.jsdelivr.net`, sehingga
Tailwind, FontAwesome, Google Fonts, dan Lucide **tidak tersedia saat
offline** — layout PWA hancur. Sekarang `sw.js` punya array `CDN_HOSTS`:
`cdn.jsdelivr.net`, `unpkg.com`, `cdnjs.cloudflare.com`,
`cdn.tailwindcss.com`, `fonts.googleapis.com`, `fonts.gstatic.com`.
- Cache bust: `CACHE_NAME` / `CDN_CACHE` `customer-pwa-v4` → **`-v5`**, plus
5 URL baru masuk `PRECACHE_URLS`.
### File disentuh
- `views/dashboard.ejs` (1481 baris)
- `public/sw.js` (173 baris)
### Verifikasi
- Render EJS OK, `div` seimbang `160/160`.
- Live `/customer/dashboard` HTTP 200, `div` seimbang `184/184`.
- Semua CDN membalas 200 tanpa redirect.
- `systemctl is-active billing-rtrw` = `active`, journal tanpa error.
### Rollback
```bash
cd /opt/billing-rtrw
cp views/dashboard.ejs.bak-twpin-225510 views/dashboard.ejs
cp public/sw.js.bak-twpin-225510 public/sw.js
sudo systemctl restart billing-rtrw
```
### Catatan
- Tampilan visual (sliding pill, animasi sheet, posisi icon) **belum dicek di
browser sungguhan** — verifikasi di atas semuanya server-side.
---
## [v1.8.2] — 2026-09-26 22:30 — Fix flash message toast
- `notif` dari server sebelumnya tidak tampil. Kini di-render sebagai variabel
`FLASH` (`{ text, type }`) lalu dimunculkan lewat `#toast-container`.
- Pemetaan tipe: `danger → error`, `warning → info`, selain itu `info`.
- Render diproteksi dari injeksi `</script>` via `.replace(/</g, '\u003c')`.
### File disentuh
- `views/dashboard.ejs`
### Rollback
```bash
cp views/dashboard.ejs.bak-flashfix-20260926221014 views/dashboard.ejs
sudo systemctl restart billing-rtrw
```
---
## [v1.8.1] — 2026-09-26 22:10 — Fix route `/customer/reboot` + ACS Connection Request 401
### 1. Route reboot
- `POST /customer/reboot` gagal auth karena hanya memakai `session.phone`
sebagai token device.
- Kini memakai `buildCustomerDeviceTokens()` sehingga mencoba seluruh kandidat
token (tag, username, serial, MAC, IP).
### 2. Connection Request 401 (fix kritis)
- ACS membalas **HTTP 401** saat perangkat menjalankan Connection Request karena
`ConnectionRequestUsername` / `ConnectionRequestPassword` belum diset.
- Diperbaiki dengan `provisionConnectionRequestAuth(deviceId)` di
`services/acsServerService.js`, dipanggil dari `handleInform()`.
- Alur: Basic challenge → 401 → **Digest auth → HTTP 200**.
- Default username `skyfiber`, password = `crypto.randomBytes(12)` hex per
perangkat, disimpan di database.
- Berlaku hanya untuk perangkat yang sedang online; 3 perangkat offline akan
ter-provisioning otomatis saat melakukan Inform.
### Hasil
- 102/105 perangkat sukses di-provisioning, 0 provisioning task gagal.
- Perubahan SSID/reboot kini berlaku ~10 detik (sebelumnya gagal).
- 3 perangkat offline akan ter-provisioning otomatis saat Inform berikutnya.
### File disentuh
- `routes/customerPortal.js` (3967 baris)
- `services/acsServerService.js` (1711 baris)
### Rollback
```bash
cd /opt/billing-rtrw
cp routes/customerPortal.js.bak-rebootfix-20260926221014 routes/customerPortal.js
cp services/acsServerService.js.bak-crfix-20260926221014 services/acsServerService.js
sudo systemctl restart billing-rtrw
```
---
## [v1.8.0] — 2026-09-26 18:04–21:07 — Redesign dashboard (APPS-style)
Redesain tampilan dashboard pelanggan dengan gaya APPS.
### Yang ditambahkan
- **Banner promo** dari database (`GET /customer/api/promo-slides`).
- **Chart pemakaian isolate-day** (maks. 3 bulan riwayat), menggantikan
polling traffic realtime yang sebelumnya membebani server.
Helper: `buildUsageSummary(profile, activePackage)`.
- **LibreSpeed** speedtest inline (sub-router `section-speedtest`).
- **Quick-action Reconnect** (`POST /customer/customer/reconnect`) dan
**Poll ONU** (`POST /customer/api/device/refresh`).
- Sub-tab router: **WiFi**, **Speedtest**, **Pemakaian**.
- Tampilan AXS/PPOB (`/customer/ppob`, `/customer/topup`).
- Link WhatsApp admin otomatis ke nomor pertama di `settings.whatsapp_admin_numbers`.
- Tema persisten via `localStorage`.
- Tombol toggle lihat IP (`isIpVisible`).
### Perbaikan
- Semua endpoint palsu `/api/customer/*` diarahkan ke route `/customer/*` asli.
- Badge **GPON**: merah `Bad GPON` bila ONU tidak terdeteksi **atau** PPPoE offline.
- Form WiFi/ONT otomatis disabled bila ONU absent, dengan pesan penjelasan.
- Variabel yang dihitung defensif di ketiga cabang render:
`hasUnpaid`, `latestUnpaid`, `latestInvoice`, `pppoeIP`, `pppoeUptime`,
`stMaxMbps`.
- Penghapusan modal Bootstrap yang rusak.
- Layout mobile dirapikan.
### File disentuh
- `views/dashboard.ejs`
### Rollback
```bash
cp views/dashboard.ejs.bak-prefn-20260926210734 views/dashboard.ejs
# atau ke markup/endpoint stabil terakhir:
cp views/dashboard.ejs.bak-pre-newdesign-20260926181219 views/dashboard.ejs
sudo systemctl restart billing-rtrw
```
---
## [v1.7.0] — 2026-09-26 12:55–14:24 — Self-registration, tabular layout, PWA
- **Self-registration** (`/customer/register`) + pairing tag perangkat
(`POST /customer/change-tag`).
- Tata letak tabel Payments/Fitur dikembalikan ke mode tabular
(`.bak-tabrapikan`, `.bak-tabcss`).
- Toggle lihat/sembunyikan password WiFi.
- Tampilan jatuh tempo (due) tagihan.
- Struktur `div` diperbaiki (tag tidak seimbang) — `.bak-divfix`.
- Navbar sticky di mobile — `.bak-sticky`.
- **Service worker** diperbaiki & di-cache ulang — `.bak-swfix`, `sw3`.
- **`rxPower` ACS** ditangani di `services/acsServerService.js`.
### File disentuh
- `views/dashboard.ejs`, `routes/customerPortal.js`, `public/sw.js`,
`services/acsServerService.js`
### Rollback
```bash
cd /opt/billing-rtrw
cp views/dashboard.ejs.bak-divfix-20260926142250 views/dashboard.ejs
cp public/sw.js.bak-swfix-20260926142327 public/sw.js
```
---
## Daftar endpoint yang dipakai dashboard
Semua Calls dari `views/dashboard.ejs` (sudah diverifikasi terhadap router):
| Aksi | Method | Endpoint |
|---|---|---|
| Promo slides | GET | `/customer/api/promo-slides` |
| Poll ONU / refresh | POST | `/customer/api/device/refresh` |
| Reconnect | POST | `/customer/customer/reconnect` |
| Ganti SSID | POST | `/customer/change-ssid` |
| Ganti password | POST | `/customer/change-password` |
| Reboot | POST | `/customer/reboot` |
| Buat tiket (+foto) | POST | `/customer/tickets/create` (multipart) |
| Bayar invoice | GET | `/customer/payment/create/:id` |
| PPOB | GET | `/customer/ppob` |
| Top up | GET | `/customer/topup` |
| Logout | GET | `/customer/logout` |
> Catatan: **tidak ada** route `/customer/poll-onu`. Tombol "Poll ONU"
> memanggil `POST /customer/api/device/refresh`.
---
## TODO / tindak lanjut
Detail lengkap ada di `/home/oxpoe/Documents/CATATAN-OPS-SKYFIBER.txt`
section 11 (mode `600`, jangan dibagikan).
### Keamanan — perlu tindakan
- [ ] Rotasi `telegram_bot_token` (via `@BotFather` → `/revoke`; token **baru**
jangan dikirim ke chat mana pun).
- [ ] Rotasi `admin_password`.
- [ ] Rotasi `admin_api_key`.
- [ ] Amankan backup `backups/settings_20260926_211113.json` (masih memuat
token lama) atau hapus setelah rotasi.
- [ ] Inisialisasi Git (`git init` + commit awal) supaya changelog berikutnya
punya riwayat & diff.
### Fungsional
- [ ] **Password WiFi pelanggan uji masih nilai sementara** dan harus
dikembalikan ke nilai asli (nilai uji tercatat di notes section 11).
- [ ] 3 perangkat belum punya CR credentials, akan provisioning otomatis saat
online:
- `001141-CL601-ZTEGCBBFB774`
- `B4E46B-SIGMA Z99LX XPON-ZICG27766207`
- `B4E46B-SIGMA Z99LX XPON-ZICG27766F6A`
- [ ] Alur pembayaran belum diuji end-to-end (butuh invoice uji).
- [ ] Cek visual floating dock di browser/HP sungguhan.
- [ ] Test PWA mode offline (SW v5) — belum pernah diuji.
- [ ] Keputusan: apakah FUP enforcement mengikuti isolate-day.
- [ ] Parity peta admin desktop vs mobile.
- [ ] Pemetaan kecepatan WiFi 2.4GHz vs 5GHz.
---
## Cara rollback umum
```bash
cd /opt/billing-rtrw
sudo systemctl stop billing-rtrw
# salin backup yang dikehendali ke file aslinya
sudo systemctl start billing-rtrw
sudo journalctl -u billing-rtrw -n 50 --no-pager
```
Backup tersedia (format `.bak-<label>-<timestamp>`):
| File | Backup |
|---|---|
| `views/dashboard.ejs` | `.bak-rxpower`, `.bak-selfapp-*`, `.bak-akun-*`, `.bak-due-*`, `.bak-tabcss-*`, `.bak-usage-ui-*`, `.bak-togglepass-*`, `.bak-tabrapikan-*`, `.bak-mobile-*`, `.bak-usageview-*`, `.bak-usagehelper-*`, `.bak-tailadmin-*`, `.bak-divfix-*`, `.bak-sticky-*`, `.bak-prefn-*`, `.bak-pre-newdesign-*`, `.bak-flashfix-*`, `.bak-dock-*`, `.bak-cdnpin-*`, `.bak-twpin-*` |
| `routes/customerPortal.js` | `.bak-selfapp-*`, `.bak-usageview-*`, `.bak-usagehelper-*`, `.bak-akun-*`, `.bak-rebootfix-*` |
| `services/acsServerService.js` | `.bak-rxpower-*`, `.bak-crfix-*` |
| `public/sw.js` | `.bak-selfapp-*`, `.bak-sw3-*`, `.bak-swfix-*`, `.bak-v4-*`, `.bak-twpin-*` |
---
## [v1.9.8] - 2026-09-28: Rombak Total UI/UX Multi-Portal (5-Color Modern Design System)
### Ringkasan Rilis
Rombak visual dan interaksi total pada seluruh portal aplikasi (Landing Page/Pelanggan, Customer PWA Dashboard, Admin Portal, Teknisi, Kolektor, dan Agen) menggunakan palette 5-warna konsisten, tipografi Google Fonts universal, elemen cyber-glassmorphism terinspirasi dari uiverse.io, dan panel KPI dense terinspirasi dari Gentelella.
### Design System & Palette
- **Dark 1 (Latar Utama)**: `#161A21`
- **Dark 2 (Gelap Sedang / Surfaces & Cards)**: `#1F2420`
- **Dark 3 (Abu-abu Gelap / Aksesori & Borders)**: `#2A313C`
- **Accent Lime (Aksen Hijau Neon)**: `#ADFF2F`
- **Light (Putih Terang / Teks)**: `#F2F5F1`
- **Typography**:
- `Plus Jakarta Sans`: Landing, PWA Pelanggan, Heading & Kartu
- `Inter`: Admin tabel, form controls, dense grid
- `JetBrains Mono`: Angka teknis (IP PPPoE, MAC, Serial, RX/TX Optical Power, Speed, Bandwidth)
### Modul yang Dirombak
1. **Landing Page & Customer Login (`views/login.ejs`)**:
- Cyber-glassmorphism card hero, paket internet (`#packages`), fitur (`#features`), layanan (`#services`), contact.
- PWA Install Modal dengan status online/offline otomatis.
2. **Halaman Isolir / Walled Garden (`views/isolated.ejs`)**:
- Palette `#161A21`, `#1F2420`, `#2A313C`, dan aksen neon `#ADFF2F`.
3. **Customer PWA Dashboard (`views/dashboard.ejs` & `views/partials/customer_bottom_nav.ejs`)**:
- Floating Glass Dock 5-Tab (`#1F2420` glass) dengan sliding active pill neon lime (`#ADFF2F`).
- Kartu status PPPoE/ONU dengan indikator neon pulse, telemetry optik, ganti SSID/password, speedtest gauge, dan Chart.js riwayat pemakaian.
4. **Admin Portal (`public/css/admin.css` & `views/admin/*`)**:
- Update CSS variables: `--bg: #161A21`, `--bg2: #1F2420`, `--bg3: #2A313C`, `--primary: #ADFF2F`, `--text: #F2F5F1`.
- Sidebar Gentelella modern dengan indikator aktif neon lime.
- KPI Tile Stats (`.tile_stats_count`) pada Dashboard, Customers, dan Billing.
- Perbaikan dark theme pada `views/admin/olts.ejs`, `monitoring.ejs`, `settings.ejs`, dan `login.ejs`.
5. **Technician Portal (`views/tech/*`)**:
- `views/tech/partials/header.ejs`, `views/tech/partials/bottom_nav.ejs`, `views/tech/dashboard.ejs`, `monitoring.ejs`, `attendance.ejs`, `map.ejs`, dan `login.ejs`.
6. **Collector & Agent Portals (`views/collector/*`, `views/agent/*`, `public/css/theme.css`)**:
- `public/css/theme.css` sebagai global theme provider untuk tema gelap 5-warna.
- Modernisasi login dan dashboard collector & agent.
### Verifikasi & Integritas
- 88 dari 88 template EJS aktif berhasil dikompilasi dengan `ejs.compile` (0 error).
- Seluruh endpoint HTTP menghasilkan kode `200 OK` / valid redirect.
- Database SQLite integrity check: `ok`.
- Service `billing-rtrw.service` berjalan stabil di Systemd VPS Deneva.
---
## [v1.9.9] - 2026-09-28: Penghapusan Total Komponen Light Mode (Konsistensi Eksklusif Halogen Kit)
### Ringkasan Perubahan
Menghapus seluruh komponen *light mode*, tombol pengalih tema (*theme toggle button / FAB*), script peralihan mode terang, dan aturan CSS override di semua portal aplikasi. Seluruh aplikasi kini konsisten 100% berjalan dalam **Mode Gelap Halogen Kit**:
1. **`public/js/theme.js`**: Menghapus fungsi injeksi tombol floating tema (`injectThemeFab`), fungsi `toggleAppTheme`, dan memaksa `data-theme=dark` di localStorage secara permanen.
2. **`public/css/theme.css`**: Menghapus seluruh blok aturan `html[data-theme=light]` dan styling tombol mengambang `.theme-fab`.
3. **Admin Sidebar (`views/admin/partials/sidebar.ejs`)`**: Menghapus tombol toggle tema pada bagian brand/header sidebar dan menyederhanakan inisialisasi dark mode.
4. **Customer PWA Dashboard (`views/dashboard.ejs`)`**: Menghapus tombol pengalih tema (`#theme-toggle-btn`) pada header topbar, menghapus fungsi `toggleTheme()` / `applyTheme()`, dan membersihkan seluruh override class CSS `html.light`.
5. **Verifikasi**: Seluruh 88 template EJS tervalidasi 0 error dan service berjalan stabil.
---
## [v1.9.9] - 2026-09-28: Penghapusan Total Komponen Light Mode (Konsistensi Eksklusif Halogen Kit)
### Ringkasan Perubahan
Menghapus seluruh komponen *light mode*, tombol pengalih tema (*theme toggle button / FAB*), script peralihan mode terang, dan aturan CSS override `html.light` di semua portal aplikasi. Seluruh aplikasi kini konsisten 100% berjalan dalam **Mode Gelap Halogen Kit**:
1. **`public/js/theme.js`**: Menghapus fungsi injeksi tombol floating tema (`injectThemeFab`), fungsi `toggleAppTheme`, dan memaksa `data-theme="dark"` di localStorage secara permanen.
2. **`public/css/theme.css`**: Menghapus seluruh blok aturan `html[data-theme="light"]` dan styling tombol mengambang `.theme-fab`.
3. **Admin Sidebar (`views/admin/partials/sidebar.ejs`)**: Menghapus tombol toggle tema pada bagian brand/header sidebar dan menyederhanakan inisialisasi dark mode.
4. **Customer PWA Dashboard (`views/dashboard.ejs`)**: Menghapus tombol pengalih tema (`#theme-toggle-btn`) pada header topbar, menghapus fungsi `toggleTheme()` / `applyTheme()`, dan membersihkan seluruh override class CSS `html.light`.
5. **Verifikasi**: Seluruh 88 template EJS tervalidasi 0 error dan service berjalan stabil.

View File

@@ -14,7 +14,7 @@ let TARGET = process.env.ISOLIR_REDIRECT_URL || '';
// Coba ambil target dari settings.json bila tersedia
try {
const { getSetting } = require('/opt/billing-rtrw/config/settingsManager');
const { getSetting } = require('./config/settingsManager');
const s = getSetting('isolir_portal_url', '') || '';
if (s) TARGET = s;
} catch (_) { /* abaikan */ }
@@ -37,6 +37,8 @@ const server = http.createServer((req, res) => {
);
});
server.on('error', (e) => console.error('[isolir-redirect] listen error:', e.message));
server.listen(PORT, '0.0.0.0', () => {
console.log(`[isolir-redirect] listening on 0.0.0.0:${PORT} -> ${TARGET}`);
});

View File

@@ -189,7 +189,7 @@
"cashiers": "Cashiers",
"agents": "Agents",
"agent_reports": "Agent Reports",
"update": "Update GitHub",
"update": "Update Gitea",
"settings": "Settings",
"cashiers_reports": "Cashier Reports",
"collector_payments": "Collector Approval",
@@ -1052,7 +1052,7 @@
"cashiers": "Cashiers",
"agents": "Agents",
"agent_reports": "Agent Reports",
"update": "GitHub Update",
"update": "Gitea Update",
"settings": "Settings",
"automation": "Automation",
"network": "Network",
@@ -1065,18 +1065,18 @@
"invoices_list": "Invoices"
},
"update": {
"title": "GitHub Update",
"title": "Gitea Update",
"status_title": "Version Status",
"server_version": "Server Version (version.txt)",
"github_version_prefix": "GitHub Version (origin/",
"github_version_suffix": ")",
"gitea_version_prefix": "Gitea Version (origin/",
"gitea_version_suffix": ")",
"has_update": "Update available",
"latest": "Up to date",
"check": "Check Version",
"run": "Update Now",
"keep_files": "Files not overwritten during update: settings.json and database folder. After update completes, restart the app to activate the new version.",
"log_title": "Update Log",
"confirm": "Run update from GitHub now? Make sure the internet connection is stable and the app will be restarted after completion."
"confirm": "Run update from Gitea now? Make sure the internet connection is stable and the app will be restarted after completion."
},
"acs": {
"monitoring": "GenieACS Monitoring",
@@ -1888,7 +1888,7 @@
"speed_mode": "Display Mode on Customer Dashboard",
"sp_mode_iframe": "Embed / iframe — for providers that allow it",
"sp_mode_tab": "New Tab — for providers that block iframes",
"repo_link": "Project Repository Link (GitHub)",
"repo_link": "Project Repository Link (Gitea)",
"uptime_link": "Website Uptime Link (Status)",
"qris_status": "Static QRIS Status",
"qris_url": "QRIS Static QR URL",
@@ -3447,8 +3447,8 @@
"check": "Check Bill",
"server_status": "Server Status",
"status": "Status",
"github": "GitHub",
"repo_gh": "Repository (GitHub)",
"gitea": "Gitea",
"repo_gitea": "Repository (Gitea)",
"area_nav": "Service Area",
"testi_nav": "Testimonials"
},

View File

@@ -189,7 +189,7 @@
"cashiers": "Kasir",
"agents": "Agent",
"agent_reports": "Laporan Agent",
"update": "Update GitHub",
"update": "Update Gitea",
"settings": "Pengaturan",
"cashiers_reports": "Laporan Kasir",
"collector_payments": "Approval Kolektor",
@@ -1052,7 +1052,7 @@
"cashiers": "Kasir",
"agents": "Agent",
"agent_reports": "Laporan Agent",
"update": "Update GitHub",
"update": "Update Gitea",
"settings": "Pengaturan",
"automation": "Automation",
"network": "Jaringan",
@@ -1065,18 +1065,18 @@
"invoices_list": "Data Tagihan"
},
"update": {
"title": "Update GitHub",
"title": "Update Gitea",
"status_title": "Status Versi",
"server_version": "Versi Server (version.txt)",
"github_version_prefix": "Versi GitHub (origin/",
"github_version_suffix": ")",
"gitea_version_prefix": "Versi Gitea (origin/",
"gitea_version_suffix": ")",
"has_update": "Ada update",
"latest": "Terbaru",
"check": "Cek Versi",
"run": "Update Sekarang",
"keep_files": "File yang tidak ditimpa saat update: settings.json dan folder database. Setelah update selesai, restart aplikasi agar versi baru aktif.",
"log_title": "Log Update",
"confirm": "Jalankan update dari GitHub sekarang? Pastikan koneksi internet aman dan aplikasi akan direstart setelah selesai."
"confirm": "Jalankan update dari Gitea sekarang? Pastikan koneksi internet aman dan aplikasi akan direstart setelah selesai."
},
"acs": {
"monitoring": "Monitoring GenieACS",
@@ -1889,7 +1889,7 @@
"speed_mode": "Mode Tampilan di Dashboard Pelanggan",
"sp_mode_iframe": "Embed / iframe — untuk provider yang mengizinkan",
"sp_mode_tab": "Buka Tab Baru — untuk provider yang memblokir iframe",
"repo_link": "Link Repositori Proyek (GitHub)",
"repo_link": "Link Repositori Proyek (Gitea)",
"uptime_link": "Link Uptime Website (Status)",
"qris_status": "Status QRIS Statis",
"qris_url": "QRIS Static QR URL",
@@ -3448,8 +3448,8 @@
"check": "Cek Tagihan",
"server_status": "Status Server",
"status": "Status",
"github": "GitHub",
"repo_gh": "Repositori (GitHub)",
"gitea": "Gitea",
"repo_gitea": "Repositori (Gitea)",
"area_nav": "Area Layanan",
"testi_nav": "Testimoni"
},

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "open-isp",
"version": "0.5.1",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-isp",
"version": "0.5.1",
"version": "0.5.2",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "open-isp",
"version": "0.5.1",
"description": "OPEN-ISP — sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"version": "0.5.3",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js",
"scripts": {
"start": "node app-customer.js",
@@ -50,12 +50,12 @@
},
"author": "oxpoe",
"license": "ISC",
"homepage": "https://github.com/oxpoe/open-isp#readme",
"homepage": "https://gitea.oxpoe.qzz.io/oxpoe/open-isp#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/oxpoe/open-isp.git"
"url": "git+https://gitea.oxpoe.qzz.io/oxpoe/open-isp.git"
},
"bugs": {
"url": "https://github.com/oxpoe/open-isp/issues"
"url": "https://gitea.oxpoe.qzz.io/oxpoe/open-isp/issues"
}
}

View File

@@ -1052,6 +1052,7 @@ router.get('/', async (req, res) => {
model: d._deviceId?._ProductClass || '-',
rx_power: rxPower,
pppoe_ip: ip,
ip: ip,
pppoe_user: pppoeUser,
customer_name: customerName,
acs_server_id: server.id,
@@ -1317,7 +1318,7 @@ router.put('/api/servers/legacy', requireAdmin, express.json(), async (req, res)
if (clearPassword) currentSettings.genieacs_password = '';
else if (String(passwordInput || '').trim()) currentSettings.genieacs_password = String(passwordInput || '');
const settingsPath = path.join(__dirname, '../settings.json');
const settingsPath = require('../config/paths').settingsPath;
fs.writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), 'utf8');
res.json({ success: true, message: 'Default ACS berhasil diperbarui.' });

View File

@@ -56,6 +56,8 @@ const promoUpload = multer({
const backupSvc = require('../services/backupService');
const monitoringSvc = require('../services/monitoringService');
const inventorySvc = require('../services/inventoryService');
const securityWatchdog = require('../services/securityWatchdogService');
const sessionLimit = require('../services/sessionLimitService');
const auditSvc = require('../services/auditTrailService');
const whatsappService = require('../services/whatsappService');
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
@@ -384,7 +386,9 @@ function getUpdateInfo(repoRoot) {
const inside = runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot);
if (!inside.ok) {
info.error = 'Folder ini belum menjadi git repository.';
// Tanpa .git (Docker/container): update dilakukan via rebuild image (bukan git)
info.container = true;
info.containerMessage = 'Instalasi ini berjalan sebagai kontainer/gambar (tanpa .git). Pembaruan dilakukan dengan membangun ulang image dari repo terbaru.';
return info;
}
@@ -394,7 +398,7 @@ function getUpdateInfo(repoRoot) {
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
info.repo = _repo || 'origin';
const fetch = _repo
? runCmd('git', ['fetch', '--prune', 'https://github.com/' + _repo + '.git', branch], repoRoot)
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
: runCmd('git', ['fetch', '--prune'], repoRoot);
if (!fetch.ok) {
info.error = 'Gagal git fetch: ' + (fetch.stderr || fetch.stdout || '').trim();
@@ -405,7 +409,7 @@ function getUpdateInfo(repoRoot) {
? runCmd('git', ['show', 'FETCH_HEAD:version.txt'], repoRoot)
: runCmd('git', ['show', `origin/${branch}:version.txt`], repoRoot);
if (!remote.ok) {
info.error = `Tidak bisa membaca version.txt dari GitHub (origin/${branch}).`;
info.error = `Tidak bisa membaca version.txt dari Gitea (origin/${branch}).`;
return info;
}
@@ -552,17 +556,38 @@ router.get('/login', (req, res) => {
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;
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.adminUser = username;
sessionLimit.registerLoginSession(req, {
role: 'admin', accountId: username, limit: 2,
ip, userAgent: req.get('user-agent')
});
return res.redirect('/admin');
}
// Check Cashier
const cashier = adminSvc.authenticateCashier(username, password);
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.cashierId = cashier.id;
req.session.cashierName = cashier.name;
@@ -570,10 +595,21 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }),
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' });
});
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 ────────────────────────────────────────────────────────
router.get('/olts', requireAdminSession, async (req, res) => {
@@ -4554,6 +4590,10 @@ router.get('/update', requireAdminSession, requireSidebarMenuAccess('update'), r
router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
const repoRoot = path.resolve(__dirname, '..');
if (!runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot).ok) {
req.session._msg = { type: 'error', text: 'Instalasi kontainer (tanpa .git): pembaruan dilakukan dengan rebuild image, bukan lewat git.' };
return res.redirect('/admin/update');
}
const log = [];
const pushCmd = (label, r) => {
log.push(`$ ${label}`.trim());
@@ -4628,7 +4668,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
info.repo = _repo || 'origin';
const fetch = _repo
? runCmd('git', ['fetch', '--prune', 'https://github.com/' + _repo + '.git', branch], repoRoot)
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
: runCmd('git', ['fetch', '--prune'], repoRoot);
pushCmd('git fetch --prune', fetch);
if (!fetch.ok) throw new Error('Gagal git fetch.');
@@ -4637,7 +4677,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
? runCmd('git', ['show', 'FETCH_HEAD:version.txt'], repoRoot)
: runCmd('git', ['show', `origin/${branch}:version.txt`], repoRoot);
pushCmd(`git show origin/${branch}:version.txt`, remote);
if (!remote.ok) throw new Error('Tidak bisa membaca version.txt dari GitHub.');
if (!remote.ok) throw new Error('Tidak bisa membaca version.txt dari Gitea.');
const remoteVersion = String(remote.stdout || '').trim() || '-';
if (remoteVersion !== '-' && remoteVersion === localBefore) {
@@ -4735,6 +4775,12 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
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;
else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false;
@@ -5090,7 +5136,8 @@ router.post('/backup/cleanup', requireAdminSession, express.urlencoded({ extende
router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => {
const items = inventorySvc.getAllItems(req.query.q);
const categories = inventorySvc.getAllCategories();
const logs = inventorySvc.getInventoryLogs(100);
const logs = inventorySvc.getInventoryLogs({ limit: 100 });
const stats = inventorySvc.getInventoryStats();
res.render('admin/inventory', {
title: 'Manajemen Inventaris',
@@ -5099,7 +5146,8 @@ router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventor
msg: flashMsg(req),
items,
categories,
logs
logs,
stats
});
});
@@ -5153,6 +5201,101 @@ router.post('/inventory/stock/add', requireAdminSession, express.urlencoded({ ex
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) => {
const filters = {
action: req.query.action || null,
@@ -5234,7 +5377,7 @@ router.post('/api/genieacs/settings', requireAdmin, async (req, res) => {
// Save to file
const fs = require('fs');
const path = require('path');
const settingsPath = path.join(__dirname, '../settings.json');
const settingsPath = require('../config/paths').settingsPath;
fs.writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), 'utf8');
// Log audit
@@ -6496,7 +6639,7 @@ router.get('/whatsapp/gowa/qr.png', requireAdminSession, async (req, res) => {
});
router.get('/whatsapp', requireAdminSession, requireSidebarMenuAccess('whatsapp'), async (req, res) => {
const waGatewayType = getSetting('wa_gateway_type', 'baileys');
const waGatewayType = getSetting('wa_gateway_type', 'gowa');
const metaSettings = {
meta_phone_number_id: getSetting('meta_phone_number_id', ''),
meta_waba_id: getSetting('meta_waba_id', ''),
@@ -6611,7 +6754,7 @@ router.post('/whatsapp/test-fonnte', requireAdminSession, express.json(), async
// LIVE CHAT ROUTES
router.get('/whatsapp/live-chat', requireAdminSession, requireSidebarMenuAccess('whatsapp'), async (req, res) => {
const waGatewayType = getSetting('wa_gateway_type', 'baileys');
const waGatewayType = getSetting('wa_gateway_type', 'gowa');
const customers = customerSvc.getAllCustomers();
res.render('admin/whatsapp_live_chat', {
title: 'Live Chat WhatsApp',
@@ -7075,7 +7218,7 @@ router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res)
router.get('/api/whatsapp/status', requireAdmin, async (req, res) => {
try {
const gatewayType = getSetting('wa_gateway_type', 'baileys');
const gatewayType = getSetting('wa_gateway_type', 'gowa');
if (gatewayType === 'fonnte') {
const token = getSetting('fonnte_token', '');
res.json({
@@ -7784,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) => {
try {
payrollSvc.approveSlip(req.params.id);

View File

@@ -5,6 +5,8 @@ const { logger } = require('../config/logger');
const db = require('../config/database');
const billingSvc = require('../services/billingService');
const customerSvc = require('../services/customerService');
const securityWatchdog = require('../services/securityWatchdogService');
const sessionLimit = require('../services/sessionLimitService');
const adminSvc = require('../services/adminService');
const attendanceSvc = require('../services/attendanceService');
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 });
});
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 password = String(req.body.password || '');
const collector = adminSvc.authenticateCollector(username, password);
if (collector) {
await securityWatchdog.recordAppLoginAttempt({ type: 'collector', username, ip, success: true });
await sessionLimit.rotateSession(req);
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.collectorName = collector.name;
req.session.collectorUsername = collector.username;
req.session.collectorArea = collector.area || '';
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!' });
});
router.get('/logout', (req, res) => {
sessionLimit.removeLoginSession(req);
req.session.destroy();
res.redirect('/collector/login');
});
@@ -177,16 +202,10 @@ router.get('/', requireCollectorSession, (req, res) => {
const todayDay = now.getDate();
const collectorId = Number(req.session.collectorId || 0);
const collectorObj = db.prepare('SELECT area FROM collectors WHERE id = ?').get(collectorId);
const collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
req.session.collectorArea = collectorArea;
const collectorObj = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(collectorId);
req.session.collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
let collectorWhere = '(c.collector_id = ? OR c.collector_id IS NULL)';
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];
}
const { where: collectorWhere, params: collectorParams } = customerSvc.getCollectorCustomerWhere(collectorObj, 'c');
let q = `
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 (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(`
SELECT id FROM collector_payment_requests
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);
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();
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);
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 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 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 = `
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,
@@ -752,6 +759,11 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
`;
const params = [curMonth, curYear];
if (colFilter) {
q += ` AND ${colFilter.where}`;
params.push(...colFilter.params);
}
if (filterStatus === 'unpaid') {
q += ` AND (i.status = 'unpaid' OR i.status IS NULL)`;
} 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`;
const list = db.prepare(q).all(...params) || [];
const statsWhere = colFilter ? ` WHERE ${colFilter.where}` : '';
const stats = db.prepare(`
SELECT
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
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 = ?
`).get(todayDay, curMonth, curYear) || {};
${statsWhere}
`).get(todayDay, curMonth, curYear, ...(colFilter ? colFilter.params : [])) || {};
res.json({
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' });
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 isAutoApprove = collector && collector.auto_approve === 1;
@@ -2088,7 +2112,7 @@ router.get('/app/admin/olts', requireAdminApiAuth, (req, res) => {
// ─── ADMIN NATIVE: WHATSAPP STATUS ───────────────────────────────────────────
router.get('/app/admin/whatsapp/status', requireAdminApiAuth, async (req, res) => {
try {
const gwType = String(require('../config/settingsManager').getSetting('wa_gateway_type', 'baileys') || 'baileys').toLowerCase();
const gwType = String(require('../config/settingsManager').getSetting('wa_gateway_type', 'gowa') || 'baileys').toLowerCase();
let isConnected = false;
let waNumber = '-';
let waName = gwType.toUpperCase();
@@ -2190,7 +2214,7 @@ router.post('/app/admin/settings/update', requireAdminApiAuth, (req, res) => {
try {
const allowed = ['company_header', 'company_subheader', 'company_phone', 'company_address', 'timezone', 'qris_enabled', 'auto_isolate_enabled', 'webhook_secret'];
const updates = req.body || {};
const settingsPath = require('path').join(__dirname, '../settings.json');
const settingsPath = require('../config/paths').settingsPath;
const current = JSON.parse(require('fs').readFileSync(settingsPath, 'utf8'));
let changed = 0;
for (const key of allowed) {

View File

@@ -1,4 +1,5 @@
const express = require('express');
const sessionLimit = require('../services/sessionLimitService');
const router = express.Router();
const customerDevice = require('../services/customerDeviceService');
const { getSettingsWithCache, getNowLocal, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal } = require('../config/settingsManager');
@@ -1791,6 +1792,14 @@ router.post('/login', loginRateLimiter, async (req, res) => {
// --- DIRECT LOGIN ---
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.pppoe_username = pppoeUsername; // PPPoE username untuk GenieACS & MikroTik
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 });
});
router.post('/login-otp', loginRateLimiter, (req, res) => {
router.post('/login-otp', loginRateLimiter, async (req, res) => {
const { otp } = req.body;
const settings = getSettingsWithCache();
const pending = req.session.pending_login;
@@ -1824,6 +1833,15 @@ router.post('/login-otp', loginRateLimiter, (req, res) => {
if (otp === pending.otp) {
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.pppoe_username = pending.pppoeUsername; // PPPoE username untuk GenieACS & MikroTik
delete req.session.pending_login;
@@ -2591,6 +2609,7 @@ router.post('/change-tag', async (req, res) => {
});
router.post('/logout', (req, res) => {
sessionLimit.removeLoginSession(req);
req.session.destroy(() => {
res.redirect('/customer/login');
});

View File

@@ -1,6 +1,8 @@
const express = require('express');
const router = express.Router();
const techSvc = require('../services/techService');
const securityWatchdog = require('../services/securityWatchdogService');
const sessionLimit = require('../services/sessionLimitService');
const customerSvc = require('../services/customerService');
const odpSvc = require('../services/odpService');
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 });
});
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 tech = techSvc.authenticate(username, password);
if (tech) {
await securityWatchdog.recordAppLoginAttempt({ type: 'technician', username, ip, success: true });
await sessionLimit.rotateSession(req);
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.techName = tech.name;
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!' });
});
router.get('/logout', (req, res) => {
sessionLimit.removeLoginSession(req);
req.session.destroy();
res.redirect('/tech/login');
});
@@ -176,7 +201,7 @@ router.get('/', requireTechSession, (req, res) => {
// --- OPEN TICKETS (Pool) ---
router.get('/pool', requireTechSession, (req, res) => {
const openTickets = techSvc.getOpenTickets();
const openTickets = techSvc.getOpenTickets(req.session.techId);
res.render('tech/pool', {
title: 'Tiket Baru',
company: company(),
@@ -201,8 +226,15 @@ router.get('/history', requireTechSession, (req, res) => {
// --- NETWORK MAP ---
router.get('/map', requireTechSession, (req, res) => {
const customers = customerSvc.getAllCustomers();
const odps = odpSvc.getAllOdps();
const tech = techSvc.getTechById(req.session.techId);
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', {
title: 'Peta Jaringan',
@@ -346,7 +378,12 @@ router.get('/monitoring', requireTechSession, async (req, res) => {
// --- CREATE CUSTOMER (Technician) ---
router.get('/customers/new', requireTechSession, (req, res) => {
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 olts = oltSvc.getAllOlts();
res.render('tech/create_customer', {
@@ -366,8 +403,20 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
const name = String(req.body.name || '').trim();
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 = {
name,
area: areaInput,
phone: String(req.body.phone || '').trim(),
email: String(req.body.email || '').trim(),
nik: String(req.body.nik || '').trim(),
@@ -461,6 +510,16 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
// API Endpoints for Technician
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) => {
try {
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) => {
try {
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 byTag = new Map();
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 tg = String(c.genieacs_tag || '').trim();
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) {
const s = search.toLowerCase();
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) => {
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);
if (!data || data.status === 'Tidak ditemukan') return res.status(404).json({ error: 'Device not found' });
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) => {
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
const { ssid } = req.body;
if (!ssid) return res.status(400).json({ error: 'SSID required' });
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) => {
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
const { password } = req.body;
if (!password || password.length < 8) return res.status(400).json({ error: 'Password minimal 8 karakter' });
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) => {
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);
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

@@ -11,7 +11,7 @@ const { getSetting, getCurrentDateInTimezone, getNowLocalISO } = require('../con
const projectRoot = path.join(__dirname, '..');
const backupDir = path.join(projectRoot, 'backups');
const dbPath = path.join(projectRoot, 'database', 'billing.db');
const settingsPath = path.join(projectRoot, 'settings.json');
const { settingsPath } = require('../config/paths');
// Pastikan direktori backup ada
if (!fs.existsSync(backupDir)) {

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'
`).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) {
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) {}

View File

@@ -62,7 +62,7 @@ async function runAutoBillingReminder() {
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: 'disabled' };
const gatewayType = getSetting('wa_gateway_type', 'baileys');
const gatewayType = getSetting('wa_gateway_type', 'gowa');
const waSvc = require('./whatsappService');
if (gatewayType === 'meta') {
@@ -808,6 +808,33 @@ function startCronJobs() {
} catch (e) { logger.error('[CRON] Retensi notif webhook error: ' + e.message); }
}, { 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.');
}

View File

@@ -71,6 +71,59 @@ function calculateExpiredAt(startDateStr, durationDays = 30) {
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 = {}) {
const now = getCurrentDateInTimezone();
const month = now.getMonth() + 1;
@@ -955,7 +1008,7 @@ Dihapus: ${removed.join(', ') || '-'}`);
}
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,
suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath,
resetPromoCyclesUsed, getEffectiveRouterId

View File

@@ -17,6 +17,7 @@ async function checkDependencies() {
mikrotik: [],
genieacs: { status: 'unknown', message: '' },
whatsapp: { status: 'unknown', message: '' },
telegram: { status: 'unknown', message: '' },
paymentGateways: [],
timestamp: new Date().toISOString()
};
@@ -104,39 +105,54 @@ async function checkDependencies() {
}
}
// 3. Check WhatsApp Gateway (using whatsappStatus from whatsappBot)
// 3. Check WhatsApp Gateway (GoWA / Baileys sesuai setting wa_gateway_type)
try {
// Import whatsappStatus dynamically to get real-time status
const whatsappBotModule = await import('./whatsappBot.mjs');
const waStatus = whatsappBotModule.whatsappStatus;
// Check connection status
// Possible values: 'connecting', 'qr', 'open', 'loggedOut', 'close'
const isOnline = waStatus.connection === 'open';
const isQR = waStatus.connection === 'qr';
const isLoggedOut = waStatus.connection === 'loggedOut';
let message = 'WhatsApp is disconnected';
if (isOnline) {
const phone = waStatus.user?.id ? String(waStatus.user.id).split(':')[0] : 'Unknown';
message = `WhatsApp connected (${phone})`;
} else if (isQR) {
message = 'WhatsApp waiting for QR scan';
} else if (isLoggedOut) {
message = 'WhatsApp logged out - scan QR again';
const { getSetting } = require('../config/settingsManager');
const gw = String(getSetting('wa_gateway_type', 'gowa') || 'baileys').toLowerCase();
if (gw === 'gowa') {
const base = String(getSetting('gowa_base_url', 'http://127.0.0.1:3000') || '').replace(/\/+$/, '');
const u = getSetting('gowa_username', '');
const p = getSetting('gowa_password', '');
const cfg = { timeout: 6000, validateStatus: (s) => s < 500 };
if (u && p) cfg.auth = { username: u, password: p };
const resp = await axios.get(`${base}/app/status`, cfg);
const res = (resp.data && resp.data.results) || {};
const isOnline = !!res.is_connected;
results.whatsapp = {
status: isOnline ? 'online' : 'offline',
message: isOnline ? `GoWA connected (${res.jid || res.device_id || '-'})` : 'GoWA tidak terhubung'
};
} else {
message = `WhatsApp ${waStatus.connection || 'disconnected'}`;
const whatsappBotModule = await import('./whatsappBot.mjs');
const waStatus = whatsappBotModule.whatsappStatus;
const isOnline = waStatus && waStatus.connection === 'open';
const isQR = waStatus && waStatus.connection === 'qr';
const isLoggedOut = waStatus && waStatus.connection === 'loggedOut';
let message = 'WhatsApp is disconnected';
if (isOnline) { const phone = waStatus.user && waStatus.user.id ? String(waStatus.user.id).split(':')[0] : 'Unknown'; message = `WhatsApp connected (${phone})`; }
else if (isQR) message = 'WhatsApp waiting for QR scan';
else if (isLoggedOut) message = 'WhatsApp logged out - scan QR again';
else message = `WhatsApp ${(waStatus && waStatus.connection) || 'disconnected'}`;
results.whatsapp = { status: isOnline ? 'online' : 'offline', message };
}
results.whatsapp = {
status: isOnline ? 'online' : 'offline',
message: message
};
} catch (err) {
results.whatsapp = {
status: 'offline',
message: `WhatsApp check error: ${err.message}`
};
results.whatsapp = { status: 'offline', message: `WhatsApp check error: ${err.message}` };
}
// 4. Check Telegram Bot
try {
const { getSetting } = require('../config/settingsManager');
const enabled = !!getSetting('telegram_enabled', false);
const token = String(getSetting('telegram_bot_token', '') || '').trim();
if (!enabled || !token) {
results.telegram = { status: 'offline', message: !enabled ? 'Telegram dinonaktifkan' : 'Token belum diisi' };
} else {
const resp = await axios.get(`https://api.telegram.org/bot${token}/getMe`, { timeout: 6000, validateStatus: (s) => s < 500 });
if (resp.data && resp.data.ok) results.telegram = { status: 'online', message: 'Terhubung sebagai @' + ((resp.data.result && resp.data.result.username) || '-') };
else results.telegram = { status: 'offline', message: (resp.data && resp.data.description) || 'getMe gagal' };
}
} catch (err) {
results.telegram = { status: 'offline', message: `Telegram check error: ${err.message}` };
}
return results;

View File

@@ -111,7 +111,7 @@ async function sendFonnteMessage(toPhone, messageText, options = {}) {
* Test Koneksi / Kirim Pesan Uji Coba Fonnte
*/
async function testFonnteConnection(targetPhone, customMessage) {
const msg = customMessage || '🔔 *Tes Koneksi Fonnte WhatsApp Gateway Berhasil!*\n\nSistem Billing RTRW Net siap mengirimkan notifikasi otomatis melalui gateway Fonnte.';
const msg = customMessage || '🔔 *Tes Koneksi Fonnte WhatsApp Gateway Berhasil!*\n\nOPEN-ISP siap mengirimkan notifikasi otomatis melalui gateway Fonnte.';
return await sendFonnteMessage(targetPhone, msg);
}

View File

@@ -1,42 +1,111 @@
/**
* Service: Manajemen Inventaris / Gudang (Warehouse)
* Sistem Pelacakan Stok, Pemasukan, Pengeluaran & Sisa Stok Terintegrasi
*/
const db = require('../config/database');
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 ───────────────────────────────────────────────────────────
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) {
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) {
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) {
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);
}
// ─── 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 ───────────────────────────────────────────────────────────────
function getAllItems(search = '') {
function getAllItems(search = '', categoryId = null) {
seedDefaultCategories();
let query = `
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,
(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 = '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
LEFT JOIN inventory_categories c ON i.category_id = c.id
WHERE 1=1
`;
const params = [];
if (categoryId) {
query += ' AND i.category_id = ?';
params.push(categoryId);
}
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}%`;
params.push(s, s, s);
params.push(s, s, s, s);
}
query += ' ORDER BY i.name ASC';
return db.prepare(query).all(...params);
@@ -44,35 +113,495 @@ function getAllItems(search = '') {
function getItemById(id) {
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
LEFT JOIN inventory_categories c ON i.category_id = c.id
WHERE i.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) {
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(`
INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description)
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) {
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(`
UPDATE inventory_items
SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ?
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) {
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);
}
// ─── 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) {
return db.prepare(`
SELECT s.*, cust.name as customer_name
@@ -83,78 +612,15 @@ function getStockByItem(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 = '') {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
if (!stock) throw new Error('Stock tidak ditemukan');
if (stock.status === 'assigned') throw new Error('Stock sudah terpasang di pelanggan lain');
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);
return issueStock({ item_id: stock.item_id, stock_id: stockId, customer_id: customerId, note }, actor);
}
function getLowStockItems() {
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
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'
@@ -164,8 +630,11 @@ function getLowStockItems() {
}
module.exports = {
seedDefaultCategories,
getAllCategories, createCategory, updateCategory, deleteCategory,
getAllItems, getItemById, createItem, updateItem, deleteItem,
getStockByItem, addStock, assignStockToCustomer, adjustStock,
getInventoryLogs, getLowStockItems
getAllItems, getItemById, getItemDetail, createItem, updateItem, deleteItem,
getAvailableSerialNumbers, getItemAvailableStock, getItemAssignedStock, getItemBrokenStock,
addStock, issueStock, adjustStock, returnStock,
getInventoryLogs, getInventoryStats,
getStockByItem, assignStockToCustomer, getLowStockItems
};

View File

@@ -1555,13 +1555,13 @@ async function generateIsolirPortalScript() {
let port = 443;
let isHttps = true;
try {
const u = new URL(normalized || 'http://127.0.0.1:4555');
const u = new URL(normalized || 'http://127.0.0.1:3001');
hostname = u.hostname;
port = u.port ? parseInt(u.port, 10) : (u.protocol === 'https:' ? 443 : 80);
isHttps = u.protocol === 'https:';
} catch {
hostname = 'GANTI-host-portal-billing';
port = 4555;
port = 3001;
isHttps = false;
}
@@ -1724,7 +1724,7 @@ async function setupIsolirFirewall(routerId = null) {
try {
const raw = String(settings.app_url || '').trim();
const normalized = raw && /^https?:\/\//i.test(raw) ? raw : (raw ? `https://${raw}` : '');
const u = new URL(normalized || 'http://127.0.0.1:4555');
const u = new URL(normalized || 'http://127.0.0.1:3001');
const isHttps = u.protocol === 'https:';
const port = u.port ? parseInt(u.port, 10) : (isHttps ? 443 : 80);
httpNatPort = isHttps ? 80 : port;
@@ -2042,7 +2042,7 @@ async function getBackup(routerId = null) {
rscLines.push(`# Router : ${routerName} (${routerHost})`);
rscLines.push(`# Tanggal : ${nowStr}`);
rscLines.push(`# Format : RouterOS Script (.rsc) ROS7 Ready`);
rscLines.push(`# Generator: Billing RTRW System`);
rscLines.push(`# Generator: OPEN-ISP Billing System`);
rscLines.push(`# ===================================================\n`);
// 1. IP Pools
@@ -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 = {
checkConnection,
getConnection,
@@ -2149,6 +2187,9 @@ module.exports = {
getHotspotProfiles,
getPppoeActive,
getActivePppoeSessionsMap,
getActivePppoeUsers,
isolateCustomerPppoe,
unisolateCustomerPppoe,
getAllActiveSessionsMap,
getHotspotActive,
getIpPools,

View File

@@ -435,6 +435,67 @@ function getAvailablePeriods() {
`).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 = {
EMPLOYEE_TYPES,
getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting,
@@ -442,7 +503,7 @@ module.exports = {
getTicketsResolvedCount, getCollectionAmount, getAttendanceData,
generateSlip, generateAllSlips,
getSlipById, getSlipsByPeriod, getSlipsByEmployee,
updateSlipDeductions, approveSlip, markSlipPaid,
updateSlipDeductions, updateSlip, approveSlip, markSlipPaid,
bulkApprove, bulkMarkPaid,
deleteSlip, deleteSlipsByPeriod,
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

@@ -74,7 +74,7 @@ const MENU_DEFINITIONS = [
] },
{ key: 'grp_system', section: 'system', href: '/admin/settings', icon: 'bi bi-gear', labelKey: 'admin.nav.grp_system', labelDefault: 'Sistem', roles: ['admin'], activePages: ['update', 'service_notice', 'settings', 'ewallet_logs', 'backup', 'audit_logs'], children: [
{ key: 'update', section: 'system', href: '/admin/update', icon: 'bi bi-cloud-arrow-down', labelKey: 'admin.nav.update', labelDefault: 'Update GitHub', roles: ['admin'], activePages: ['update'] },
{ key: 'update', section: 'system', href: '/admin/update', icon: 'bi bi-cloud-arrow-down', labelKey: 'admin.nav.update', labelDefault: 'Update Gitea', roles: ['admin'], activePages: ['update'] },
{ key: 'service_notice', section: 'system', href: '/admin/service-notice', icon: 'bi bi-megaphone-fill', labelKey: 'admin.nav.service_notice', labelDefault: 'Status Layanan', roles: ['admin'], activePages: ['service_notice'] },
{ key: 'settings', section: 'system', href: '/admin/settings', icon: 'bi bi-gear', labelKey: 'admin.nav.settings', labelDefault: 'Pengaturan', roles: ['admin'], activePages: ['settings'] },
{ key: 'ewallet_logs', section: 'system', href: '/admin/ewallet-logs', icon: 'bi bi-journal-text', labelKey: 'admin.nav.log_rekap', labelDefault: 'Log & Rekap', roles: ['admin'], activePages: ['ewallet_logs'] },

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);
}
/**
* 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) {
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 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 open = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
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 };
}
function getAssignedTickets(techId) {
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
JOIN customers c ON t.customer_id = c.id
WHERE t.technician_id = ? AND t.status != 'resolved'
@@ -30,19 +66,21 @@ function getAssignedTickets(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(`
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
JOIN customers c ON t.customer_id = c.id
WHERE t.status = 'open'
WHERE t.status = 'open' ${areaWhere}
ORDER BY t.created_at DESC
`).all();
`).all(...(filter && filter.isRestricted ? filter.params : []));
}
function getResolvedTickets(techId) {
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
JOIN customers c ON t.customer_id = c.id
WHERE t.technician_id = ? AND t.status = 'resolved'
@@ -52,11 +90,27 @@ function getResolvedTickets(techId) {
function takeTicket(ticketId, techId) {
let validTechId = Number(techId);
const exists = db.prepare('SELECT id FROM technicians WHERE id = ?').get(validTechId);
if (!exists) {
const fallback = db.prepare('SELECT id FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
if (fallback) validTechId = fallback.id;
let tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(validTechId);
if (!tech) {
const fallback = db.prepare('SELECT id, area FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
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);
return validTechId;
}
@@ -102,6 +156,8 @@ function createTechnician(data) {
module.exports = {
authenticate,
getTechById,
getTechAreaWhere,
canTechAccessCustomer,
getTechStats,
getAssignedTickets,
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) => {
if (!isAdmin(msg)) return;
const text = msg.text;
if (text === '/start' || text === '/menu') return; // Handled by onText
// Logika handle text manual jika diperlukan (misal untuk perintah kick/edit)
const text = String(msg.text || '').trim();
if (!text || text.startsWith('/')) return; // command ditangani onText
if (text === '/start' || text === '/menu') return;
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

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

@@ -8,7 +8,7 @@ const path = require('path');
const { getSetting } = require('../config/settingsManager');
function gateway() {
return String(getSetting('wa_gateway_type', 'baileys') || 'baileys').toLowerCase();
return String(getSetting('wa_gateway_type', 'gowa') || 'baileys').toLowerCase();
}
function spx(t) {

View File

@@ -933,7 +933,7 @@ export const whatsappStatus = {
_connection: 'connecting',
get connection() {
try {
const gw = getSetting('wa_gateway_type', 'baileys');
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') {
const token = getSetting('fonnte_token', '');
return token ? 'open' : 'close';
@@ -954,7 +954,7 @@ export const whatsappStatus = {
_user: null,
get user() {
try {
const gw = getSetting('wa_gateway_type', 'baileys');
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') {
return { id: 'fonnte_gateway', name: 'Fonnte Gateway' };
}
@@ -973,7 +973,7 @@ export const whatsappStatus = {
_phoneNumber: null,
get phoneNumber() {
try {
const gw = getSetting('wa_gateway_type', 'baileys');
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') return 'Fonnte Gateway';
if (gw === 'meta') return getSetting('meta_phone_number_id', 'Meta API');
if (gw === 'gowa') return getSetting('gowa_device_id', '') || 'GOWA Gateway';
@@ -986,7 +986,7 @@ export const whatsappStatus = {
_pushName: null,
get pushName() {
try {
const gw = getSetting('wa_gateway_type', 'baileys');
const gw = getSetting('wa_gateway_type', 'gowa');
if (gw === 'fonnte') return 'Fonnte Gateway';
if (gw === 'meta') return 'Meta Cloud API';
if (gw === 'gowa') return 'GOWA Gateway';
@@ -1032,7 +1032,7 @@ function loadWhatsappAdminSendList() {
* @param {string} priority - Priority level: 'high', 'medium', 'low'
*/
export async function sendMonitoringAlert(message, priority = 'medium') {
const gatewayType = getSetting('wa_gateway_type', 'baileys');
const gatewayType = getSetting('wa_gateway_type', 'gowa');
if (gatewayType === 'baileys' && (!currentSock || whatsappStatus._connection !== 'open')) {
logger.warn('[WhatsApp] Bot belum siap (koneksi belum terbuka), tidak dapat mengirim alert monitoring');
return { success: false, message: 'Bot belum siap' };
@@ -1139,7 +1139,7 @@ export async function simulateHumanTyping(sock, jid, text = '') {
}
export async function sendWA(to, text, options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'baileys');
const gatewayType = getSetting('wa_gateway_type', 'gowa');
let finalText = text;
if (options.spintax !== false && typeof text === 'string' && text.includes('{') && text.includes('}')) {
@@ -1219,7 +1219,7 @@ export async function sendWA(to, text, options = {}) {
}
export async function sendWAImage(to, imageBuffer, caption = '', options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'baileys');
const gatewayType = getSetting('wa_gateway_type', 'gowa');
let finalCaption = caption;
if (options.spintax !== false && typeof caption === 'string' && caption.includes('{') && caption.includes('}')) {
@@ -1286,7 +1286,7 @@ export async function sendWAImage(to, imageBuffer, caption = '', options = {}) {
}
export async function sendWADocument(to, documentBuffer, filename = 'Invoice.pdf', caption = '', mimetype = 'application/pdf', options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'baileys');
const gatewayType = getSetting('wa_gateway_type', 'gowa');
let finalCaption = caption;
if (options.spintax !== false && typeof caption === 'string' && caption.includes('{') && caption.includes('}')) {

View File

@@ -140,7 +140,7 @@ function formatUnpaidInvoicesSummary(unpaidInvoices) {
* @param {object} options Opsi tambahan: { templateName, parameters, langCode, url, filename }
*/
async function sendWhatsAppMessage(toPhone, messageText, options = {}) {
const gatewayType = getSetting('wa_gateway_type', 'baileys'); // 'baileys', 'fonnte', or 'meta'
const gatewayType = getSetting('wa_gateway_type', 'gowa'); // 'baileys', 'fonnte', or 'meta'
if (gatewayType === 'fonnte') {
// Mode FONNTE API GATEWAY (Cloud / Self-Hosted)

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

@@ -1 +1 @@
B 4.8.1-R18
0.5.3

View File

@@ -174,7 +174,7 @@
<span class="text-muted" style="font-size:12px;">-</span>
<% } %>
</td>
<td><code><%= d.ip %></code></td>
<td><code><%= d.ip || d.pppoe_ip || '-' %></code></td>
<td><%= d.rx_power %> dBm</td>
<td><%= d.pppoe_user %></td>
<td><span class="acs-server-tag"><%= d.acs_server_name %></span></td>

View File

@@ -247,7 +247,7 @@
<td class="text-muted"><%= i+1 %></td>
<td class="fw6">
<a href="javascript:void(0)" onclick="viewCustDetail(<%= i %>)" style="color:var(--primary);text-decoration:none;font-weight:700;display:inline-flex;align-items:center;gap:5px;cursor:pointer;" title="<%= t('admin.cust.tt_detail','Klik untuk lihat detail pelanggan & MikroTik') %>">
<%= c.name %>
<%= c.name %><% var _miss=[]; if(!c.phone)_miss.push('HP'); if(!c.lat||!c.lng)_miss.push('koordinat'); if(!c.genieacs_tag)_miss.push('ACS'); %><% if(_miss.length===0){ %><i class="bi bi-check-circle-fill" style="font-size:12px;color:#22c55e;vertical-align:middle;" title="<%= t('admin.customers.data_ok','Data lengkap') %>"></i><% } else { %><i class="bi bi-x-circle-fill" style="font-size:12px;color:#ef4444;vertical-align:middle;" title="<%= t('admin.customers.data_incomplete','Belum lengkap') %>: <%- _miss.join(', ') %>"></i><% } %>
<i class="bi bi-info-circle-fill" style="font-size:12px;opacity:0.75;"></i>
</a>
<% if (c.customer_code) { %><div style="font-size:11px;font-weight:700;color:var(--muted);margin-top:2px;display:flex;align-items:center;gap:4px;" title="ID Pelanggan"><i class="bi bi-person-badge"></i> <%= c.customer_code %></div><% } %>

View File

@@ -93,6 +93,9 @@
<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 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>
</div>
</div>
@@ -250,6 +253,76 @@
</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>
var I18N_INV = {
addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>",
@@ -288,6 +361,48 @@
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 filterItems(q) {

View File

@@ -568,13 +568,21 @@
</td>
</tr>
<tr>
<td>WhatsApp Bot</td>
<td>WhatsApp Bot<div class="sc-label" style="font-size:11px"><%= dependencies.whatsapp.message %></div></td>
<td class="text-right">
<span class="badge <%= dependencies.whatsapp.status === 'online' ? 'bs' : 'bd' %>">
<%= dependencies.whatsapp.status.toUpperCase() %>
</span>
</td>
</tr>
<tr>
<td>Telegram Bot<div class="sc-label" style="font-size:11px"><%= dependencies.telegram ? dependencies.telegram.message : '' %></div></td>
<td class="text-right">
<span class="badge <%= (dependencies.telegram && dependencies.telegram.status === 'online') ? 'bs' : 'bd' %>">
<%= dependencies.telegram ? dependencies.telegram.status.toUpperCase() : 'UNKNOWN' %>
</span>
</td>
</tr>
</table>
<p class="sc-label mt16 mb8">Router MikroTik</p>

View File

@@ -129,7 +129,7 @@ document.addEventListener('DOMContentLoaded', () => {
<i class="bi bi-box-arrow-left"></i><span><%= t('common.logout', 'Logout') %></span>
</a>
<div class="mt-4 px-3 py-2 text-center" style="border-top: 1px solid rgba(255,255,255,0.1);">
<a href="https://github.com/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
OPEN-ISP BILLING SYSTEM
</a>
<div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5">
@@ -163,8 +163,8 @@ document.addEventListener('DOMContentLoaded', () => {
<%= t('admin.lock.code_label','Kode aktivasi menu ini') %> <strong style="color:#86efac">GRATIS</strong> <%= t('admin.lock.wiki_note','— tanpa perlu donasi untuk membukanya.') %>
</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:8px">
<a href="https://github.com/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
<i class="bi bi-github"></i> <%= t('admin.lock.get_code','Ambil Kode Aktivasi (Gratis)') %>
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
<i class="bi bi-git"></i> <%= t('admin.lock.get_code','Ambil Kode Aktivasi (Gratis)') %>
</a>
<a href="/admin/sidebar-settings" class="btn btn-g" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px" onclick="hideLockedMenuNotice()">
<i class="bi bi-layout-sidebar-inset"></i> <%= t('admin.lock.open_settings','Buka Pengaturan Sidebar') %>

View File

@@ -415,8 +415,8 @@
/radius incoming set accept=yes port=3799
# 3. Tambahkan Server RADIUS Billing ke MikroTik
/radius remove [find comment="RTRW-Billing-RADIUS"]
/radius add service=ppp address=<%= typeof serverIp !== 'undefined' ? serverIp : '192.168.8.88' %> secret="<%= radiusStatus.secret %>" authentication-port=<%= radiusStatus.authPort %> accounting-port=<%= radiusStatus.acctPort %> timeout=3000ms comment="RTRW-Billing-RADIUS"
/radius remove [find comment="OPEN-ISP-Billing-RADIUS"]
/radius add service=ppp address=<%= typeof serverIp !== 'undefined' ? serverIp : '192.168.8.88' %> secret="<%= radiusStatus.secret %>" authentication-port=<%= radiusStatus.authPort %> accounting-port=<%= radiusStatus.acctPort %> timeout=3000ms comment="OPEN-ISP-Billing-RADIUS"
# 4. Tambahkan IP Address Gateway Subnet Isolir RADIUS
/ip address remove [find comment="Gateway-Isolir-RADIUS"]

View File

@@ -208,6 +208,55 @@
</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-hd"><h6><i class="bi bi-sliders"></i> <%= t('admin.settings.system_title', 'Pengaturan Sistem') %></h6></div>
<div class="card-bd settings-grid">
@@ -590,8 +639,8 @@
}
</script>
<div class="fg">
<label class="fl"><%= t('admin.set.repo_link','Link Repositori Proyek (GitHub)') %></label>
<input class="fc" name="project_url" value="<%= settings.project_url || 'https://github.com/oxpoe/open-isp' %>" placeholder="https://github.com/oxpoe/open-isp">
<label class="fl"><%= t('admin.set.repo_link','Link Repositori Proyek (Gitea)') %></label>
<input class="fc" name="project_url" value="<%= settings.project_url || 'https://gitea.oxpoe.qzz.io/oxpoe/open-isp' %>" placeholder="https://gitea.oxpoe.qzz.io/oxpoe/open-isp">
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.repo_hint','Dipakai untuk tautan ') %><b>Repo</b>, <b>Form/Issue</b> (<code>/issues/new/choose</code>), <b>Wiki</b>, dan <b>Lisensi</b> di landing page.</div>
</div>
<div class="fg">
@@ -1187,5 +1236,20 @@ function setSearch(q) {
document.querySelectorAll('.set-tab').forEach(function (b) { b.addEventListener('click', function () { setSetTab(this.getAttribute('data-cat')); }); });
})();
</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>
</html>

View File

@@ -10,7 +10,7 @@
<%- include('partials/sidebar', { activePage: activePage || 'update', company }) %>
<div class="mw">
<div class="topbar">
<div class="tb-title"><button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button><i class="bi bi-cloud-arrow-down"></i> <%= t('admin.update.title', 'Update GitHub') %></div>
<div class="tb-title"><button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button><i class="bi bi-cloud-arrow-down"></i> <%= t('admin.update.title', 'Update Gitea') %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> <%= t('common.logout', 'Logout') %></a>
@@ -29,6 +29,17 @@
<% if (info && info.error) { %>
<div class="alert alert-d"><i class="bi bi-exclamation-circle"></i> <%= info.error %></div>
<% } %>
<% if (info && info.container) { %>
<div class="alert" style="background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.3);border-radius:10px;padding:12px 14px;font-size:13px;line-height:1.55">
<b><i class="bi bi-box-seam"></i> <%= t('admin.update.container_title', 'Instalasi Container (tanpa .git)') %></b><br>
<%= t('admin.update.container_msg', 'Pembaruan TIDAK lewat git. Bangun ulang image dari repo terbaru lalu ganti container:') %>
<div style="margin-top:8px;font-family:monospace;font-size:12px;background:rgba(0,0,0,.25);padding:8px 10px;border-radius:8px">
docker compose build &amp;&amp; docker compose up -d<br>
# atau: build image baru, lalu ganti tag/container
</div>
<%= t('admin.update.container_safe', 'Data (settings.json, database, uploads) tetap aman bila disimpan di volume/mount.') %>
</div>
<% } %>
<div class="form-row" style="align-items:flex-start">
<div class="fg">
<label class="fl"><%= t('admin.update.server_version', 'Versi Server (version.txt)') %></label>
@@ -38,9 +49,9 @@
</div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.update.github_version_prefix', 'Versi GitHub (') %><%= info?.repo || 'origin' %>/<%= info?.branch || '-' %><%= t('admin.update.github_version_suffix', ')') %></label>
<label class="fl"><%= t('admin.update.gitea_version_prefix', 'Versi Gitea (') %><%= info?.repo || 'origin' %>/<%= info?.branch || '-' %><%= t('admin.update.gitea_version_suffix', ')') %></label>
<div class="fc" style="display:flex;align-items:center;gap:8px">
<i class="bi bi-github"></i>
<i class="bi bi-git"></i>
<span style="font-weight:800"><%= info?.remoteVersion || '-' %></span>
<% if (info?.needsUpdate) { %>
<span class="badge badge-d" style="margin-left:8px"><i class="bi bi-arrow-up-circle"></i> <%= t('admin.update.has_update', 'Ada update') %></span>
@@ -51,6 +62,7 @@
</div>
</div>
<% if (!(info && info.container)) { %>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
<form method="GET" action="/admin/update">
<button type="submit" class="btn btn-g"><i class="bi bi-arrow-repeat"></i> <%= t('admin.update.check', 'Cek Versi') %></button>
@@ -63,6 +75,7 @@
<div style="margin-top:12px;color:var(--muted);font-size:12px;line-height:1.5">
<%= t('admin.update.keep_files', 'File yang tidak ditimpa saat update: settings.json dan folder database. Setelah update selesai, restart aplikasi agar versi baru aktif.') %>
</div>
<% } %>
</div>
</div>
@@ -79,7 +92,7 @@
<script>
function confirmUpdate() {
return confirm("<%= t('admin.update.confirm', 'Jalankan update dari GitHub sekarang? Pastikan koneksi internet aman dan aplikasi akan direstart setelah selesai.') %>");
return confirm("<%= t('admin.update.confirm', 'Jalankan update dari Gitea sekarang? Pastikan koneksi internet aman dan aplikasi akan direstart setelah selesai.') %>");
}
</script>
</body>

View File

@@ -51,9 +51,9 @@
<a href="/admin/whatsapp/live-chat" class="wa-tab <%= waTab==='livechat' ? 'active' : '' %>"><i class="bi bi-chat-dots-fill"></i> <%= t('admin.wa.tab_livechat','Live Chat') %></a>
</div>
<!-- PENGATURAN HYBRID GATEWAY (BAILEYS VS FONNTE VS META API) -->
<!-- PENGATURAN GATEWAY (GOWA / FONNTE / META API) -->
<div class="card mb16" style="padding: 20px;">
<h6 style="margin-bottom: 15px; font-weight: 700; color: var(--primary);"><i class="bi bi-sliders"></i> Mode WhatsApp Gateway (Baileys / Fonnte / GOWA / Meta API)</h6>
<h6 style="margin-bottom: 15px; font-weight: 700; color: var(--primary);"><i class="bi bi-sliders"></i> Mode WhatsApp Gateway (GoWA / Fonnte / Meta API)</h6>
<form method="POST" action="/admin/whatsapp/gateway-settings">
<div class="form-row mb16">
@@ -334,14 +334,6 @@ async function sendTestGowa() {
<h2 id="status-text"><%= t('admin.whatsapp.checking', 'Mengecek status...') %></h2>
<p id="status-desc" class="text-muted"><%= t('admin.whatsapp.please_wait', 'Harap tunggu sebentar.') %></p>
<% if (waGatewayType === 'baileys') { %>
<div id="qr-section" style="display:none">
<p class="mb-3"><%= t('admin.whatsapp.qr_instruction', 'Silakan pindai QR Code ini dengan aplikasi WhatsApp Anda:') %></p>
<div id="qr-container"></div>
<p class="mt-3 text-sm text-muted"><%= t('admin.whatsapp.qr_auto_refresh', 'QR Code akan diperbarui secara otomatis.') %></p>
</div>
<% } %>
<div id="info-section" style="display:none" class="wa-info">
<div class="alert alert-s">
<i class="bi bi-check-circle-fill"></i> <%= t('admin.whatsapp.connected_title', 'WhatsApp Terhubung') %>
@@ -355,14 +347,6 @@ async function sendTestGowa() {
<form method="POST" action="/admin/whatsapp/test-notification" onsubmit="return confirm('<%= t('admin.whatsapp.confirm_test', 'Kirim test notifikasi WhatsApp ke admin sekarang?') %>');">
<button class="btn btn-p"><i class="bi bi-send-check"></i> <%= t('admin.whatsapp.test_notification', 'Kirim Test Notif') %></button>
</form>
<% if (waGatewayType === 'baileys') { %>
<form method="POST" action="/admin/whatsapp/repair-keys" onsubmit="return confirm('Bersihkan cache pre-keys & sesi usang untuk mengatasi masalah pesan pending / Bad-MAC tanpa perlu scan QR ulang?');">
<button class="btn btn-g" style="border-color: #3b82f6; color: #2563eb;"><i class="bi bi-wrench-adjustable"></i> Perbaiki Sesi (Anti Waiting)</button>
</form>
<form method="POST" action="/admin/whatsapp/reset" onsubmit="return confirm('<%= t('admin.whatsapp.confirm_reset', 'Apakah Anda yakin ingin menghapus sesi WhatsApp? Anda harus scan QR ulang setelah ini.') %>')">
<button class="btn btn-d"><i class="bi bi-trash"></i> <%= t('admin.whatsapp.reset_session', 'Hapus Sesi / Reset') %></button>
</form>
<% } %>
</div>
</div>
</div>

View File

@@ -37,7 +37,7 @@
<p class="iso-sub"><%= settings.company_header || 'OPEN-ISP' %> • <%= t('donate.tagline','Donasi sukarela untuk pengembangan aplikasi open source (ISC)') %></p>
<div style="margin-top:14px;padding:12px 14px;border-radius:14px;background:rgba(173,255,47,.08);border:1px solid rgba(173,255,47,.25);text-align:left;font-size:12.5px;color:#cfe9a0">
<i class="bi bi-info-circle"></i> <strong><%= t('donate.menu_code_free','Kode aktivasi menu tersedia GRATIS') %></strong> <%= t('donate.free_hint','untuk semua — donasi') %> <strong><%= t('donate.not_required','tidak wajib') %></strong>.
<a href="https://github.com/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
</div>
</div>
@@ -159,10 +159,10 @@
<!-- AKTIVASI PLACEHOLDER -->
<div id="activationPlaceholder" class="activation-card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">
<span class="badge bp"><i class="bi bi-github"></i> <%= t('donate.code_free','KODE AKTIVASI GRATIS') %></span>
<span class="badge bp"><i class="bi bi-git"></i> <%= t('donate.code_free','KODE AKTIVASI GRATIS') %></span>
<span style="font-weight:700;font-size:14px"><%= t('donate.status','Status:') %> <span style="color:var(--warning);font-weight:800" id="statusText"><%= t('donate.waiting_donation','Menunggu Donasi Masuk') %></span></span>
</div>
<p class="muted" style="font-size:13.5px;margin:0"><i class="bi bi-info-circle" style="color:var(--info)"></i> <%= t('donate.menu_code','Kode aktivasi menu') %> <strong style="color:var(--primary)"><%= t('donate.free','GRATIS') %></strong> di <a href="https://github.com/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:var(--primary)"><%= t('donate.wiki','Wiki OPEN-ISP') %></a>. Donasi bersifat sukarela; jika berdonasi, kode juga otomatis dikirim ke WhatsApp Anda begitu pembayaran terdeteksi.</p>
<p class="muted" style="font-size:13.5px;margin:0"><i class="bi bi-info-circle" style="color:var(--info)"></i> <%= t('donate.menu_code','Kode aktivasi menu') %> <strong style="color:var(--primary)"><%= t('donate.free','GRATIS') %></strong> di <a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:var(--primary)"><%= t('donate.wiki','Wiki OPEN-ISP') %></a>. Donasi bersifat sukarela; jika berdonasi, kode juga otomatis dikirim ke WhatsApp Anda begitu pembayaran terdeteksi.</p>
</div>
<!-- AKTIVASI CARD -->

View File

@@ -46,7 +46,7 @@
<a href="/lang/id" style="<%= (typeof lang !== 'undefined' && lang === 'id') ? 'color:var(--primary);font-weight:700' : '' %>">ID</a>
<a href="/lang/en" style="<%= (typeof lang !== 'undefined' && lang === 'en') ? 'color:var(--primary);font-weight:700' : '' %>">EN</a>
<% if (_upt) { %><a href="<%= _upt %>" target="_blank" rel="noopener"><i class="bi bi-activity"></i> <%= t('landing.nav.status','Status') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>" target="_blank" rel="noopener" title="Repositori Open Source"><i class="bi bi-github"></i> <%= t('landing.footer.github','GitHub') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>" target="_blank" rel="noopener" title="Repositori Open Source"><i class="bi bi-git"></i> <%= t('landing.footer.gitea','Gitea') %></a><% } %>
</nav>
<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>
@@ -64,7 +64,7 @@
<a href="#testimoni" onclick="toggleMenu()"><%= t('landing.testi.eyebrow','Testimoni') %></a>
<a href="#faq" onclick="toggleMenu()"><%= t('landing.nav.faq','FAQ') %></a>
<% if (_upt) { %><a href="<%= _upt %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> <%= t('landing.footer.server_status','Status Server') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-github"></i> <%= t('landing.footer.repo_gh','Repositori (GitHub)') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-git"></i> <%= t('landing.footer.repo_gitea','Repositori (Gitea)') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>/wiki" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-journal-text"></i> <%= t('landing.footer.wiki2','Wiki / Dokumentasi') %></a><% } %>
<a href="/customer/login"><i class="bi bi-box-arrow-in-right"></i> <%= t('landing.nav.cust_login','Masuk Pelanggan') %></a>
<a href="/customer/check-billing"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
@@ -332,7 +332,7 @@
<div>
<h4><%= t('landing.footer.opensource','Open Source') %></h4>
<div class="foot-links">
<a href="<%= projectUrl %>" target="_blank" rel="noopener"><i class="bi bi-github"></i> <%= t('landing.footer.repo','Repositori') %></a>
<a href="<%= projectUrl %>" target="_blank" rel="noopener"><i class="bi bi-git"></i> <%= t('landing.footer.repo','Repositori') %></a>
<a href="<%= projectUrl %>/issues/new/choose" target="_blank" rel="noopener"><i class="bi bi-bug"></i> <%= t('landing.footer.issues','Laporkan Masalah / Form') %></a>
<a href="<%= projectUrl %>/wiki" target="_blank" rel="noopener"><i class="bi bi-journal-text"></i> <%= t('landing.footer.wiki','Wiki & Dokumentasi') %></a>
<a href="<%= projectUrl %>/blob/main/LICENSE" target="_blank" rel="noopener"><i class="bi bi-shield-check"></i> <%= t('landing.footer.license','Lisensi ISC') %></a>

View File

@@ -43,7 +43,8 @@
<a href="#features"><%= t('landing.nav.features','Keunggulan') %></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 (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>
<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>
@@ -59,7 +60,7 @@
<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><% } %>
<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>
</header>
@@ -223,7 +224,7 @@
<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/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>